@gitlab/ui 78.1.0 → 78.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [78.1.1](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.1.0...v78.1.1) (2024-03-05)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **GlDuoChat:** allow typing prompt while streaming ([bc107bf](https://gitlab.com/gitlab-org/gitlab-ui/commit/bc107bf7b4e84ec787142ed5b43f299ec6c7ea0b))
7
+
1
8
  # [78.1.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.0.0...v78.1.0) (2024-03-05)
2
9
 
3
10
 
@@ -201,12 +201,22 @@ var script = {
201
201
  return acc;
202
202
  }, [[]]);
203
203
  },
204
+ lastMessage() {
205
+ return this.messages[this.messages.length - 1];
206
+ },
204
207
  resetDisabled() {
208
+ var _this$lastMessage;
205
209
  if (this.isLoading || !this.hasMessages) {
206
210
  return true;
207
211
  }
208
- const lastMessage = this.messages[this.messages.length - 1];
209
- return lastMessage.content === CHAT_RESET_MESSAGE;
212
+ return ((_this$lastMessage = this.lastMessage) === null || _this$lastMessage === void 0 ? void 0 : _this$lastMessage.content) === CHAT_RESET_MESSAGE;
213
+ },
214
+ submitDisabled() {
215
+ return this.isLoading || this.isStreaming;
216
+ },
217
+ isStreaming() {
218
+ var _this$lastMessage2, _this$lastMessage2$ch, _this$lastMessage3;
219
+ return Boolean(((_this$lastMessage2 = this.lastMessage) === null || _this$lastMessage2 === void 0 ? void 0 : (_this$lastMessage2$ch = _this$lastMessage2.chunks) === null || _this$lastMessage2$ch === void 0 ? void 0 : _this$lastMessage2$ch.length) > 0 && !((_this$lastMessage3 = this.lastMessage) !== null && _this$lastMessage3 !== void 0 && _this$lastMessage3.content));
210
220
  },
211
221
  filteredSlashCommands() {
212
222
  const caseInsensitivePrompt = this.prompt.toLowerCase();
@@ -227,12 +237,13 @@ var script = {
227
237
  }
228
238
  },
