@gitlab/duo-ui 15.9.0 → 15.10.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.
@@ -0,0 +1,228 @@
1
+ <script>
2
+ import { GlButton, GlFormTextarea, GlForm } from '@gitlab/ui';
3
+ import { translate } from '@gitlab/ui/dist/utils/i18n';
4
+
5
+ export const i18n = {
6
+ TELL_US_MORE: translate('AgenticBinaryFeedback.tellUsMore', 'Tell us more'),
7
+ SUBMIT: translate('AgenticBinaryFeedback.submit', 'Submit'),
8
+ BACK: translate('AgenticBinaryFeedback.back', 'Back'),
9
+ CLOSE: translate('AgenticBinaryFeedback.close', 'Close'),
10
+ FEEDBACK_PLACEHOLDER: translate('AgenticBinaryFeedback.placeholder', 'Share your feedback...'),
11
+ THUMBS_UP_HEADER: translate('AgenticBinaryFeedback.thumbsUpHeader', 'What made this helpful?'),
12
+ THUMBS_DOWN_HEADER: translate(
13
+ 'AgenticBinaryFeedback.thumbsDownHeader',
14
+ 'What would have been more helpful?'
15
+ ),
16
+ TOO_GENERIC: translate('AgenticBinaryFeedback.tooGeneric', 'Too generic'),
17
+ MISSING_STEPS: translate('AgenticBinaryFeedback.missingSteps', 'Missing steps'),
18
+ WRONG_CONTEXT: translate('AgenticBinaryFeedback.wrongContext', 'Wrong context'),
19
+ OUTDATED_INCORRECT: translate('AgenticBinaryFeedback.outdatedIncorrect', 'Outdated/incorrect'),
20
+ SOLVED_PROBLEM: translate('AgenticBinaryFeedback.solvedProblem', 'Solved my problem'),
21
+ SAVED_TIME: translate('AgenticBinaryFeedback.savedTime', 'Saved me time'),
22
+ GOOD_EXAMPLES: translate('AgenticBinaryFeedback.goodExamples', 'Good examples'),
23
+ ACCURATE_INFO: translate('AgenticBinaryFeedback.accurateInfo', 'Accurate information'),
24
+ };
25
+
26
+ export const PANEL_VIEW = {
27
+ REASON_LIST: 'reason_list',
28
+ TELL_US_MORE: 'tell_us_more',
29
+ };
30
+
31
+ export const thumbsDownReasons = [
32
+ { key: 'too_generic', label: i18n.TOO_GENERIC },
33
+ { key: 'missing_steps', label: i18n.MISSING_STEPS },
34
+ { key: 'wrong_context', label: i18n.WRONG_CONTEXT },
35
+ { key: 'outdated_incorrect', label: i18n.OUTDATED_INCORRECT },
36
+ { key: 'tell_us_more', label: i18n.TELL_US_MORE, isCustom: true },
37
+ ];
38
+
39
+ export const thumbsUpReasons = [
40
+ { key: 'solved_problem', label: i18n.SOLVED_PROBLEM },
41
+ { key: 'saved_time', label: i18n.SAVED_TIME },
42
+ { key: 'good_examples', label: i18n.GOOD_EXAMPLES },
43
+ { key: 'accurate_info', label: i18n.ACCURATE_INFO },
44
+ { key: 'tell_us_more', label: i18n.TELL_US_MORE, isCustom: true },
45
+ ];
46
+
47
+ const CHARACTER_LIMIT = 140;
48
+
49
+ export default {
50
+ name: 'AgenticFeedbackPanel',
51
+ i18n,
52
+ thumbsUpReasons,
53
+ thumbsDownReasons,
54
+ components: {
55
+ GlButton,
56
+ GlFormTextarea,
57
+ GlForm,
58
+ },
59
+ props: {
60
+ feedbackType: {
61
+ type: String,
62
+ required: true,
63
+ validator: (value) => ['thumbs_up', 'thumbs_down'].includes(value),
64
+ },
65
+ },
66
+ data() {
67
+ return {
68
+ panelView: PANEL_VIEW.REASON_LIST,
69
+ customFeedback: '',
70
+ };
71
+ },
72
+ computed: {
73
+ isThumbsUp() {
74
+ return this.feedbackType === 'thumbs_up';
75
+ },
76
+ reasonOptions() {
77
+ return this.isThumbsUp ? this.$options.thumbsUpReasons : this.$options.thumbsDownReasons;
78
+ },
79
+ reasonHeader() {
80
+ return this.isThumbsUp
81
+ ? this.$options.i18n.THUMBS_UP_HEADER
82
+ : this.$options.i18n.THUMBS_DOWN_HEADER;
83
+ },
84
+ isOverCharacterLimit() {
85
+ return this.customFeedback.length > CHARACTER_LIMIT;
86
+ },
87
+ trimmedFeedback() {
88
+ return this.customFeedback.trim();
89
+ },
90
+ isTextareaValid() {
91
+ return this.trimmedFeedback.length === 0 ? null : !this.isOverCharacterLimit;
92
+ },
93
+ canSubmitCustomFeedback() {
94
+ return this.trimmedFeedback.length > 0 && !this.isOverCharacterLimit;
95
+ },
96
+ characterCountText() {
97
+ const remaining = CHARACTER_LIMIT - this.customFeedback.length;
98
+ if (remaining >= 0) {
99
+ return remaining === 1
100
+ ? `${remaining} character remaining`
101
+ : `${remaining} characters remaining`;
102
+ }
103
+ const over = Math.abs(remaining);
104
+ return over === 1 ? `${over} character over limit` : `${over} characters over limit`;
105
+ },
106
+ isReasonListVisible() {
107
+ return this.panelView === PANEL_VIEW.REASON_LIST;
108
+ },
109
+ isTellUsMoreVisible() {
110
+ return this.panelView === PANEL_VIEW.TELL_US_MORE;
111
+ },
112
+ },
113
+ methods: {
114
+ selectReason(reason) {
115
+ if (reason.isCustom) {
116
+ this.panelView = PANEL_VIEW.TELL_US_MORE;
117
+ return;
118
+ }
119
+ this.$emit('submit', {
120
+ feedbackType: this.feedbackType,
121
+ feedbackReason: reason.key,
122
+ });
123
+ },
124
+ submitCustomFeedback() {
125
+ this.$emit('submit', {
126
+ feedbackType: this.feedbackType,
127
+ feedbackReason: `custom_${this.customFeedback.trim()}`,
128
+ });
129
+ },
130
+ goBackToReasons() {
131
+ this.panelView = PANEL_VIEW.REASON_LIST;
132
+ this.customFeedback = '';
133
+ },
134
+ close() {
135
+ this.$emit('close');
136
+ },
137
+ },
138
+ };
139
+ </script>
140
+
141
+ <template>
142
+ <div
143
+ class="agentic-feedback-panel gl-border gl-mt-2 gl-rounded-lg gl-border-default gl-bg-subtle gl-p-3"
144
+ data-testid="feedback-reason-dropdown"
145
+ >
146
+ <div class="gl-mb-3 gl-flex gl-items-center gl-justify-between gl-gap-2">
147
+ <p class="gl-m-0 gl-text-sm gl-font-bold" data-testid="reason-header">
148
+ {{ reasonHeader }}
149
+ </p>
150
+ <div class="gl-flex gl-shrink-0 gl-items-center gl-gap-1">
151
+ <gl-button
152
+ v-if="isTellUsMoreVisible"
153
+ icon="go-back"
154
+ category="tertiary"
155
+ size="small"
156
+ :aria-label="$options.i18n.BACK"
157
+ data-testid="back-button"
158
+ @click="goBackToReasons"
159
+ />
160
+ <gl-button
161
+ icon="close"
162
+ category="tertiary"
163
+ size="small"
164
+ class="gl-mr-[-5px]"
165
+ :aria-label="$options.i18n.CLOSE"
166
+ data-testid="close-dropdown-button"
167
+ @click="close"
168
+ />
169
+ </div>
170
+ </div>
171
+
172
+ <div
173
+ v-if="isReasonListVisible"
174
+ class="gl-flex gl-flex-wrap gl-gap-2"
175
+ data-testid="reason-buttons"
176
+ >
177
+ <gl-button
178
+ v-for="reason in reasonOptions"
179
+ :key="reason.key"
180
+ category="secondary"
181
+ size="small"
182
+ class="gl-rounded-full"
183
+ :icon="reason.isCustom ? 'pencil' : undefined"
184
+ :data-testid="`reason-${reason.key}`"
185
+ @click="selectReason(reason)"
186
+ >
187
+ {{ reason.label }}
188
+ </gl-button>
189
+ </div>
190
+
191
+ <div
192
+ v-if="isTellUsMoreVisible"
193
+ class="gl-flex gl-w-full gl-flex-col gl-gap-3"
194
+ data-testid="tell-us-more-view"
195
+ >
196
+ <gl-form
197
+ class="gl-flex gl-w-full gl-flex-col gl-gap-3"
198
+ @submit.prevent="canSubmitCustomFeedback ? submitCustomFeedback() : null"
199
+ >
200
+ <gl-form-textarea
201
+ v-model="customFeedback"
202
+ :state="isTextareaValid"
203
+ :placeholder="$options.i18n.FEEDBACK_PLACEHOLDER"
204
+ :rows="3"
205
+ :max-rows="5"
206
+ data-testid="custom-feedback-textarea"
207
+ />
208
+ <div class="gl-flex gl-items-center gl-justify-between">
209
+ <gl-button
210
+ variant="confirm"
211
+ size="small"
212
+ type="submit"
213
+ data-testid="submit-custom-feedback-button"
214
+ >
215
+ {{ $options.i18n.SUBMIT }}
216
+ </gl-button>
217
+ <span
218
+ :class="isOverCharacterLimit ? 'gl-text-danger' : 'gl-text-subtle'"
219
+ class="gl-text-sm"
220
+ data-testid="character-count"
221
+ >
222
+ {{ characterCountText }}
223
+ </span>
224
+ </div>
225
+ </gl-form>
226
+ </div>
227
+ </div>
228
+ </template>
@@ -16,6 +16,8 @@ import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_s
16
16
  // eslint-disable-next-line no-restricted-imports
