@gitlab/ui 78.8.1 → 78.10.0

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.
Files changed (24) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.js +51 -14
  3. package/dist/components/experimental/duo/chat/components/duo_chat_message/utils.js +8 -0
  4. package/dist/components/experimental/duo/chat/duo_chat.js +2 -2
  5. package/dist/components/experimental/duo/chat/mock_data.js +18 -4
  6. package/dist/components/experimental/duo/user_feedback/user_feedback_modal.js +18 -6
  7. package/dist/tokens/css/tokens.css +1 -1
  8. package/dist/tokens/css/tokens.dark.css +1 -1
  9. package/dist/tokens/js/tokens.dark.js +1 -1
  10. package/dist/tokens/js/tokens.js +1 -1
  11. package/dist/tokens/scss/_tokens.dark.scss +1 -1
  12. package/dist/tokens/scss/_tokens.scss +1 -1
  13. package/package.json +2 -2
  14. package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.spec.js +258 -138
  15. package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue +46 -17
  16. package/src/components/experimental/duo/chat/components/duo_chat_message/utils.js +9 -0
  17. package/src/components/experimental/duo/chat/components/duo_chat_message/utils.spec.js +24 -0
  18. package/src/components/experimental/duo/chat/duo_chat.spec.js +1 -0
  19. package/src/components/experimental/duo/chat/duo_chat.stories.js +14 -31
  20. package/src/components/experimental/duo/chat/duo_chat.vue +4 -1
  21. package/src/components/experimental/duo/chat/mock_data.js +18 -3
  22. package/src/components/experimental/duo/user_feedback/user_feedback.stories.js +1 -3
  23. package/src/components/experimental/duo/user_feedback/user_feedback_modal.spec.js +19 -3
  24. package/src/components/experimental/duo/user_feedback/user_feedback_modal.vue +19 -6
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ # [78.10.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.9.0...v78.10.0) (2024-04-08)
2
+
3
+
4
+ ### Features
5
+
6
+ * **GlDuoChat:** detect streaming based on chunkId ([6cdfe5c](https://gitlab.com/gitlab-org/gitlab-ui/commit/6cdfe5cd823ed0e832a5ec51870421ff4c532dbc))
7
+ * **GlDuoChatMessage:** Handle streaming in the component ([082ab86](https://gitlab.com/gitlab-org/gitlab-ui/commit/082ab867597972bb18ba5287ac69126f38e17619))
8
+
9
+ # [78.9.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.8.1...v78.9.0) (2024-04-04)
10
+
11
+
12
+ ### Features
13
+
14
+ * **DuoChat:** add mandatory field indicator on DuoChat Feedback Modal ([df7d4a5](https://gitlab.com/gitlab-org/gitlab-ui/commit/df7d4a55e799695c9d972ba029758136c0892990))
15
+
1
16
  ## [78.8.1](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.8.0...v78.8.1) (2024-04-04)
2
17
 
3
18
 
@@ -6,6 +6,7 @@ import { MESSAGE_MODEL_ROLES } from '../../constants';
6
6
  import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_sources';
7
7
  import { renderDuoChatMarkdownPreview } from '../../markdown_renderer';
8
8
  import { CopyCodeElement } from './copy_code_element';
9
+ import { concatUntilEmpty } from './utils';
9
10
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
10
11
 
11
12
  const i18n = {
@@ -18,12 +19,6 @@ const i18n = {
18
19
  BETTER_RESPONSE: 'How the response might better meet your needs.'
19
20
  }
20
21
  };
21
- const concatUntilEmpty = arr => {
22
- if (!arr) return '';
23
- let end = arr.findIndex(el => !el);
24
- if (end < 0) end = arr.length;
25
- return arr.slice(0, end).join('');
26
- };
27
22
  var script = {
28
23
  name: 'GlDuoChatMessage',
29
24
  safeHtmlConfigExtension: {
@@ -72,10 +67,16 @@ var script = {
72
67
  data() {
73
68
  return {
74
69
  didWhat: '',
75
- improveWhat: ''
70
+ improveWhat: '',
71
+ messageWatcher: null,
72
+ // imperatively set up watcher on message
73
+ messageChunks: []
76
74
  };
77
75
  },
78
76
  computed: {
77
+ isChunk() {
78
+ return typeof this.message.chunkId === 'number';
79
+ },
79
80
  isAssistantMessage() {
80
81
  return this.message.role.toLowerCase() === MESSAGE_MODEL_ROLES.assistant;
81
82
  },
@@ -90,12 +91,18 @@ var script = {
90
91
  var _this$message$extras2;
91
92
  return (_this$message$extras2 = this.message.extras) === null || _this$message$extras2 === void 0 ? void 0 : _this$message$extras2.hasFeedback;
92
93
  },
93
- messageContent() {
94
+ defaultContent() {
94
95
  if (this.message.errors.length > 0) return this.renderMarkdown(this.message.errors.join('; '));
95
96
  if (this.message.contentHtml) {
96
97
  return this.message.contentHtml;
97
98
  }
98
- return this.renderMarkdown(this.message.content || concatUntilEmpty(this.message.chunks));
99
+ return this.renderMarkdown(this.message.content);
100
+ },
101
+ messageContent() {
102
+ if (this.isAssistantMessage && this.isChunk) {
103
+ return this.renderMarkdown(concatUntilEmpty(this.messageChunks));
104
+ }
105
+ return this.defaultContent || this.renderMarkdown(concatUntilEmpty(this.message.chunks));
99
106
  }
100
107
  },
101
108
  beforeCreate() {
@@ -104,15 +111,39 @@ var script = {
104
111
  }
105
112
  },
106
113
  mounted() {
107
- this.$nextTick(this.hydrateContentWithGFM);
114
+ if (this.isAssistantMessage) {
115
+ // The watcher has to be created imperatively here
116
+ // to give an opportunity to remove it after
117
+ // the complete message has arrived
118
+ this.messageWatcher = this.$watch('message', this.manageMessageUpdate);
119
+ }
120
+ this.setChunks();
121
+ this.hydrateContentWithGFM();
108
122
  },
109
123
  updated() {
110
- this.$nextTick(this.hydrateContentWithGFM);
124
+ this.hydrateContentWithGFM();
111
125
  },
112
126
  methods: {
113
- async hydrateContentWithGFM() {
114
- if (this.message.contentHtml) {
115
- this.renderGFM(this.$refs.content);
127
+ setChunks() {
128
+ if (this.isChunk) {
129
+ const {
130
+ chunkId,
131
+ content
132
+ } = this.message;
133
+ this.$set(this.messageChunks, chunkId - 1, content);
134
+ } else {
135
+ this.messageChunks = [];
136
+ }
137
+ },
138
+ stopWatchingMessage() {
139
+ if (this.messageWatcher) {
140
+ this.messageWatcher(); // Stop watching the message prop
141
+ this.messageWatcher = null; // Ensure the watcher can't be stopped multiple times
142
+ }
143
+ },
144
+ hydrateContentWithGFM() {
145
+ if (!this.isChunk) {
146
+ this.$nextTick(this.renderGFM(this.$refs.content));
116
147
  }
117
148
  },
118
149
  logEvent(e) {
@@ -122,6 +153,12 @@ var script = {
122
153
  improveWhat: this.improveWhat,
123
154
  message: this.message
124
155
  });
156
+ },
157
+ manageMessageUpdate() {
158
+ this.setChunks();
159
+ if (!this.isChunk) {
160
+ this.stopWatchingMessage();
161
+ }
125
162
  }
126
163
  },
127
164
  i18n
@@ -0,0 +1,8 @@
1
+ const concatUntilEmpty = arr => {
2
+ if (!arr) return '';
3
+ let end = arr.findIndex(el => !el);
4
+ if (end < 0) end = arr.length;
5
+ return arr.slice(0, end).join('');
6
+ };
7
+
8
+ export { concatUntilEmpty };
@@ -215,8 +215,8 @@ var script = {
215
215
  return this.isLoading || this.isStreaming;
216
216
  },
217
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));
218
+ var _this$lastMessage2, _this$lastMessage2$ch, _this$lastMessage3, _this$lastMessage4;
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) || typeof ((_this$lastMessage4 = this.lastMessage) === null || _this$lastMessage4 === void 0 ? void 0 : _this$lastMessage4.chunkId) === 'number');
220
220
  },
221
221
  filteredSlashCommands() {
222
222
  const caseInsensitivePrompt = this.prompt.toLowerCase();
@@ -28,6 +28,20 @@ const MOCK_RESPONSE_MESSAGE = {
28
28
  errors: [],
29
29
  timestamp: '2021-04-21T12:00:00.000Z'
30
30
  };
31
+ const generateSeparateChunks = n => {
32
+ const res = [];
33
+ for (let i = 1; i <= n; i += 1) {
34
+ res.push({
35
+ chunkId: i,
36
+ content: `chunk #${i}`,
37
+ role: MESSAGE_MODEL_ROLES.assistant,
38
+ requestId: '987',
39
+ errors: [],
40
+ timestamp: '2021-04-21T12:00:00.000Z'
41
+ });
42
+ }
43
+ return res;
44
+ };
31
45
  const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
32
46
  id: '123',
33
47
  content: `To change your password in GitLab:
@@ -51,7 +65,6 @@ const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
51
65
  ~~~
52
66
  which is rendered while streaming.
53
67
  `,
54
- contentHtml: '',
55
68
  role: 'assistant',
56
69
  extras: {},
57
70
  requestId: '987',
@@ -81,7 +94,7 @@ function generateMockResponseChunks() {
81
94
  ...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
82
95
  requestId,
83
96
  content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.substring(start, end),
84
- chunkId
97
+ chunkId: chunkId + 1
85
98
  };
86
99
 
87
100
  // eslint-disable-next-line no-await-in-loop
@@ -101,8 +114,9 @@ function generateMockResponseChunks() {
101
114
  }
102
115
  const MOCK_USER_PROMPT_MESSAGE = {
103
116
  id: '456',
117
+ chunkId: null,
104
118
  content: 'How to create a new template?',
105
- contentHtml: '',
119
+ contentHtml: '<p>How to create a new template?</p>',
106
120
  role: MESSAGE_MODEL_ROLES.user,
107
121
  requestId: '987',
108
122
  errors: [],
@@ -135,4 +149,4 @@ const SLASH_COMMANDS = [{
135
149
  description: 'Explain the selected snippet.'
136
150
  }];
137
151
 
138
- export { MOCK_RESPONSE_MESSAGE, MOCK_RESPONSE_MESSAGE_FOR_STREAMING, MOCK_USER_PROMPT_MESSAGE, SLASH_COMMANDS, generateMockResponseChunks, renderGFM, renderMarkdown };
152
+ export { MOCK_RESPONSE_MESSAGE, MOCK_RESPONSE_MESSAGE_FOR_STREAMING, MOCK_USER_PROMPT_MESSAGE, SLASH_COMMANDS, generateMockResponseChunks, generateSeparateChunks, renderGFM, renderMarkdown };
@@ -17,6 +17,7 @@ const i18n = {
17
17
  IMPROVEMENT_SUGGESTION_PLACEHOLDER: 'How the response might better meet your needs.',
18
18
  MORE_LABEL: 'More information',
19
19
  MORE_PLACEHOLDER: 'How could the content be improved?',
20
+ REQUIRED_VALIDATION_ERROR: 'Select at least one option.',
20
21
  FEEDBACK_OPTIONS: {
21
22
  helpful: 'Helpful',
22
23
  unhelpful: 'Unhelpful or irrelevant',
@@ -70,23 +71,34 @@ var script = {
70
71
  data() {
71
72
  return {
72
73
  selectedFeedbackOptions: [],
73
- extendedFeedback: ''
74
+ extendedFeedback: '',
75
+ isValid: null
74
76
  };
75
77
  },
78
+ watch: {
79
+ selectedFeedbackOptions(options) {
80
+ this.isValid = options.length > 0;
81
+ }
82
+ },
76
83
  methods: {
84
+ close() {
85
+ this.$refs.feedbackModal.hide();
86
+ },
77
87
  show() {
78
88
  this.$refs.feedbackModal.show();
79
89
  },
80
- onFeedbackSubmit() {
90
+ onFeedbackSubmit(e) {
81
91
  if (this.selectedFeedbackOptions.length) {
82
92
  this.$emit('feedback-submitted', {
83
93
  feedbackChoices: this.selectedFeedbackOptions,
84
94
  extendedTextFeedback: this.extendedFeedback
85
95
  });
96
+ this.close();
97
+ this.isValid = null;
98
+ } else {
99
+ e === null || e === void 0 ? void 0 : e.preventDefault();
100
+ this.isValid = false;
86
101
  }
87
- },
88
- onFeedbackCanceled() {
89
- this.$refs.feedbackModal.hide();
90
102
  }
91
103
  },
92
104
  actions: {
@@ -105,7 +117,7 @@ var script = {
105
117
  const __vue_script__ = script;
106
118
 
107
119
  /* template */
108
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-modal',{ref:"feedbackModal",attrs:{"modal-id":"feedbackModal","title":_vm.modalTitle,"action-primary":_vm.$options.actions.primary,"action-cancel":_vm.$options.actions.cancel,"visible":false,"size":"sm"},on:{"primary":_vm.onFeedbackSubmit,"canceled":_vm.onFeedbackCanceled}},[_c('p',[_vm._v(_vm._s(_vm.$options.i18n.MODAL.DESCRIPTION))]),_vm._v(" "),_c('gl-form-group',{attrs:{"label":_vm.$options.i18n.MODAL.OPTIONS_LABEL,"optional":false,"data-testid":"feedback-options"}},[_c('gl-form-checkbox-group',{attrs:{"options":_vm.$options.feedbackOptions},model:{value:(_vm.selectedFeedbackOptions),callback:function ($$v) {_vm.selectedFeedbackOptions=$$v;},expression:"selectedFeedbackOptions"}})],1),_vm._v(" "),_c('gl-alert',{staticClass:"gl-mb-5",attrs:{"dismissible":false}},[_vm._v(_vm._s(_vm.modalAlert))]),_vm._v(" "),_vm._t("feedback-extra-fields",function(){return [_c('gl-form-group',{attrs:{"label":_vm.$options.i18n.MODAL.MORE_LABEL,"optional":""}},[_c('gl-form-textarea',{attrs:{"placeholder":_vm.$options.i18n.MODAL.MORE_PLACEHOLDER},model:{value:(_vm.extendedFeedback),callback:function ($$v) {_vm.extendedFeedback=$$v;},expression:"extendedFeedback"}})],1)]})],2)};
120
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-modal',{ref:"feedbackModal",attrs:{"modal-id":"feedbackModal","title":_vm.modalTitle,"action-primary":_vm.$options.actions.primary,"action-cancel":_vm.$options.actions.cancel,"visible":false,"size":"sm"},on:{"primary":_vm.onFeedbackSubmit,"canceled":_vm.close}},[_c('p',[_vm._v(_vm._s(_vm.$options.i18n.MODAL.DESCRIPTION))]),_vm._v(" "),_c('gl-form-group',{attrs:{"invalid-feedback":_vm.$options.i18n.MODAL.REQUIRED_VALIDATION_ERROR,"state":_vm.isValid,"label":_vm.$options.i18n.MODAL.OPTIONS_LABEL,"data-testid":"feedback-options"}},[_c('gl-form-checkbox-group',{attrs:{"options":_vm.$options.feedbackOptions},model:{value:(_vm.selectedFeedbackOptions),callback:function ($$v) {_vm.selectedFeedbackOptions=$$v;},expression:"selectedFeedbackOptions"}})],1),_vm._v(" "),_c('gl-alert',{staticClass:"gl-mb-5",attrs:{"dismissible":false}},[_vm._v(_vm._s(_vm.modalAlert))]),_vm._v(" "),_vm._t("feedback-extra-fields",function(){return [_c('gl-form-group',{attrs:{"label":_vm.$options.i18n.MODAL.MORE_LABEL,"optional":""}},[_c('gl-form-textarea',{attrs:{"placeholder":_vm.$options.i18n.MODAL.MORE_PLACEHOLDER},model:{value:(_vm.extendedFeedback),callback:function ($$v) {_vm.extendedFeedback=$$v;},expression:"extendedFeedback"}})],1)]})],2)};
109
121
  var __vue_staticRenderFns__ = [];
110
122
 
111
123
  /* style */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Thu, 04 Apr 2024 18:24:00 GMT
3
+ * Generated on Mon, 08 Apr 2024 17:41:40 GMT
4
4
  */
5
5
 
6
6
  :root {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Thu, 04 Apr 2024 18:24:00 GMT
3
+ * Generated on Mon, 08 Apr 2024 17:41:40 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 Thu, 04 Apr 2024 18:24:00 GMT
3
+ * Generated on Mon, 08 Apr 2024 17:41:40 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 Thu, 04 Apr 2024 18:24:00 GMT
3
+ * Generated on Mon, 08 Apr 2024 17:41:40 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 Thu, 04 Apr 2024 18:24:00 GMT
3
+ // Generated on Mon, 08 Apr 2024 17:41:40 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 Thu, 04 Apr 2024 18:24:00 GMT
3
+ // Generated on Mon, 08 Apr 2024 17:41:40 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.8.1",
3
+ "version": "78.10.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -102,7 +102,7 @@
102
102
  "@gitlab/eslint-plugin": "19.5.0",
103
103
  "@gitlab/fonts": "^1.3.0",
104
104
  "@gitlab/stylelint-config": "6.1.0",
105
- "@gitlab/svgs": "3.94.0",
105
+ "@gitlab/svgs": "3.95.0",
106
106
  "@rollup/plugin-commonjs": "^11.1.0",
107
107
  "@rollup/plugin-node-resolve": "^7.1.3",
108
108
  "@rollup/plugin-replace": "^2.3.2",
@@ -1,7 +1,11 @@
1
1
  import { nextTick } from 'vue';
2
2
  import { shallowMount } from '@vue/test-utils';
3
3
  import GlDuoUserFeedback from '../../../user_feedback/user_feedback.vue';
4
- import { MOCK_USER_PROMPT_MESSAGE, MOCK_RESPONSE_MESSAGE } from '../../mock_data';
4
+ import {
5
+ MOCK_USER_PROMPT_MESSAGE,
6
+ MOCK_RESPONSE_MESSAGE,
7
+ generateSeparateChunks,
8
+ } from '../../mock_data';
5
9
  import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_sources.vue';
6
10
  import GlDuoChatMessage from './duo_chat_message.vue';
7
11
 
@@ -35,7 +39,7 @@ describe('DuoChatMessage', () => {
35
39
  };
36
40
 
37
41
  beforeEach(() => {
38
- renderMarkdown = jest.fn().mockImplementation((val) => `markdown: ${val}`);
42
+ renderMarkdown = jest.fn().mockImplementation((val) => val);
39
43
  renderGFM = jest.fn();
40
44
  });
41
45
 
@@ -49,18 +53,26 @@ describe('DuoChatMessage', () => {
49
53
  expect(customElements.get('copy-code')).toBeDefined();
50
54
  });
51
55
 
52
- describe('rendering with user message', () => {
56
+ describe('rendering', () => {
53
57
  beforeEach(() => {
54
58
  renderMarkdown.mockImplementation(() => mockMarkdownContent);
55
- createComponent();
56
59
  });
57
60
 
58
- it('converts the message `content` to Markdown', () => {
59
- expect(renderMarkdown).toHaveBeenCalledWith(MOCK_USER_PROMPT_MESSAGE.content);
61
+ it('renders html content of the message by default', () => {
62
+ createComponent();
63
+ expect(renderMarkdown).not.toHaveBeenCalled();
64
+ expect(wrapper.html()).toContain(MOCK_USER_PROMPT_MESSAGE.contentHtml);
60
65
  });
61
66
 
62
- it('renders message content', () => {
63
- expect(wrapper.text()).toBe(mockMarkdownContent);
67
+ it('converts the message `content` to Markdown if there is no contentHtml', () => {
68
+ createComponent({
69
+ message: {
70
+ ...MOCK_USER_PROMPT_MESSAGE,
71
+ contentHtml: undefined,
72
+ },
73
+ });
74
+ expect(renderMarkdown).toHaveBeenCalledWith(MOCK_USER_PROMPT_MESSAGE.content);
75
+ expect(findContent().text()).toBe(mockMarkdownContent);
64
76
  });
65
77
 
66
78
  it('does not render the documentation sources component', () => {
@@ -135,8 +147,6 @@ describe('DuoChatMessage', () => {
135
147
  ...MOCK_USER_PROMPT_MESSAGE,
136
148
  errors,
137
149
  contentHtml: 'fooHtml barHtml',
138
- content: 'foo bar',
139
- chunks: ['a', 'b', 'c'],
140
150
  },
141
151
  });
142
152
 
@@ -155,7 +165,6 @@ describe('DuoChatMessage', () => {
155
165
  errors: [],
156
166
  contentHtml: 'fooHtml barHtml',
157
167
  content: 'foo bar',
158
- chunks: ['a', 'b', 'c'],
159
168
  },
160
169
  });
161
170
 
@@ -169,47 +178,14 @@ describe('DuoChatMessage', () => {
169
178
  message: {
170
179
  ...MOCK_USER_PROMPT_MESSAGE,
171
180
  errors: [],
172
- contentHtml: '',
181
+ contentHtml: undefined,
173
182
  content: 'foo bar',
174
- chunks: ['a', 'b', 'c'],
175
183
  },
176
184
  });
177
185
 
178
186
  await nextTick();
179
187
 
180
- expect(findContent().text()).toContain('markdown: foo bar');
181
- });
182
-
183
- it('outputs chunks if there is no content', async () => {
184
- createComponent({
185
- message: {
186
- ...MOCK_USER_PROMPT_MESSAGE,
187
- errors: [],
188
- contentHtml: '',
189
- content: '',
190
- chunks: ['a', 'b', 'c'],
191
- },
192
- });
193
-
194
- await nextTick();
195
-
196
- expect(findContent().text()).toContain('markdown: abc');
197
- });
198
-
199
- it('outputs chunks until first undefined', async () => {
200
- createComponent({
201
- message: {
202
- ...MOCK_USER_PROMPT_MESSAGE,
203
- errors: [],
204
- contentHtml: '',
205
- content: '',
206
- chunks: ['a', undefined, 'c'],
207
- },
208
- });
209
-
210
- await nextTick();
211
-
212
- expect(findContent().text()).toContain('markdown: a');
188
+ expect(findContent().text()).toContain('foo bar');
213
189
  });
214
190
 
215
191
  it('hydrates the message with GFM when mounting with contentHtml', async () => {
@@ -231,44 +207,33 @@ describe('DuoChatMessage', () => {
231
207
  },
232
208
  });
233
209
 
234
- wrapper.setProps({
210
+ await wrapper.setProps({
235
211
  message: {
236
212
  ...MOCK_USER_PROMPT_MESSAGE,
237
213
  contentHtml: 'foo bar',
238
214
  },
239
215
  });
240
-
241
- await nextTick();
242
216
  expect(renderGFM).toHaveBeenCalled();
243
217
  });
244
- });
245
-
246
- describe('default renderers', () => {
247
- it('outputs errors if they are present', async () => {
248
- const errors = ['error1', 'error2', 'error3'];
249
218
 
219
+ it('sanitizes html produced by errors', async () => {
250
220
  createComponent({
251
221
  options: {
252
222
  provide: null,
253
223
  },
254
224
  message: {
255
225
  ...MOCK_USER_PROMPT_MESSAGE,
256
- errors,
257
- contentHtml: 'fooHtml barHtml',
258
- content: 'foo bar',
259
- chunks: ['a', 'b', 'c'],
226
+ errors: ['[click here](javascript:prompt(1))'],
227
+ contentHtml: undefined,
228
+ content: '',
260
229
  },
261
230
  });
262
231
 
263
232
  await nextTick();
264
-
265
- const contentText = findContent().text();
266
- expect(contentText).toContain(errors[0]);
267
- expect(contentText).toContain(errors[1]);
268
- expect(contentText).toContain(errors[2]);
233
+ expect(findContent().html()).toContain('<p><a>click here</a></p>');
269
234
  });
270
235
 
271
- it('outputs contentHtml if it is present', async () => {
236
+ it('sanitizes html produced by content', async () => {
272
237
  createComponent({
273
238
  options: {
274
239
  provide: null,
@@ -276,20 +241,17 @@ describe('DuoChatMessage', () => {
276
241
  message: {
277
242
  ...MOCK_USER_PROMPT_MESSAGE,
278
243
  errors: [],
279
- contentHtml: 'fooHtml barHtml',
280
- content: 'foo bar',
281
- chunks: ['a', 'b', 'c'],
244
+ contentHtml: undefined,
245
+ content: '[click here](javascript:prompt(1))',
282
246
  },
283
247
  });
284
248
 
285
249
  await nextTick();
286
250
 
287
- expect(findContent().html()).toBe(
288
- '<div class="gl-markdown gl-compact-markdown">fooHtml barHtml</div>'
289
- );
251
+ expect(findContent().html()).toContain('<p><a>click here</a></p>');
290
252
  });
291
253
 
292
- it('outputs markdown content if there is no contentHtml', async () => {
254
+ it('deprecated: sanitizes html produced by chunks', async () => {
293
255
  createComponent({
294
256
  options: {
295
257
  provide: null,
@@ -297,18 +259,18 @@ describe('DuoChatMessage', () => {
297
259
  message: {
298
260
  ...MOCK_USER_PROMPT_MESSAGE,
299
261
  errors: [],
300
- contentHtml: '',
301
- content: 'foo bar',
302
- chunks: ['a', 'b', 'c'],
262
+ contentHtml: undefined,
263
+ content: '',
264
+ chunks: ['[click here]', '(javascript:prompt(1))'],
303
265
  },
304
266
  });
305
267
 
306
268
  await nextTick();
307
269
 
308
- expect(findContent().html()).toBe('<div>\n <p>foo bar</p>\n</div>');
270
+ expect(findContent().html()).toContain('<p><a>click here</a></p>');
309
271
  });
310
272
 
311
- it('outputs chunks if there is no content', async () => {
273
+ it('sanitizes contentHtml', async () => {
312
274
  createComponent({
313
275
  options: {
314
276
  provide: null,
@@ -316,92 +278,250 @@ describe('DuoChatMessage', () => {
316
278
  message: {
317
279
  ...MOCK_USER_PROMPT_MESSAGE,
318
280
  errors: [],
319
- contentHtml: '',
281
+ contentHtml: `<a href="javascript:prompt(1)">click here</a>`,
320
282
  content: '',
321
- chunks: ['a', 'b', 'c'],
283
+ chunks: [],
322
284
  },
323
285
  });
324
286
 
325
287
  await nextTick();
326
288
 
327
- expect(findContent().html()).toBe('<div>\n <p>abc</p>\n</div>');
289
+ expect(findContent().html()).toBe(
290
+ '<div class="gl-markdown gl-compact-markdown"><a>click here</a></div>'
291
+ );
328
292
  });
329
293
 
330
- it('sanitizes html produced by errors', async () => {
331
- createComponent({
332
- options: {
333
- provide: null,
334
- },
335
- message: {
336
- ...MOCK_USER_PROMPT_MESSAGE,
337
- errors: ['[click here](javascript:prompt(1))'],
338
- contentHtml: '',
339
- content: '',
340
- chunks: [],
341
- },
294
+ describe('message updates watcher', () => {
295
+ const newContent = 'new foo content';
296
+ beforeEach(() => {
297
+ createComponent();
342
298
  });
343
299
 
344
- await nextTick();
300
+ it('listens to the message changes', async () => {
301
+ expect(findContent().text()).toContain(MOCK_USER_PROMPT_MESSAGE.content);
302
+ // setProps is justified here because we are testing the component's
303
+ // reactive behavior which consistutes an exception
304
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
305
+ await wrapper.setProps({
306
+ message: {
307
+ ...MOCK_USER_PROMPT_MESSAGE,
308
+ contentHtml: `<p>${newContent}</p>`,
309
+ },
310
+ });
311
+ expect(findContent().text()).not.toContain(MOCK_USER_PROMPT_MESSAGE.content);
312
+ expect(findContent().text()).toContain(newContent);
313
+ });
345
314
 
346
- expect(findContent().html()).toBe('<div>\n <p><a>click here</a></p>\n</div>');
347
- });
315
+ it('prioritises the output of contentHtml over content', async () => {
316
+ // setProps is justified here because we are testing the component's
317
+ // reactive behavior which consistutes an exception
318
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
319
+ await wrapper.setProps({
320
+ message: {
321
+ ...MOCK_USER_PROMPT_MESSAGE,
322
+ contentHtml: `<p>${MOCK_USER_PROMPT_MESSAGE.content}</p>`,
323
+ content: newContent,
324
+ },
325
+ });
326
+ expect(findContent().text()).not.toContain(newContent);
327
+ expect(findContent().text()).toContain(MOCK_USER_PROMPT_MESSAGE.content);
328
+ });
348
329
 
349
- it('sanitizes html produced by content', async () => {
350
- createComponent({
351
- options: {
352
- provide: null,
353
- },
354
- message: {
355
- ...MOCK_USER_PROMPT_MESSAGE,
356
- errors: [],
357
- contentHtml: '',
358
- content: '[click here](javascript:prompt(1))',
359
- chunks: [],
360
- },
330
+ it('outputs errors if message has no content', async () => {
331
+ // setProps is justified here because we are testing the component's
332
+ // reactive behavior which consistutes an exception
333
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
334
+ await wrapper.setProps({
335
+ message: {
336
+ ...MOCK_USER_PROMPT_MESSAGE,
337
+ contentHtml: '',
338
+ content: '',
339
+ errors: ['error'],
340
+ },
341
+ });
342
+ expect(findContent().text()).not.toContain(newContent);
343
+ expect(findContent().text()).not.toContain(MOCK_USER_PROMPT_MESSAGE.content);
344
+ expect(findContent().text()).toContain('error');
361
345
  });
362
346
 
363
- await nextTick();
347
+ it('merges all the errors for output', async () => {
348
+ const errors = ['foo', 'bar', 'baz'];
349
+ // setProps is justified here because we are testing the component's
350
+ // reactive behavior which consistutes an exception
351
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
352
+ await wrapper.setProps({
353
+ message: {
354
+ ...MOCK_USER_PROMPT_MESSAGE,
355
+ contentHtml: '',
356
+ content: '',
357
+ errors,
358
+ },
359
+ });
360
+ expect(findContent().text()).toContain(errors[0]);
361
+ expect(findContent().text()).toContain(errors[1]);
362
+ expect(findContent().text()).toContain(errors[2]);
363
+ });
364
364
 
365
- expect(findContent().html()).toBe('<div>\n <p><a>click here</a></p>\n</div>');
365
+ it('hydrates the output message with GLFM if its not a chunk', async () => {
366
+ // setProps is justified here because we are testing the component's
367
+ // reactive behavior which consistutes an exception
368
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
369
+ await wrapper.setProps({
370
+ message: {
371
+ ...MOCK_USER_PROMPT_MESSAGE,
372
+ contentHtml: `<p>${newContent}</p>`,
373
+ },
374
+ });
375
+ expect(renderGFM).toHaveBeenCalled();
376
+ });
366
377
  });
367
378
 
368
- it('sanitizes html produced by chunks', async () => {
369
- createComponent({
370
- options: {
371
- provide: null,
372
- },
373
- message: {
374
- ...MOCK_USER_PROMPT_MESSAGE,
375
- errors: [],
376
- contentHtml: '',
377
- content: '',
378
- chunks: ['[click here]', '(javascript:prompt(1))'],
379
- },
379
+ describe('updates to the message', () => {
380
+ const [CHUNK1, CHUNK2, CHUNK3] = generateSeparateChunks(3);
381
+ const consolidatedContent = CHUNK1.content + CHUNK2.content;
382
+
383
+ beforeEach(() => {
384
+ createComponent({ message: MOCK_RESPONSE_MESSAGE });
380
385
  });
381
386
 
382
- await nextTick();
387
+ it('does not handle message updates with chunks for the user messages', async () => {
388
+ createComponent({ message: MOCK_USER_PROMPT_MESSAGE });
389
+ expect(findContent().text()).toContain(MOCK_USER_PROMPT_MESSAGE.content);
390
+ // setProps is justified here because we are testing the component's
391
+ // reactive behavior which consistutes an exception
392
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
393
+ await wrapper.setProps({
394
+ message: {
395
+ ...MOCK_USER_PROMPT_MESSAGE,
396
+ content: 'foo bar',
397
+ chunkId: 1,
398
+ },
399
+ });
400
+ expect(findContent().text()).toContain(MOCK_USER_PROMPT_MESSAGE.content);
401
+ });
383
402
 
384
- expect(findContent().html()).toBe('<div>\n <p><a>click here</a></p>\n</div>');
385
- });
403
+ it('does not fail if the message has no chunkId', async () => {
404
+ // setProps is justified here because we are testing the component's
405
+ // reactive behavior which consistutes an exception
406
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
407
+ await wrapper.setProps({
408
+ message: {
409
+ ...CHUNK1,
410
+ chunkId: null,
411
+ },
412
+ });
413
+ expect(findContent().text()).toContain(CHUNK1.content);
414
+ });
386
415
 
387
- it('sanitizes contentHtml', async () => {
388
- createComponent({
389
- options: {
390
- provide: null,
391
- },
392
- message: {
393
- ...MOCK_USER_PROMPT_MESSAGE,
394
- errors: [],
395
- contentHtml: `<a href="javascript:prompt(1)">click here</a>`,
396
- content: '',
397
- chunks: [],
398
- },
416
+ it('renders chunks correctly when the chunks arrive out of order', async () => {
417
+ expect(CHUNK1.content).toBe('chunk #1');
418
+ expect(CHUNK2.content).toBe('chunk #2');
419
+ expect(CHUNK3.content).toBe('chunk #3');
420
+ // setProps is justified here because we are testing the component's
421
+ // reactive behavior which consistutes an exception
422
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
423
+ await wrapper.setProps({
424
+ message: CHUNK2,
425
+ });
426
+ expect(findContent().text()).toBe('');
427
+
428
+ await wrapper.setProps({
429
+ message: CHUNK1,
430
+ });
431
+ expect(findContent().text()).toBe(CHUNK1.content + CHUNK2.content);
432
+
433
+ await wrapper.setProps({
434
+ message: CHUNK3,
435
+ });
436
+ expect(findContent().text()).toBe(CHUNK1.content + CHUNK2.content + CHUNK3.content);
399
437
  });
400
438
 
401
- await nextTick();
439
+ it('renders the chunks as they arrive when they arrive in the correct order', async () => {
440
+ // setProps is justified here because we are testing the component's
441
+ // reactive behavior which consistutes an exception
442
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
443
+ await wrapper.setProps({
444
+ message: CHUNK1,
445
+ });
446
+ expect(findContent().text()).toBe(CHUNK1.content);
402
447
 
403
- expect(findContent().html()).toBe(
404
- '<div class="gl-markdown gl-compact-markdown"><a>click here</a></div>'
448
+ await wrapper.setProps({
449
+ message: CHUNK2,
450
+ });
451
+ expect(findContent().text()).toBe(consolidatedContent);
452
+ });
453
+
454
+ it('treats the initial message content as chunk if message has chunkId', async () => {
455
+ createComponent({
456
+ message: CHUNK1,
457
+ });
458
+ await nextTick();
459
+ expect(findContent().text()).toBe(CHUNK1.content);
460
+
461
+ // setProps is justified here because we are testing the component's
462
+ // reactive behavior which consistutes an exception
463
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
464
+ await wrapper.setProps({
465
+ message: CHUNK2,
466
+ });
467
+ expect(findContent().text()).toBe(consolidatedContent);
468
+ });
469
+
470
+ it('does not hydrate the chunk messages with GLFM', async () => {
471
+ createComponent({
472
+ propsData: {
473
+ message: CHUNK1,
474
+ },
475
+ });
476
+ await nextTick();
477
+ renderGFM.mockClear();
478
+ expect(renderGFM).not.toHaveBeenCalled();
479
+
480
+ // setProps is justified here because we are testing the component's
481
+ // reactive behavior which consistutes an exception
482
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
483
+ await wrapper.setProps({
484
+ message: CHUNK2,
485
+ });
486
+ expect(renderGFM).not.toHaveBeenCalled();
487
+ });
488
+
489
+ it.each`
490
+ content | contentHtml | errors | expectedContent
491
+ ${'alpha'} | ${'beta'} | ${['foo', 'bar']} | ${'foo; bar'}
492
+ ${'alpha'} | ${'beta'} | ${[]} | ${'beta'}
493
+ ${'alpha'} | ${undefined} | ${['foo', 'bar']} | ${'foo; bar'}
494
+ ${'alpha'} | ${undefined} | ${[]} | ${'alpha'}
495
+ ${''} | ${'beta'} | ${['foo', 'bar']} | ${'foo; bar'}
496
+ ${''} | ${'beta'} | ${[]} | ${'beta'}
497
+ ${''} | ${undefined} | ${['foo', 'bar']} | ${'foo; bar'}
498
+ `(
499
+ 'outputs "$expectedContent" and hydrates this content when content is "$content", contentHtml is "$contentHtml" and errors is "$errors" with "chunkId: null"',
500
+ async ({ content, contentHtml, errors, expectedContent } = {}) => {
501
+ createComponent({
502
+ propsData: {
503
+ message: CHUNK1,
504
+ },
505
+ });
506
+ await nextTick();
507
+ renderGFM.mockClear();
508
+ expect(renderGFM).not.toHaveBeenCalled();
509
+
510
+ // setProps is justified here because we are testing the component's
511
+ // reactive behavior which consistutes an exception
512
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
513
+ await wrapper.setProps({
514
+ message: {
515
+ ...CHUNK2,
516
+ chunkId: null,
517
+ content,
518
+ contentHtml,
519
+ errors,
520
+ },
521
+ });
522
+ expect(renderGFM).toHaveBeenCalled();
523
+ expect(findContent().text()).toBe(expectedContent);
524
+ }
405
525
  );
406
526
  });
407
527
  });
@@ -8,6 +8,7 @@ import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_s
8
8
  // eslint-disable-next-line no-restricted-imports
9
9
  import { renderDuoChatMarkdownPreview } from '../../markdown_renderer';
10
10
  import { CopyCodeElement } from './copy_code_element';
11
+ import { concatUntilEmpty } from './utils';
11
12
 
12
13
  export const i18n = {
13
14
  MODAL: {
@@ -21,16 +22,6 @@ export const i18n = {
21
22
  },
22
23
  };
23
24
 
24
- const concatUntilEmpty = (arr) => {
25
- if (!arr) return '';
26
-
27
- let end = arr.findIndex((el) => !el);
28
-
29
- if (end < 0) end = arr.length;
30
-
31
- return arr.slice(0, end).join('');
32
- };
33
-
34
25
  export default {
35
26
  name: 'GlDuoChatMessage',
36
27
  safeHtmlConfigExtension: {
@@ -80,9 +71,14 @@ export default {
80
71
  return {
81
72
  didWhat: '',
82
73
  improveWhat: '',
74
+ messageWatcher: null, // imperatively set up watcher on message
75
+ messageChunks: [],
83
76
  };
84
77
  },
85
78
  computed: {
79
+ isChunk() {
80
+ return typeof this.message.chunkId === 'number';
81
+ },
86
82
  isAssistantMessage() {
87
83
  return this.message.role.toLowerCase() === MESSAGE_MODEL_ROLES.assistant;
88
84
  },
@@ -95,7 +91,7 @@ export default {
95
91
  hasFeedback() {
96
92
  return this.message.extras?.hasFeedback;
97
93
  },
98
- messageContent() {
94
+ defaultContent() {
99
95
  if (this.message.errors.length > 0)
100
96
  return this.renderMarkdown(this.message.errors.join('; '));
101
97
 
@@ -103,7 +99,13 @@ export default {
103
99
  return this.message.contentHtml;
104
100
  }
105
101
 
106
- return this.renderMarkdown(this.message.content || concatUntilEmpty(this.message.chunks));
102
+ return this.renderMarkdown(this.message.content);
103
+ },
104
+ messageContent() {
105
+ if (this.isAssistantMessage && this.isChunk) {
106
+ return this.renderMarkdown(concatUntilEmpty(this.messageChunks));
107
+ }
108
+ return this.defaultContent || this.renderMarkdown(concatUntilEmpty(this.message.chunks));
107
109
  },
108
110
  },
109
111
  beforeCreate() {
@@ -112,15 +114,36 @@ export default {
112
114
  }
113
115
  },
114
116
  mounted() {
115
- this.$nextTick(this.hydrateContentWithGFM);
117
+ if (this.isAssistantMessage) {
118
+ // The watcher has to be created imperatively here
119
+ // to give an opportunity to remove it after
120
+ // the complete message has arrived
121
+ this.messageWatcher = this.$watch('message', this.manageMessageUpdate);
122
+ }
123
+ this.setChunks();
124
+ this.hydrateContentWithGFM();
116
125
  },
117
126
  updated() {
118
- this.$nextTick(this.hydrateContentWithGFM);
127
+ this.hydrateContentWithGFM();
119
128
  },
120
129
  methods: {
121
- async hydrateContentWithGFM() {
122
- if (this.message.contentHtml) {
123
- this.renderGFM(this.$refs.content);
130
+ setChunks() {
131
+ if (this.isChunk) {
132
+ const { chunkId, content } = this.message;
133
+ this.$set(this.messageChunks, chunkId - 1, content);
134
+ } else {
135
+ this.messageChunks = [];
136
+ }
137
+ },
138
+ stopWatchingMessage() {
139
+ if (this.messageWatcher) {
140
+ this.messageWatcher(); // Stop watching the message prop
141
+ this.messageWatcher = null; // Ensure the watcher can't be stopped multiple times
142
+ }
143
+ },
144
+ hydrateContentWithGFM() {
145
+ if (!this.isChunk) {
146
+ this.$nextTick(this.renderGFM(this.$refs.content));
124
147
  }
125
148
  },
126
149
  logEvent(e) {
@@ -131,6 +154,12 @@ export default {
131
154
  message: this.message,
132
155
  });
133
156
  },
157
+ manageMessageUpdate() {
158
+ this.setChunks();
159
+ if (!this.isChunk) {
160
+ this.stopWatchingMessage();
161
+ }
162
+ },
134
163
  },
135
164
  i18n,
136
165
  };
@@ -0,0 +1,9 @@
1
+ export const concatUntilEmpty = (arr) => {
2
+ if (!arr) return '';
3
+
4
+ let end = arr.findIndex((el) => !el);
5
+
6
+ if (end < 0) end = arr.length;
7
+
8
+ return arr.slice(0, end).join('');
9
+ };
@@ -0,0 +1,24 @@
1
+ import { concatUntilEmpty } from './utils';
2
+
3
+ describe('concatUntilEmpty utility', () => {
4
+ it('returns empty string if input array is falsy', () => {
5
+ expect(concatUntilEmpty()).toEqual('');
6
+ expect(concatUntilEmpty(null)).toEqual('');
7
+ expect(concatUntilEmpty(undefined)).toEqual('');
8
+ });
9
+
10
+ it('concatenates array elements until first falsy element', () => {
11
+ const arr = ['a', 'b', undefined, 'c'];
12
+ expect(concatUntilEmpty(arr)).toEqual('ab');
13
+ });
14
+
15
+ it('concatenates all array elements if none are falsy', () => {
16
+ const arr = ['a', 'b', 'c'];
17
+ expect(concatUntilEmpty(arr)).toEqual('abc');
18
+ });
19
+
20
+ it('returns empty string if first element is falsy', () => {
21
+ const arr = [undefined, 'b', 'c'];
22
+ expect(concatUntilEmpty(arr)).toEqual('');
23
+ });
24
+ });
@@ -357,6 +357,7 @@ describe('GlDuoChat', () => {
357
357
  { ...MOCK_RESPONSE_MESSAGE, content: undefined, chunks: [''] },
358
358
  ],
359
359
  ],
360
+ [[{ ...MOCK_RESPONSE_MESSAGE, chunkId: 1 }]],
360
361
  ])('prevents submission when streaming (messages = "%o")', (msgs = []) => {
361
362
  createComponent({
362
363
  propsData: { isChatAvailable: true, messages: msgs },
@@ -125,45 +125,28 @@ export const Interactive = (args, { argTypes }) => ({
125
125
  async mockResponseFromAi() {
126
126
  const generator = generateMockResponseChunks(this.requestId);
127
127
 
128
- for await (const result of generator) {
129
- const { chunkId, content, ...messageAttributes } = result;
128
+ for await (const newResponse of generator) {
130
129
  const existingMessageIndex = this.msgs.findIndex(
131
- (msg) => msg.requestId === result.requestId && msg.role === result.role
130
+ (msg) => msg.requestId === newResponse.requestId && msg.role === newResponse.role
132
131
  );
133
-
134
- if (existingMessageIndex === -1) {
135
- this.addNewMessage(messageAttributes, content);
132
+ const existingMessage = this.msgs[existingMessageIndex];
133
+ if (existingMessage) {
134
+ this.updateExistingMessage(newResponse, existingMessageIndex);
136
135
  } else {
137
- this.updateExistingMessage(existingMessageIndex, content, chunkId);
136
+ this.addNewMessage(newResponse);
138
137
  }
139
138
  }
140
139
  },
141
- addNewMessage(messageAttributes, content) {
140
+ addNewMessage(msg) {
142
141
  this.promptInFlight = false;
143
- this.$set(this.msgs, this.msgs.length, {
144
- ...messageAttributes,
145
- chunks: [content],
146
- });
142
+ this.$set(this.msgs, this.msgs.length, msg);
147
143
  },
148
- updateExistingMessage(index, content, chunkId) {
149
- const message = this.msgs[index];
150
-
151
- if (chunkId != null) {
152
- // Ensure the chunks array exists
153
- if (!message.chunks) {
154
- this.$set(message, 'chunks', []);
155
- } else {
156
- this.$set(message.chunks, chunkId, content);
157
- }
158
- } else {
159
- // Update for final message
160
- this.$set(message, 'content', content);
161
-
162
- // Remove chunks if they are not needed anymore
163
- if (message.chunks) {
164
- this.$delete(message, 'chunks');
165
- }
166
- }
144
+ updateExistingMessage(newResponse, existingMessageIndex) {
145
+ const existingMessage = this.msgs[existingMessageIndex];
146
+ this.$set(this.msgs, existingMessageIndex, {
147
+ ...existingMessage,
148
+ ...newResponse,
149
+ });
167
150
  },
168
151
  },
169
152
  template: `
@@ -225,7 +225,10 @@ export default {
225
225
  return this.isLoading || this.isStreaming;
226
226
  },
227
227
  isStreaming() {
228
- return Boolean(this.lastMessage?.chunks?.length > 0 && !this.lastMessage?.content);
228
+ return Boolean(
229
+ (this.lastMessage?.chunks?.length > 0 && !this.lastMessage?.content) ||
230
+ typeof this.lastMessage?.chunkId === 'number'
231
+ );
229
232
  },
230
233
  filteredSlashCommands() {
231
234
  const caseInsensitivePrompt = this.prompt.toLowerCase();
@@ -41,6 +41,21 @@ export const MOCK_RESPONSE_MESSAGE = {
41
41
  timestamp: '2021-04-21T12:00:00.000Z',
42
42
  };
43
43
 
44
+ export const generateSeparateChunks = (n) => {
45
+ const res = [];
46
+ for (let i = 1; i <= n; i += 1) {
47
+ res.push({
48
+ chunkId: i,
49
+ content: `chunk #${i}`,
50
+ role: MESSAGE_MODEL_ROLES.assistant,
51
+ requestId: '987',
52
+ errors: [],
53
+ timestamp: '2021-04-21T12:00:00.000Z',
54
+ });
55
+ }
56
+ return res;
57
+ };
58
+
44
59
  export const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
45
60
  id: '123',
46
61
  content: `To change your password in GitLab:
@@ -64,7 +79,6 @@ export const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
64
79
  ~~~
65
80
  which is rendered while streaming.
66
81
  `,
67
- contentHtml: '',
68
82
  role: 'assistant',
69
83
  extras: {},
70
84
  requestId: '987',
@@ -91,7 +105,7 @@ export async function* generateMockResponseChunks(requestId = 1) {
91
105
  ...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
92
106
  requestId,
93
107
  content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.substring(start, end),
94
- chunkId,
108
+ chunkId: chunkId + 1,
95
109
  };
96
110
 
97
111
  // eslint-disable-next-line no-await-in-loop
@@ -108,8 +122,9 @@ export async function* generateMockResponseChunks(requestId = 1) {
108
122
 
109
123
  export const MOCK_USER_PROMPT_MESSAGE = {
110
124
  id: '456',
125
+ chunkId: null,
111
126
  content: 'How to create a new template?',
112
- contentHtml: '',
127
+ contentHtml: '<p>How to create a new template?</p>',
113
128
  role: MESSAGE_MODEL_ROLES.user,
114
129
  requestId: '987',
115
130
  errors: [],
@@ -74,9 +74,7 @@ export const Slots = (args, { argTypes }) => ({
74
74
  @feedback="logEvent">
75
75
  <template #feedback-extra-fields>
76
76
  <div class="gl-mb-5">
77
- <gl-alert variant="info" :dismissible="false">
78
- GitLab team members can not see your conversation. Please be as descriptive as possible.
79
- </gl-alert>
77
+ Example slot content: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
80
78
  </div>
81
79
  <gl-form-group label="What were you doing?" optional>
82
80
  <gl-form-textarea placeholder="The situation in which you interacted with GitLab Duo Chat." v-model="didWhat" />
@@ -12,10 +12,12 @@ const DummyComponent = {
12
12
 
13
13
  describe('FeedbackModal', () => {
14
14
  let wrapper;
15
+ const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
15
16
  const findModal = () => wrapper.findComponent(GlModal);
16
- const findOptions = () => wrapper.findComponent('[data-testid="feedback-options"]');
17
+ const findOptions = () => findByTestId('feedback-options');
17
18
  const findOptionsCheckboxes = () => findOptions().findAllComponents(GlFormCheckbox);
18
19
  const findTextarea = () => wrapper.findComponent(GlFormTextarea);
20
+
19
21
  const selectOption = (index = 0) => {
20
22
  wrapper
21
23
  .findAllComponents(GlFormCheckboxGroup)
@@ -31,6 +33,9 @@ describe('FeedbackModal', () => {
31
33
  },
32
34
  provide: options.injections,
33
35
  });
36
+
37
+ wrapper.vm.close = jest.fn();
38
+ return wrapper;
34
39
  };
35
40
 
36
41
  describe('inputs', () => {
@@ -56,7 +61,7 @@ describe('FeedbackModal', () => {
56
61
  createComponent();
57
62
  });
58
63
 
59
- it('emits the feedback event when the submit button is clicked', () => {
64
+ it('emits the feedback event when the submit button is clicked and closes the modal', () => {
60
65
  selectOption();
61
66
  findModal().vm.$emit('primary');
62
67
  expect(wrapper.emitted('feedback-submitted')).toEqual([
@@ -67,9 +72,20 @@ describe('FeedbackModal', () => {
67
72
  },
68
73
  ],
69
74
  ]);
75
+
76
+ expect(wrapper.vm.close).toHaveBeenCalledTimes(1);
70
77
  });
71
- it('does not emit event if there is no option selected', () => {
78
+
79
+ it('does not render validation error by default', () => {
80
+ expect(findOptions().vm.$attrs.state).not.toBe(false);
81
+ });
82
+
83
+ it('renders validation error when submit was triggered without selected a required option', async () => {
72
84
  findModal().vm.$emit('primary');
85
+ await wrapper.vm.$nextTick();
86
+
87
+ expect(findOptions().vm.$attrs.state).toBe(false);
88
+ expect(findOptions().vm.$attrs['invalid-feedback']).toBe('Select at least one option.');
73
89
  expect(wrapper.emitted('feedback-submitted')).toBeUndefined();
74
90
  });
75
91
  });
@@ -18,6 +18,7 @@ export const i18n = {
18
18
  IMPROVEMENT_SUGGESTION_PLACEHOLDER: 'How the response might better meet your needs.',
19
19
  MORE_LABEL: 'More information',
20
20
  MORE_PLACEHOLDER: 'How could the content be improved?',
21
+ REQUIRED_VALIDATION_ERROR: 'Select at least one option.',
21
22
  FEEDBACK_OPTIONS: {
22
23
  helpful: 'Helpful',
23
24
  unhelpful: 'Unhelpful or irrelevant',
@@ -81,23 +82,34 @@ export default {
81
82
  return {
82
83
  selectedFeedbackOptions: [],
83
84
  extendedFeedback: '',
85
+ isValid: null,
84
86
  };
85
87
  },
88
+ watch: {
89
+ selectedFeedbackOptions(options) {
90
+ this.isValid = options.length > 0;
91
+ },
92
+ },
86
93
  methods: {
94
+ close() {
95
+ this.$refs.feedbackModal.hide();
96
+ },
87
97
  show() {
88
98
  this.$refs.feedbackModal.show();
89
99
  },
90
- onFeedbackSubmit() {
100
+ onFeedbackSubmit(e) {
91
101
  if (this.selectedFeedbackOptions.length) {
92
102
  this.$emit('feedback-submitted', {
93
103
  feedbackChoices: this.selectedFeedbackOptions,
94
104
  extendedTextFeedback: this.extendedFeedback,
95
105
  });
106
+ this.close();
107
+ this.isValid = null;
108
+ } else {
109
+ e?.preventDefault();
110
+ this.isValid = false;
96
111
  }
97
112
  },
98
- onFeedbackCanceled() {
99
- this.$refs.feedbackModal.hide();
100
- },
101
113
  },
102
114
  actions: {
103
115
  primary: {
@@ -121,12 +133,13 @@ export default {
121
133
  :visible="false"
122
134
  size="sm"
123
135
  @primary="onFeedbackSubmit"
124
- @canceled="onFeedbackCanceled"
136
+ @canceled="close"
125
137
  >
126
138
  <p>{{ $options.i18n.MODAL.DESCRIPTION }}</p>
127
139
  <gl-form-group
140
+ :invalid-feedback="$options.i18n.MODAL.REQUIRED_VALIDATION_ERROR"
141
+ :state="isValid"
128
142
  :label="$options.i18n.MODAL.OPTIONS_LABEL"
129
- :optional="false"
130
143
  data-testid="feedback-options"
131
144
  >
132
145
  <gl-form-checkbox-group