229
239
  watch: {
230
- isLoading() {
240
+ isLoading(newVal) {
231
241
  this.isHidden = false;
232
242
  this.scrollToBottom();
233
- },
234
- messages() {
235
- this.prompt = '';
243
+ if (newVal) {
244
+ // We reset the prompt when we start getting the response and focus in the prompt field
245
+ this.setPromptAndFocus();
246
+ }
236
247
  }
237
248
  },
238
249
  created() {
@@ -250,6 +261,9 @@ var script = {
250
261
  this.$emit('chat-hidden');
251
262
  },
252
263
  sendChatPrompt() {
264
+ if (this.submitDisabled) {
265
+ return;
266
+ }
253
267
  if (this.prompt) {
254
268
  if (this.prompt === CHAT_RESET_MESSAGE && this.resetDisabled) {
255
269
  return;
@@ -324,14 +338,19 @@ var script = {
324
338
  this.activeCommandIndex = 0;
325
339
  }
326
340
  },
341
+ async setPromptAndFocus() {
342
+ let prompt = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
343
+ this.prompt = prompt;
344
+ await this.$nextTick();
345
+ this.$refs.prompt.$el.focus();
346
+ },
327
347
  selectSlashCommand(index) {
328
348
  const command = this.filteredSlashCommands[index];
329
349
  if (command.shouldSubmit) {
330
350
  this.prompt = command.name;
331
351
  this.sendChatPrompt();
332
352
  } else {
333
- this.prompt = `${command.name} `;
334
- this.$refs.prompt.$el.focus();
353
+ this.setPromptAndFocus(`${command.name} `);
335
354
  }
336
355
  }
337
356
  },
@@ -347,7 +366,7 @@ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=
347
366
  {
348
367
  'gl-h-full': !_vm.hasMessages,
349
368
  'force-scroll-bar': _vm.hasMessages,
350
- } ],attrs:{"tag":"section","name":"message"}},[_vm._l((_vm.conversations),function(conversation,index){return _c('gl-duo-chat-conversation',{key:("conversation-" + index),attrs:{"messages":conversation,"show-delimiter":index > 0},on:{"track-feedback":_vm.onTrackFeedback}})}),_vm._v(" "),(!_vm.hasMessages && !_vm.isLoading)?[_c('gl-empty-state',{key:"empty-state",staticClass:"gl-flex-grow gl-justify-content-center",attrs:{"svg-path":_vm.$options.emptySvg,"svg-height":145,"title":_vm.emptyStateTitle,"description":_vm.emptyStateDescription}}),_vm._v(" "),_c('gl-duo-chat-predefined-prompts',{key:"predefined-prompts",attrs:{"prompts":_vm.predefinedPrompts},on:{"click":_vm.sendPredefinedPrompt}})]:_vm._e(),_vm._v(" "),(_vm.isLoading)?_c('gl-duo-chat-loader',{key:"loader",attrs:{"tool-name":_vm.toolName}}):_vm._e(),_vm._v(" "),_c('div',{key:"anchor",ref:"anchor",staticClass:"scroll-anchor"})],2)],1),_vm._v(" "),(_vm.isChatAvailable)?_c('footer',{staticClass:"gl-drawer-footer gl-drawer-footer-sticky gl-p-5 gl-border-t gl-bg-gray-10",class:{ 'gl-drawer-body-scrim-on-footer': !_vm.scrolledToBottom },attrs:{"data-testid":"chat-footer"}},[_c('gl-form',{attrs:{"data-testid":"chat-prompt-form"},on:{"submit":function($event){$event.stopPropagation();$event.preventDefault();return _vm.sendChatPrompt.apply(null, arguments)}}},[_c('gl-form-input-group',{scopedSlots:_vm._u([{key:"append",fn:function(){return [_c('gl-button',{staticClass:"!gl-absolute gl-bottom-2 gl-right-2 gl-rounded-base!",attrs:{"icon":"paper-airplane","category":"primary","variant":"confirm","type":"submit","aria-label":_vm.$options.i18n.CHAT_SUBMIT_LABEL,"disabled":_vm.isLoading}})]},proxy:true}],null,false,3929529036)},[_c('div',{staticClass:"duo-chat-input gl-flex-grow-1 gl-vertical-align-top gl-max-w-full gl-min-h-8 gl-inset-border-1-gray-400 gl-rounded-base gl-bg-white",attrs:{"data-value":_vm.prompt}},[(_vm.shouldShowSlashCommands)?_c('gl-card',{ref:"commands",staticClass:"slash-commands !gl-absolute gl-translate-y-n100 gl-list-style-none gl-pl-0 gl-w-full gl-shadow-md",attrs:{"body-class":"gl-p-2!"}},_vm._l((_vm.filteredSlashCommands),function(command,index){return _c('gl-dropdown-item',{key:command.name,class:{ 'active-command': index === _vm.activeCommandIndex },on:{"click":function($event){return _vm.selectSlashCommand(index)}},nativeOn:{"mouseenter":function($event){_vm.activeCommandIndex = index;}}},[_c('span',{staticClass:"gl-display-flex gl-justify-content-space-between"},[_c('span',{staticClass:"gl-display-block"},[_vm._v(_vm._s(command.name))]),_vm._v(" "),_c('small',{staticClass:"gl-text-gray-500 gl-font-style-italic gl-text-right gl-pl-3"},[_vm._v(_vm._s(command.description))])])])}),1):_vm._e(),_vm._v(" "),_c('gl-form-textarea',{ref:"prompt",staticClass:"gl-absolute gl-h-full! gl-py-4! gl-bg-transparent! gl-rounded-top-right-none gl-rounded-bottom-right-none gl-shadow-none!",class:{ 'gl-text-truncate': !_vm.prompt },attrs:{"data-testid":"chat-prompt-input","placeholder":_vm.inputPlaceholder,"disabled":_vm.isLoading,"autofocus":""},nativeOn:{"keydown":function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"enter",13,$event.key,"Enter")){ return null; }if($event.ctrlKey||$event.shiftKey||$event.altKey||$event.metaKey){ return null; }$event.preventDefault();},"keyup":function($event){return _vm.onInputKeyup.apply(null, arguments)}},model:{value:(_vm.prompt),callback:function ($$v) {_vm.prompt=$$v;},expression:"prompt"}})],1)]),_vm._v(" "),_c('gl-form-text',{staticClass:"gl-text-gray-400 gl-line-height-20 gl-mt-3",attrs:{"data-testid":"chat-legal-disclaimer"}},[_vm._v(_vm._s(_vm.$options.i18n.CHAT_LEGAL_DISCLAIMER))])],1)],1):_vm._e()]):_vm._e()};
369
+ } ],attrs:{"tag":"section","name":"message"}},[_vm._l((_vm.conversations),function(conversation,index){return _c('gl-duo-chat-conversation',{key:("conversation-" + index),attrs:{"messages":conversation,"show-delimiter":index > 0},on:{"track-feedback":_vm.onTrackFeedback}})}),_vm._v(" "),(!_vm.hasMessages && !_vm.isLoading)?[_c('gl-empty-state',{key:"empty-state",staticClass:"gl-flex-grow gl-justify-content-center",attrs:{"svg-path":_vm.$options.emptySvg,"svg-height":145,"title":_vm.emptyStateTitle,"description":_vm.emptyStateDescription}}),_vm._v(" "),_c('gl-duo-chat-predefined-prompts',{key:"predefined-prompts",attrs:{"prompts":_vm.predefinedPrompts},on:{"click":_vm.sendPredefinedPrompt}})]:_vm._e(),_vm._v(" "),(_vm.isLoading)?_c('gl-duo-chat-loader',{key:"loader",attrs:{"tool-name":_vm.toolName}}):_vm._e(),_vm._v(" "),_c('div',{key:"anchor",ref:"anchor",staticClass:"scroll-anchor"})],2)],1),_vm._v(" "),(_vm.isChatAvailable)?_c('footer',{staticClass:"gl-drawer-footer gl-drawer-footer-sticky gl-p-5 gl-border-t gl-bg-gray-10",class:{ 'gl-drawer-body-scrim-on-footer': !_vm.scrolledToBottom },attrs:{"data-testid":"chat-footer"}},[_c('gl-form',{attrs:{"data-testid":"chat-prompt-form"},on:{"submit":function($event){$event.stopPropagation();$event.preventDefault();return _vm.sendChatPrompt.apply(null, arguments)}}},[_c('gl-form-input-group',{scopedSlots:_vm._u([{key:"append",fn:function(){return [_c('gl-button',{staticClass:"!gl-absolute gl-bottom-2 gl-right-2 gl-rounded-base!",attrs:{"icon":"paper-airplane","category":"primary","variant":"confirm","type":"submit","data-testid":"chat-prompt-submit-button","aria-label":_vm.$options.i18n.CHAT_SUBMIT_LABEL,"disabled":_vm.submitDisabled}})]},proxy:true}],null,false,3132459889)},[_c('div',{staticClass:"duo-chat-input gl-flex-grow-1 gl-vertical-align-top gl-max-w-full gl-min-h-8 gl-inset-border-1-gray-400 gl-rounded-base gl-bg-white",attrs:{"data-value":_vm.prompt}},[(_vm.shouldShowSlashCommands)?_c('gl-card',{ref:"commands",staticClass:"slash-commands !gl-absolute gl-translate-y-n100 gl-list-style-none gl-pl-0 gl-w-full gl-shadow-md",attrs:{"body-class":"gl-p-2!"}},_vm._l((_vm.filteredSlashCommands),function(command,index){return _c('gl-dropdown-item',{key:command.name,class:{ 'active-command': index === _vm.activeCommandIndex },on:{"click":function($event){return _vm.selectSlashCommand(index)}},nativeOn:{"mouseenter":function($event){_vm.activeCommandIndex = index;}}},[_c('span',{staticClass:"gl-display-flex gl-justify-content-space-between"},[_c('span',{staticClass:"gl-display-block"},[_vm._v(_vm._s(command.name))]),_vm._v(" "),_c('small',{staticClass:"gl-text-gray-500 gl-font-style-italic gl-text-right gl-pl-3"},[_vm._v(_vm._s(command.description))])])])}),1):_vm._e(),_vm._v(" "),_c('gl-form-textarea',{ref:"prompt",staticClass:"gl-absolute gl-h-full! gl-py-4! gl-bg-transparent! gl-rounded-top-right-none gl-rounded-bottom-right-none gl-shadow-none!",class:{ 'gl-text-truncate': !_vm.prompt },attrs:{"data-testid":"chat-prompt-input","placeholder":_vm.inputPlaceholder,"autofocus":""},nativeOn:{"keydown":function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"enter",13,$event.key,"Enter")){ return null; }if($event.ctrlKey||$event.shiftKey||$event.altKey||$event.metaKey){ return null; }$event.preventDefault();},"keyup":function($event){return _vm.onInputKeyup.apply(null, arguments)}},model:{value:(_vm.prompt),callback:function ($$v) {_vm.prompt=$$v;},expression:"prompt"}})],1)]),_vm._v(" "),_c('gl-form-text',{staticClass:"gl-text-gray-400 gl-line-height-20 gl-mt-3",attrs:{"data-testid":"chat-legal-disclaimer"}},[_vm._v(_vm._s(_vm.$options.i18n.CHAT_LEGAL_DISCLAIMER))])],1)],1):_vm._e()]):_vm._e()};
351
370
  var __vue_staticRenderFns__ = [];