17
17
  import { copyToClipboard, concatUntilEmpty } from '../utils';
18
18
  import AgenticBinaryFeedback from '../../../agentic_chat/components/agentic_binary_feedback/agentic_binary_feedback.vue';
19
+ import AgenticFeedbackPanel from '../../../agentic_chat/components/agentic_feedback_panel/agentic_feedback_panel.vue';
20
+ import MessageActionBar from '../message_action_bar/message_action_bar.vue';
19
21
  import MessageFeedback from './message_feedback.vue';
20
22
  import MarkdownRenderer from './markdown_renderer.vue';
21
23
  import { CopyCodeElement } from './copy_code_element';
@@ -43,8 +45,10 @@ export default {
43
45
  },
44
46
  components: {
45
47
  AgenticBinaryFeedback,
48
+ AgenticFeedbackPanel,
46
49
  DocumentationSources,
47
50
  DuoChatContextItemSelections,
51
+ MessageActionBar,
48
52
  MessageFeedback,
49
53
  MessageMap,
50
54
  GlIcon,
@@ -130,6 +134,8 @@ export default {
130
134
  messageChunks: [],
131
135
  selectedContextItemsDefaultCollapsed: SELECTED_CONTEXT_ITEMS_DEFAULT_COLLAPSED,
132
136
  copied: false,
137
+ feedbackChoice: null,
138
+ feedbackSubmitted: false,
133
139
  };
134
140
  },
135
141
  computed: {
@@ -322,6 +328,16 @@ export default {
322
328
  e.classList.remove(DUO_CODE_SCRIM_TOP_CLASS);
323
329
  }
324
330
  },
331
+ onFeedbackTypeSelected(type) {
332
+ this.feedbackChoice = type;
333
+ },
334
+ onFeedbackPanelSubmit(payload) {
335
+ this.feedbackSubmitted = true;
336
+ this.logEvent(payload);
337
+ },
338
+ onFeedbackPanelClose() {
339
+ this.feedbackChoice = null;
340
+ },
325
341
  async copyMessage() {
326
342
  try {
327
343
  await copyToClipboard(this.message.content, this.$el);
@@ -403,42 +419,57 @@ export default {
403
419
 
404
420
  <documentation-sources v-if="sources" :sources="sources" />
405
421
 
406
- <div
407
- v-if="isAssistantMessage"
408
- class="duo-chat-message-actions -gl-ml-2 gl-flex gl-items-start gl-gap-3"
409
- >
410
- <gl-animated-loader-icon v-if="isChunkAndNotCancelled" :is-on="true" />
411
- <template v-if="shouldShowFeedbackLink && isBinaryFeedbackEnabled">
412
- <agentic-binary-feedback
413
- v-if="showBinaryFeedback"
414
- data-testid="agentic-feedback-latest"
422
+ <template v-if="isAssistantMessage">
423
+ <message-action-bar>
424
+ <gl-animated-loader-icon v-if="isChunkAndNotCancelled" :is-on="true" />
425
+ <template v-if="shouldShowFeedbackLink && isBinaryFeedbackEnabled">
426
+ <agentic-binary-feedback
427
+ v-if="showBinaryFeedback"
428
+ :feedback-choice="feedbackChoice"
429
+ :submitted="feedbackSubmitted"
430
+ data-testid="agentic-feedback-latest"
431
+ @select-type="onFeedbackTypeSelected"
432
+ />
433
+ <div
434
+ v-else
435
+ class="agentic-feedback-hover-wrapper"
436
+ :class="{
437
+ '-gl-mr-3 gl-w-0 gl-overflow-hidden gl-opacity-0':
438
+ !feedbackChoice && !feedbackSubmitted,
439
+ }"
440
+ data-testid="agentic-feedback-hover-container"
441
+ >
442
+ <agentic-binary-feedback
443
+ :feedback-choice="feedbackChoice"
444
+ :submitted="feedbackSubmitted"
445
+ @select-type="onFeedbackTypeSelected"
446
+ />
447
+ </div>
448
+ </template>
449
+ <message-feedback
450
+ v-else-if="shouldShowFeedbackLink"
451
+ :has-feedback="hasFeedback"
415
452
  @feedback="logEvent"
416
453
  />
417
- <div
418
- v-else
419
- class="agentic-feedback-hover-wrapper -gl-mr-3 gl-w-0 gl-overflow-hidden gl-opacity-0"
420
- data-testid="agentic-feedback-hover-container"
421
- >
422
- <agentic-binary-feedback @feedback="logEvent" />
423
- </div>
424
- </template>
425
- <message-feedback
426
- v-else-if="shouldShowFeedbackLink"
427
- :has-feedback="hasFeedback"
428
- @feedback="logEvent"
429
- />
430
- <gl-button
431
- v-if="shouldShowCopyAction"
432
- v-gl-tooltip
433
- :title="copied ? $options.i18n.CHAT_MESSAGE_COPIED : $options.i18n.CHAT_MESSAGE_COPY"
434
- :icon="copied ? 'check-circle-filled' : 'copy-to-clipboard'"
435
- :class="{ '!gl-text-success': copied, '!gl-text-subtle': !copied }"
436
- category="tertiary"
437
- size="small"
438
- @click="copyMessage"
439
- @focusout="copied = false"
454
+ <gl-button
455
+ v-if="shouldShowCopyAction"
456
+ v-gl-tooltip
457
+ :title="copied ? $options.i18n.CHAT_MESSAGE_COPIED : $options.i18n.CHAT_MESSAGE_COPY"
458
+ :icon="copied ? 'check-circle-filled' : 'copy-to-clipboard'"
459
+ :class="{ '!gl-text-success': copied, '!gl-text-subtle': !copied }"
460
+ category="tertiary"
461
+ size="small"
462
+ @click="copyMessage"
463
+ @focusout="copied = false"
464
+ />
465
+ </message-action-bar>
466
+ <agentic-feedback-panel
467
+ v-if="feedbackChoice && !feedbackSubmitted"
468
+ :feedback-type="feedbackChoice"
469
+ @submit="onFeedbackPanelSubmit"
470
+ @close="onFeedbackPanelClose"
440
471
  />
441
- </div>
472
+ </template>
442
473
 
443
474
  <duo-chat-context-item-selections
444
475
  v-if="displaySelectedContextItems && isUserMessage"
@@ -1,7 +1,9 @@
1
1
  <script>
2
- import { GlButton, GlAvatar } from '@gitlab/ui';
2
+ import { GlButton, GlAvatar, GlSearchBoxByType } from '@gitlab/ui';
3
+ import debounce from 'lodash/debounce';
3
4
  import { sprintf, translate } from '../../../../utils/i18n';
4
5
  import { formatLocalizedDate } from '../../../../utils/date';
6
+ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../../constants';
5
7
  import DuoChatThreadsEmpty from './duo_chat_threads_empty.vue';
6
8
  import DuoChatThreadsSkeleton from './duo_chat_threads_skeleton_loader.vue';
7
9
 
@@ -13,6 +15,9 @@ const i18n = {
13
15
  THREAD_DELETE_LABEL: translate('DuoChat.threadDeleteLabel', 'Delete this chat'),
14
16
  OPEN_CHAT_LABEL: translate('DuoChat.openChatLabel', 'Open chat: %{title}'),
15
17
  UNTITLED_CHAT_TITLE: translate('DuoChat.untitledChatTitle', 'Untitled Chat'),
18
+ SEARCH_PLACEHOLDER: translate('DuoChat.searchPlaceholder', 'Search chats...'),
19
+ NO_RESULTS: translate('DuoChat.noSearchResults', 'No results found'),
20
+ CHAT_HISTORY_SEARCH: translate('DuoChat.chatHistorySearch', 'Search chat history'),
16
21
  };
17
22
 
18
23
  export default {
@@ -21,6 +26,7 @@ export default {
21
26
  components: {
22
27
  GlButton,
23
28
  GlAvatar,
29
+ GlSearchBoxByType,
24
30
  DuoChatThreadsEmpty,
25
31
  DuoChatThreadsSkeleton,
26
32
  },
@@ -41,13 +47,30 @@ export default {
41
47
  },
42
48
  },
43
49
 
50
+ data() {
51
+ return {
52
+ searchQuery: '',
53
+ debouncedSearchQuery: '',
54
+ };
55
+ },
56
+
44
57
  computed: {
58
+ filteredThreads() {
59
+ if (!this.debouncedSearchQuery.trim()) {
60
+ return this.threads;
61
+ }
62
+ const query = this.debouncedSearchQuery.trim();
63
+ return this.threads.filter((thread) => {
64
+ const { title = '', goal = '', agentName = '' } = thread;
65
+ return title.includes(query) || goal.includes(query) || agentName.includes(query);
66
+ });
67
+ },
45
68
  groupedThreads() {
46
- if (!this.hasThreads) {
69
+ if (!this.hasFilteredThreads) {
47
70
  return {};
48
71
  }
49
72
 
50
- return this.threads
73
+ return this.filteredThreads
51
74
  .slice()
52
75
  .sort((a, b) => this.compareThreadDates(b.updatedAt, a.updatedAt))
53
76
  .sort((a, b) => this.compareThreadDates(b.lastUpdatedAt, a.lastUpdatedAt))
@@ -63,6 +86,21 @@ export default {
63
86
  hasThreads() {
64
87
  return this.threads.length > 0;
65
88
  },
89
+ hasFilteredThreads() {
90
+ return this.filteredThreads.length > 0;
91
+ },
92
+ },
93
+
94
+ watch: {
95
+ searchQuery(newValue) {
96
+ this.updateDebouncedSearchQuery(newValue);
97
+ },
98
+ },
99
+
100
+ created() {
101
+ this.updateDebouncedSearchQuery = debounce((value) => {
102
+ this.debouncedSearchQuery = value;
103
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
66
104
  },
67
105
 
68
106
  methods: {
@@ -102,7 +140,22 @@ export default {
102
140
  <div class="gl-flex gl-flex-col gl-overflow-hidden">
103
141
  <duo-chat-threads-skeleton v-if="loading" />
104
142
  <template v-else-if="hasThreads">
143
+ <div class="gl-px-4 gl-pt-4">
144
+ <span class="gl-sr-only">{{ $options.i18n.CHAT_HISTORY_SEARCH }}</span>
145
+ <gl-search-box-by-type
146
+ v-model="searchQuery"
147
+ data-testid="chat-threads-search-input"
148
+ :placeholder="$options.i18n.SEARCH_PLACEHOLDER"
149
+ />
150
+ </div>
105
151
  <div class="gl-grow gl-overflow-y-scroll gl-px-4 gl-py-5">
152
+ <p
153
+ v-if="!hasFilteredThreads"
154
+ data-testid="chat-threads-no-results"
155
+ class="gl-mb-0 gl-text-center gl-text-subtle"
156
+ >
157
+ {{ $options.i18n.NO_RESULTS }}
158
+ </p>
106
159
  <div v-for="(threadsForDate, date) in groupedThreads" :key="date" class="gl-mb-3">
107
160
  <div
108
161
  data-testid="chat-threads-date-header"
@@ -0,0 +1,11 @@
1
+ <script>
2
+ export default {
3
+ name: 'MessageActionBar',
4
+ };
5
+ </script>
6
+
7
+ <template>
8
+ <div class="message-action-bar -gl-ml-2 gl-flex gl-items-start gl-gap-3">
9
+ <slot></slot>
10
+ </div>
11
+ </template>
@@ -7,6 +7,8 @@ export const CHAT_BASE_COMMANDS = [CHAT_RESET_MESSAGE, CHAT_CLEAR_MESSAGE, CHAT_
7
7
 
8
8
  export const LOADING_TRANSITION_DURATION = 7500;
9
9
 
10
+ export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250;
11
+
10
12
  export const DOCUMENTATION_SOURCE_TYPES = {
11
13
  HANDBOOK: {
12
14
  value: 'handbook',