352
371
 
353
372
  /* style */
@@ -1,3 +1,4 @@
1
+ import { setStoryTimeout } from '../../../../utils/test_utils';
1
2
  import { DOCUMENTATION_SOURCE_TYPES, MESSAGE_MODEL_ROLES } from './constants';
2
3
 
3
4
  const MOCK_SOURCES = [{
@@ -57,21 +58,47 @@ const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
57
58
  errors: [],
58
59
  timestamp: '2021-04-21T12:00:00.000Z'
59
60
  };
60
- const generateMockResponseChunks = requestId => {
61
- const chunks = [];
62
- const chunkSize = 5;
63
- const chunkCount = Math.ceil(MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.length / chunkSize);
64
- for (let i = 0; i < chunkCount; i += 1) {
65
- const chunk = {
66
- ...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
67
- requestId,
68
- content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.substring(i * chunkSize, (i + 1) * chunkSize),
69
- chunkId: i
70
- };
71
- chunks.push(chunk);
61
+
62
+ // Utility function for delay
63
+ async function delayRandom() {
64
+ let min = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 16;
65
+ let max = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 267;
66
+ const delay = Math.floor(Math.random() * (max - min + 1)) + min;
67
+ // eslint-disable-next-line no-promise-executor-return
68
+ return new Promise(resolve => setStoryTimeout(resolve, delay));
69
+ }
70
+ function generateMockResponseChunks() {
71
+ try {
72
+ let requestId = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
73
+ return async function* () {
74
+ const chunkSize = 5;
75
+ const contentLength = MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.length;
76
+ const chunkCount = Math.ceil(contentLength / chunkSize);
77
+ for (let chunkId = 0; chunkId < chunkCount; chunkId += 1) {
78
+ const start = chunkId * chunkSize;
79
+ const end = Math.min((chunkId + 1) * chunkSize, contentLength);
80
+ const chunk = {
81
+ ...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
82
+ requestId,
83
+ content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.substring(start, end),
84
+ chunkId
85
+ };
86
+
87
+ // eslint-disable-next-line no-await-in-loop
88
+ await delayRandom();
89
+ yield chunk;
90
+ }
91
+ yield {
92
+ ...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
93
+ requestId,
94
+ content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content,
95
+ chunkId: null
96
+ };
97
+ }();
98
+ } catch (e) {
99
+ return Promise.reject(e);
72
100
  }
73
- return chunks;
74
- };
101
+ }
75
102
  const MOCK_USER_PROMPT_MESSAGE = {
76
103
  id: '456',
77
104
  content: 'How to create a new template?',
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Tue, 05 Mar 2024 12:25:08 GMT
3
+ * Generated on Tue, 05 Mar 2024 16:29:47 GMT
4
4
  */
5
5
 
6
6
  :root {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Tue, 05 Mar 2024 12:25:08 GMT
3
+ * Generated on Tue, 05 Mar 2024 16:29:47 GMT
4
4
  */
5
5
 
6
6
  :root.gl-dark {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Tue, 05 Mar 2024 12:25:08 GMT
3
+ * Generated on Tue, 05 Mar 2024 16:29:47 GMT
4
4
  */
5
5
 
6
6
  export const DATA_VIZ_GREEN_50 = "#133a03";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Tue, 05 Mar 2024 12:25:08 GMT
3
+ * Generated on Tue, 05 Mar 2024 16:29:47 GMT
4
4
  */
5
5
 
6
6
  export const DATA_VIZ_GREEN_50 = "#ddfab7";
@@ -1,6 +1,6 @@
1
1
 
2
2
  // Do not edit directly
3
- // Generated on Tue, 05 Mar 2024 12:25:08 GMT
3
+ // Generated on Tue, 05 Mar 2024 16:29:47 GMT
4
4
 
5
5
  $gl-text-tertiary: #737278 !default;
6
6
  $gl-text-secondary: #89888d !default;
@@ -1,6 +1,6 @@
1
1
 
2
2
  // Do not edit directly
3
- // Generated on Tue, 05 Mar 2024 12:25:08 GMT
3
+ // Generated on Tue, 05 Mar 2024 16:29:47 GMT
4
4
 
5
5
  $gl-text-tertiary: #89888d !default;
6
6
  $gl-text-secondary: #737278 !default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "78.1.0",
3
+ "version": "78.1.1",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -8,6 +8,7 @@ import DuoChatLoader from './components/duo_chat_loader/duo_chat_loader.vue';
8
8
  import DuoChatPredefinedPrompts from './components/duo_chat_predefined_prompts/duo_chat_predefined_prompts.vue';
9
9
  import DuoChatConversation from './components/duo_chat_conversation/duo_chat_conversation.vue';
10
10
  import GlDuoChat from './duo_chat.vue';
11
+ import { MOCK_RESPONSE_MESSAGE, MOCK_USER_PROMPT_MESSAGE } from './mock_data';
11
12
 
12
13
  import { MESSAGE_MODEL_ROLES, CHAT_RESET_MESSAGE } from './constants';
13
14
 
@@ -339,11 +340,38 @@ describe('GlDuoChat', () => {
339
340
  data: { prompt: promptStr },
340
341
  });
341
342
  trigger();
342
- if (expectEmitted) {
343
- expect(wrapper.emitted('send-chat-prompt')).toEqual(expectEmitted);
344
- } else {
345
- expect(wrapper.emitted('send-chat-prompt')).toBe(undefined);
346
- }
343
+ expect(wrapper.emitted('send-chat-prompt')).toEqual(expectEmitted);
344
+ });
345
+
346
+ it.each`
347
+ desc | msgs
348
+ ${''} | ${[]}
349
+ ${'with just a user message'} | ${[MOCK_USER_PROMPT_MESSAGE]}
350
+ ${'with a user message, and a complete response'} | ${[MOCK_USER_PROMPT_MESSAGE, MOCK_RESPONSE_MESSAGE]}
351
+ `('prevents submission when loading $desc', ({ msgs } = {}) => {
352
+ createComponent({
353
+ propsData: { isChatAvailable: true, isLoading: true, messages: msgs },
354
+ data: { prompt: promptStr },
355
+ });
356
+ clickSubmit();
357
+ expect(wrapper.emitted('send-chat-prompt')).toBe(undefined);
358
+ });
359
+
360
+ it.each([
361
+ [[{ ...MOCK_RESPONSE_MESSAGE, content: undefined, chunks: [''] }]],
362
+ [
363
+ [
364
+ MOCK_USER_PROMPT_MESSAGE,
365
+ { ...MOCK_RESPONSE_MESSAGE, content: undefined, chunks: [''] },
366
+ ],
367
+ ],
368
+ ])('prevents submission when streaming (messages = "%o")', (msgs = []) => {
369
+ createComponent({
370
+ propsData: { isChatAvailable: true, messages: msgs },
371
+ data: { prompt: promptStr },
372
+ });
373
+ clickSubmit();
374
+ expect(wrapper.emitted('send-chat-prompt')).toBe(undefined);
347
375
  });
348
376
  });
349
377
 
@@ -428,7 +456,7 @@ describe('GlDuoChat', () => {
428
456
  expect(findChatComponent().exists()).toBe(true);
429
457
  });
430
458
 
431
- it('resets the prompt when new messages are added', async () => {
459
+ it('resets the prompt when a message is loaded', async () => {
432
460
  const prompt = 'foo';
433
461
  createComponent({ data: { prompt } });
434
462
  expect(findChatInput().props('value')).toBe(prompt);
@@ -436,7 +464,7 @@ describe('GlDuoChat', () => {
436
464
  // reactive behavior which consistutes an exception
437
465
  // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
438
466
  wrapper.setProps({
439
- messages,
467
+ isLoading: true,
440
468
  });
441
469
  await nextTick();
442
470
  expect(findChatInput().props('value')).toBe('');
@@ -1,6 +1,5 @@
1
1
  import GlButton from '../../../base/button/button.vue';
2
2
  import GlAlert from '../../../base/alert/alert.vue';
3
- import { setStoryTimeout } from '../../../../utils/test_utils';
4
3
  import { makeContainer } from '../../../../utils/story_decorators/container';
5
4
  import GlDuoChat from './duo_chat.vue';
6
5
  import readme from './duo_chat.md';
@@ -132,32 +131,52 @@ export const Interactive = (args, { argTypes }) => ({
132
131
  this.isHidden = false;
133
132
  this.loggerInfo += `Chat opened\n\n`;
134
133
  },
135
- onResponseRequested() {
134
+ async onResponseRequested() {
136
135
  this.timeout = null;
137
- this.chunks = generateMockResponseChunks(this.requestId);
138
- this.mockResponseFromAi();
136
+ await this.mockResponseFromAi();
139
137
  this.requestId += 1;
140
138
  },
141
- mockResponseFromAi() {
142
- this.promptInFlight = false;
143
- if (this.chunks.length) {
144
- const newResponse = this.chunks.shift();
139
+ async mockResponseFromAi() {
140
+ const generator = generateMockResponseChunks(this.requestId);
141
+
142
+ for await (const result of generator) {
143
+ const { chunkId, content, ...messageAttributes } = result;
145
144
  const existingMessageIndex = this.msgs.findIndex(
146
- (msg) => msg.requestId === newResponse.requestId && msg.role === newResponse.role
145
+ (msg) => msg.requestId === result.requestId && msg.role === result.role
147
146
  );
148
- const existingMessage = this.msgs[existingMessageIndex];
149
- if (existingMessage) {
150
- this.msgs.splice(existingMessageIndex, 1, {
151
- ...existingMessage,
152
- content: existingMessage.content + newResponse.content,
153
- });
147
+
148
+ if (existingMessageIndex === -1) {
149
+ this.addNewMessage(messageAttributes, content);
150
+ } else {
151
+ this.updateExistingMessage(existingMessageIndex, content, chunkId);
152
+ }
153
+ }
154
+ },
155
+ addNewMessage(messageAttributes, content) {
156
+ this.promptInFlight = false;
157
+ this.$set(this.msgs, this.msgs.length, {
158
+ ...messageAttributes,
159
+ chunks: [content],
160
+ });
161
+ },
162
+ updateExistingMessage(index, content, chunkId) {
163
+ const message = this.msgs[index];
164
+
165
+ if (chunkId != null) {
166
+ // Ensure the chunks array exists
167
+ if (!message.chunks) {
168
+ this.$set(message, 'chunks', []);
154
169
  } else {
155
- this.msgs.push(newResponse);
170
+ this.$set(message.chunks, chunkId, content);
171
+ }
172
+ } else {
173
+ // Update for final message
174
+ this.$set(message, 'content', content);
175
+
176
+ // Remove chunks if they are not needed anymore
177
+ if (message.chunks) {
178
+ this.$delete(message, 'chunks');
156
179
  }
157
- this.logerInfo += `New response: ${JSON.stringify(newResponse)}\n\n`;
158
- this.timeout = setStoryTimeout(() => {
159
- this.mockResponseFromAi();
160
- }, Math.floor(Math.random() * 251) + 16);
161
180
  }
162
181
  },
163
182
  },
@@ -212,13 +212,20 @@ export default {
212
212
  [[]]
213
213
  );
214
214
  },
215
+ lastMessage() {
216
+ return this.messages[this.messages.length - 1];
217
+ },
215
218
  resetDisabled() {
216
219
  if (this.isLoading || !this.hasMessages) {
217
220
  return true;
218
221
  }
219
-
220
- const lastMessage = this.messages[this.messages.length - 1];
221
- return lastMessage.content === CHAT_RESET_MESSAGE;
222
+ return this.lastMessage?.content === CHAT_RESET_MESSAGE;
223
+ },
224
+ submitDisabled() {
225
+ return this.isLoading || this.isStreaming;
226
+ },
227
+ isStreaming() {
228
+ return Boolean(this.lastMessage?.chunks?.length > 0 && !this.lastMessage?.content);
222
229
  },
223
230
  filteredSlashCommands() {
224
231
  const caseInsensitivePrompt = this.prompt.toLowerCase();
@@ -246,12 +253,13 @@ export default {
246
253
  },
247
254
  },
248
255
  watch: {
249
- isLoading() {
256
+ isLoading(newVal) {
250
257
  this.isHidden = false;
251
258
  this.scrollToBottom();
252
- },
253
- messages() {
254
- this.prompt = '';
259
+ if (newVal) {
260
+ // We reset the prompt when we start getting the response and focus in the prompt field
261
+ this.setPromptAndFocus();
262
+ }
255
263
  },
256
264
  },
257
265
  created() {
@@ -269,6 +277,9 @@ export default {
269
277
  this.$emit('chat-hidden');
270
278
  },
271
279
  sendChatPrompt() {
280
+ if (this.submitDisabled) {
281
+ return;
282
+ }
272
283
  if (this.prompt) {
273
284
  if (this.prompt === CHAT_RESET_MESSAGE && this.resetDisabled) {
274
285
  return;
@@ -336,14 +347,18 @@ export default {
336
347
  this.activeCommandIndex = 0;
337
348
  }
338
349
  },
350
+ async setPromptAndFocus(prompt = '') {
351
+ this.prompt = prompt;
352
+ await this.$nextTick();
353
+ this.$refs.prompt.$el.focus();
354
+ },
339
355
  selectSlashCommand(index) {
340
356
  const command = this.filteredSlashCommands[index];
341
357
  if (command.shouldSubmit) {
342
358
  this.prompt = command.name;
343
359
  this.sendChatPrompt();
344
360
  } else {
345
- this.prompt = `${command.name} `;
346
- this.$refs.prompt.$el.focus();
361
+ this.setPromptAndFocus(`${command.name} `);
347
362
  }
348
363
  },
349
364
  },
@@ -494,7 +509,6 @@ export default {
494
509
  class="gl-absolute gl-h-full! gl-py-4! gl-bg-transparent! gl-rounded-top-right-none gl-rounded-bottom-right-none gl-shadow-none!"
495
510
  :class="{ 'gl-text-truncate': !prompt }"
496
511
  :placeholder="inputPlaceholder"
497
- :disabled="isLoading"
498
512
  autofocus
499
513
  @keydown.enter.exact.native.prevent
500
514
  @keyup.native="onInputKeyup"
@@ -507,8 +521,9 @@ export default {
507
521
  variant="confirm"
508
522
  class="!gl-absolute gl-bottom-2 gl-right-2 gl-rounded-base!"
509
523
  type="submit"
524
+ data-testid="chat-prompt-submit-button"
510
525
  :aria-label="$options.i18n.CHAT_SUBMIT_LABEL"
511
- :disabled="isLoading"
526
+ :disabled="submitDisabled"
512
527
  />
513
528
  </template>
514
529
  </gl-form-input-group>
@@ -1,3 +1,4 @@
1
+ import { setStoryTimeout } from '../../../../utils/test_utils';
1
2
  import { DOCUMENTATION_SOURCE_TYPES, MESSAGE_MODEL_ROLES } from './constants';
2
3
 
3
4
  const MOCK_SOURCES = [
@@ -66,24 +67,39 @@ export const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
66
67
  timestamp: '2021-04-21T12:00:00.000Z',
67
68
  };
68
69
 
69
- export const generateMockResponseChunks = (requestId) => {
70
- const chunks = [];
70
+ // Utility function for delay
71
+ async function delayRandom(min = 16, max = 267) {
72
+ const delay = Math.floor(Math.random() * (max - min + 1)) + min;
73
+ // eslint-disable-next-line no-promise-executor-return
74
+ return new Promise((resolve) => setStoryTimeout(resolve, delay));
75
+ }
76
+
77
+ export async function* generateMockResponseChunks(requestId = 1) {
71
78
  const chunkSize = 5;
72
- const chunkCount = Math.ceil(MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.length / chunkSize);
73
- for (let i = 0; i < chunkCount; i += 1) {
79
+ const contentLength = MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.length;
80
+ const chunkCount = Math.ceil(contentLength / chunkSize);
81
+
82
+ for (let chunkId = 0; chunkId < chunkCount; chunkId += 1) {
83
+ const start = chunkId * chunkSize;
84
+ const end = Math.min((chunkId + 1) * chunkSize, contentLength);
74
85
  const chunk = {
75
86
  ...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
76
87
  requestId,
77
- content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.substring(
78
- i * chunkSize,
79
- (i + 1) * chunkSize
80
- ),
81
- chunkId: i,
88
+ content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.substring(start, end),
89
+ chunkId,
82
90
  };
83
- chunks.push(chunk);
91
+
92
+ // eslint-disable-next-line no-await-in-loop
93
+ await delayRandom();
94
+ yield chunk;
84
95
  }
85
- return chunks;
86
- };
96
+ yield {
97
+ ...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
98
+ requestId,
99
+ content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content,
100
+ chunkId: null,
101
+ };
102
+ }
87
103
 
88
104
  export const MOCK_USER_PROMPT_MESSAGE = {
89
105
  id: '456',