@gitlab/duo-ui 15.3.0 → 15.4.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/components/agentic_chat/agentic_duo_chat.js +1 -1
  3. package/dist/components/agentic_chat/web_agentic_duo_chat.js +1 -1
  4. package/dist/components/chat/components/duo_chat_message/duo_chat_message.js +21 -28
  5. package/dist/components/chat/components/duo_chat_message/markdown_renderer.js +102 -0
  6. package/dist/components/chat/components/duo_chat_message/message_types/message_agent.js +5 -6
  7. package/dist/components/chat/components/duo_chat_message/message_types/message_base.js +27 -14
  8. package/dist/components/chat/components/duo_chat_message/message_types/message_tool_kv_section.js +1 -1
  9. package/dist/components/chat/components/duo_chat_message_tool_approval/components/base_tool_params.js +1 -1
  10. package/dist/components/chat/components/duo_chat_message_tool_approval/components/create_commit_tool_params.js +9 -3
  11. package/dist/components/chat/components/duo_chat_message_tool_approval/components/pre_block.js +11 -6
  12. package/dist/components/chat/components/duo_chat_message_tool_approval/components/tool_params_json_view.js +1 -1
  13. package/dist/components/chat/components/duo_chat_threads/duo_chat_threads.js +1 -1
  14. package/dist/components/chat/components/duo_chat_threads/{duo_chat_threads_skeleton.js → duo_chat_threads_skeleton_loader.js} +4 -2
  15. package/dist/components/chat/duo_chat.js +1 -1
  16. package/dist/components/chat/markdown_renderer.js +31 -5
  17. package/dist/components/chat/mock_data.js +28 -2
  18. package/dist/components/chat/web_duo_chat.js +1 -1
  19. package/dist/components/ui/duo_layout/duo_layout.js +1 -1
  20. package/dist/components.css +1 -1
  21. package/dist/components.css.map +1 -1
  22. package/dist/utils/highlight.js +483 -0
  23. package/package.json +3 -2
  24. package/src/components/agentic_chat/agentic_duo_chat.scss +2 -1
  25. package/src/components/agentic_chat/agentic_duo_chat.vue +1 -1
  26. package/src/components/agentic_chat/web_agentic_duo_chat.vue +1 -1
  27. package/src/components/chat/components/duo_chat_message/duo_chat_message.scss +15 -19
  28. package/src/components/chat/components/duo_chat_message/duo_chat_message.vue +30 -28
  29. package/src/components/chat/components/duo_chat_message/markdown_renderer.vue +71 -0
  30. package/src/components/chat/components/duo_chat_message/message_types/message_agent.vue +8 -8
  31. package/src/components/chat/components/duo_chat_message/message_types/message_base.vue +24 -14
  32. package/src/components/chat/components/duo_chat_message/message_types/message_tool_kv_section.vue +1 -1
  33. package/src/components/chat/components/duo_chat_message_tool_approval/components/base_tool_params.vue +1 -1
  34. package/src/components/chat/components/duo_chat_message_tool_approval/components/create_commit_tool_params.vue +7 -2
  35. package/src/components/chat/components/duo_chat_message_tool_approval/components/pre_block.vue +11 -6
  36. package/src/components/chat/components/duo_chat_message_tool_approval/components/tool_params_json_view.vue +1 -1
  37. package/src/components/chat/components/duo_chat_threads/duo_chat_threads.vue +1 -1
  38. package/src/components/chat/components/duo_chat_threads/{duo_chat_threads_skeleton.vue → duo_chat_threads_skeleton_loader.vue} +4 -4
  39. package/src/components/chat/duo_chat.vue +1 -1
  40. package/src/components/chat/markdown_renderer.js +37 -6
  41. package/src/components/chat/mock_data.js +30 -1
  42. package/src/components/chat/web_duo_chat.vue +1 -1
  43. package/src/components/ui/duo_layout/duo_layout.vue +1 -1
  44. package/src/utils/highlight.js +562 -0
  45. package/dist/components/chat/components/duo_chat_message_tool_approval/services/highlight.js +0 -21
  46. package/src/components/chat/components/duo_chat_message_tool_approval/services/highlight.js +0 -18
@@ -14,9 +14,9 @@ import { MESSAGE_MODEL_ROLES, SELECTED_CONTEXT_ITEMS_DEFAULT_COLLAPSED } from '.
14
14
 
15
15
  import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_sources.vue';
16
16
  // eslint-disable-next-line no-restricted-imports
17
- import { renderDuoChatMarkdownPreview } from '../../markdown_renderer';
18
17
  import { copyToClipboard, concatUntilEmpty } from '../utils';
19
18
  import MessageFeedback from './message_feedback.vue';
19
+ import MarkdownRenderer from './markdown_renderer.vue';
20
20
  import { CopyCodeElement } from './copy_code_element';
21
21
  import { InsertCodeSnippetElement } from './insert_code_snippet_element';
22
22
 
@@ -48,6 +48,7 @@ export default {
48
48
  GlIcon,
49
49
  GlAnimatedLoaderIcon,
50
50
  GlButton,
51
+ MarkdownRenderer,
51
52
  },
52
53
  directives: {
53
54
  SafeHtml,
@@ -64,10 +65,6 @@ export default {
64
65
  element.classList.add('duo-chat-markdown', 'duo-chat-compact-markdown');
65
66
  },
66
67
  },
67
- renderMarkdown: {
68
- from: 'renderMarkdown',
69
- default: () => renderDuoChatMarkdownPreview,
70
- },
71
68
  },
72
69
  props: {
73
70
  /**
@@ -147,31 +144,25 @@ export default {
147
144
  hasFeedback() {
148
145
  return Boolean(this.message.extras?.hasFeedback);
149
146
  },
147
+ hasContentHtml() {
148
+ return this.message.contentHtml?.length > 0;
149
+ },
150
150
  defaultContent() {
151
- if (this.message.contentHtml) {
151
+ if (this.hasContentHtml) {
152
152
  return this.message.contentHtml;
153
153
  }
154
154
 
155
- return this.renderMarkdown(this.message.content, { trustedUrls: this.trustedUrls });
155
+ return this.message.content;
156
156
  },
157
157
  messageContent() {
158
158
  if (this.isAssistantMessage && this.isChunk) {
159
- return this.renderMarkdown(concatUntilEmpty(this.messageChunks), {
160
- trustedUrls: this.trustedUrls,
161
- });
159
+ return concatUntilEmpty(this.messageChunks);
162
160
  }
163
161
 
164
- return (
165
- this.renderMarkdown(this.defaultContent, { trustedUrls: this.trustedUrls }) ||
166
- this.renderMarkdown(concatUntilEmpty(this.message.chunks), {
167
- trustedUrls: this.trustedUrls,
168
- })
169
- );
162
+ return this.defaultContent || concatUntilEmpty(this.message.chunks);
170
163
  },
171
164
  renderedError() {
172
- return this.renderMarkdown(this.message.errors?.join('; ') || '', {
173
- trustedUrls: this.trustedUrls,
174
- });
165
+ return this.message.errors?.join('; ');
175
166
  },
176
167
  error() {
177
168
  return Boolean(this.message?.errors?.length) && this.message.errors.join('; ');
@@ -255,12 +246,14 @@ export default {
255
246
  this.messageWatcher = null; // Ensure the watcher can't be stopped multiple times
256
247
  }
257
248
  },
258
- hydrateContentWithGFM() {
259
- if (this.isDoneStreaming && this.$refs.content) {
260
- this.$nextTick(() => {
261
- this.renderGFM(this.$refs.content);
262
- });
249
+ async hydrateContentWithGFM() {
250
+ await this.$nextTick();
251
+ if (this.isDoneStreaming) {
252
+ if (this.$refs.content) {
253
+ this.renderGFM(this.$refs.content.$el);
254
+ }
263
255
  }
256
+
264
257
  this.detectScrollableCodeBlocks();
265
258
  },
266
259
  logEvent(e) {
@@ -339,6 +332,7 @@ export default {
339
332
  :class="{
340
333
  'gl-pr-7': isAssistantMessage,
341
334
  'gl-justify-end gl-pl-7': isUserMessage,
335
+ 'duo-chat-message-complete': isDoneStreaming,
342
336
  }"
343
337
  @insert-code-snippet="onInsertCodeSnippet"
344
338
  @copy-code-snippet="onCopyCodeSnippet"
@@ -356,7 +350,13 @@ export default {
356
350
  class="error-icon !gl-mt-1 gl-mr-3 gl-shrink-0 gl-text-danger"
357
351
  data-testid="error"
358
352
  />
359
- <div ref="error-message" v-safe-html:[$options.safeHtmlConfigExtension]="renderedError"></div>
353
+ <markdown-renderer
354
+ v-if="error"
355
+ ref="error-message"
356
+ class="duo-chat-message-error"
357
+ :markdown="renderedError"
358
+ :trusted-urls="trustedUrls"
359
+ />
360
360
  </div>
361
361
  <template v-else-if="isAssistantMessage || isUserMessage">
362
362
  <div class="gl-sr-only">
@@ -372,14 +372,16 @@ export default {
372
372
  @get-content="onGetContextItemContent"
373
373
  />
374
374
 
375
- <div
375
+ <markdown-renderer
376
376
  ref="content"
377
- v-safe-html:[$options.safeHtmlConfigExtension]="messageContent"
378
377
  class="duo-chat-message"
379
378
  :class="{
380
379
  'gl-bg-feedback-info gl-p-4 gl-text-feedback-info': isUserMessage,
381
380
  }"
382
- ></div>
381
+ :is-html="hasContentHtml"
382
+ :markdown="messageContent"
383
+ :trusted-urls="trustedUrls"
384
+ />
383
385
 
384
386
  <documentation-sources v-if="sources" :sources="sources" />
385
387
 
@@ -0,0 +1,71 @@
1
+ <script>
2
+ import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
3
+ // eslint-disable-next-line no-restricted-imports
4
+ import { renderDuoChatMarkdownPreview } from '../../markdown_renderer';
5
+
6
+ export default {
7
+ name: 'MarkdownRenderer',
8
+ directives: {
9
+ SafeHtml,
10
+ },
11
+ inject: {
12
+ // Note, we likely might move away from Provide/Inject for this
13
+ // and only ship the versions that are currently in the default
14
+ // See https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3953#note_1762834219
15
+ // for more context.
16
+ renderGFM: {
17
+ from: 'renderGFM',
18
+ default: () => (element) => {
19
+ element.classList.add('gl-markdown', 'gl-compact-markdown', 'gl-text-sm');
20
+ },
21
+ },
22
+ renderMarkdown: {
23
+ from: 'renderMarkdown',
24
+ default: () => renderDuoChatMarkdownPreview,
25
+ },
26
+ },
27
+ props: {
28
+ markdown: {
29
+ type: String,
30
+ required: false,
31
+ default: '',
32
+ },
33
+ isHtml: {
34
+ type: Boolean,
35
+ required: false,
36
+ default: false,
37
+ },
38
+ trustedUrls: {
39
+ type: Array,
40
+ required: false,
41
+ default: () => [],
42
+ },
43
+ },
44
+ data() {
45
+ return {
46
+ renderedMarkdown: '',
47
+ };
48
+ },
49
+ watch: {
50
+ async markdown() {
51
+ this.render();
52
+ },
53
+ },
54
+ created() {
55
+ this.render();
56
+ },
57
+ methods: {
58
+ async render() {
59
+ this.renderedMarkdown = this.isHtml
60
+ ? this.markdown
61
+ : await this.renderMarkdown(this.markdown, this.trustedUrls);
62
+ },
63
+ },
64
+ safeHtmlConfigExtension: {
65
+ ADD_TAGS: ['copy-code', 'insert-code-snippet'],
66
+ },
67
+ };
68
+ </script>
69
+ <template>
70
+ <div ref="content" v-safe-html:[$options.safeHtmlConfigExtension]="renderedMarkdown"></div>
71
+ </template>
@@ -1,17 +1,16 @@
1
1
  <script>
2
- import { GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
2
+ import { GlIcon } from '@gitlab/ui';
3
3
  import MessageFeedback from '../message_feedback.vue';
4
+ import MarkdownRenderer from '../markdown_renderer.vue';
4
5
  import BaseMessage from './message_base.vue';
5
6
 
6
7
  export default {
7
8
  name: 'DuoAgentMessage',
8
9
  components: {
9
10
  BaseMessage,
10
- MessageFeedback,
11
11
  GlIcon,
12
- },
13
- directives: {
14
- SafeHtml,
12
+ MessageFeedback,
13
+ MarkdownRenderer,
15
14
  },
16
15
  safeHtmlConfigExtension: {
17
16
  ADD_TAGS: ['copy-code', 'insert-code-snippet'],
@@ -57,12 +56,13 @@ export default {
57
56
  </div>
58
57
  </div>
59
58
  <slot name="message" v-bind="slotProps">
60
- <div
59
+ <markdown-renderer
61
60
  ref="content"
62
- v-safe-html:[$options.safeHtmlConfigExtension]="slotProps.htmlContent"
61
+ :is-html="slotProps.isHtml"
62
+ :markdown="slotProps.content"
63
63
  @insert-code-snippet="onInsertCodeSnippet"
64
64
  @copy-code-snippet="onCopyCodeSnippet"
65
- ></div>
65
+ />
66
66
  </slot>
67
67
 
68
68
  <message-feedback v-if="withFeedback" @feedback="$emit('feedback', $event)" />
@@ -1,15 +1,14 @@
1
1
  <script>
2
- import { GlIcon, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
3
- // eslint-disable-next-line no-restricted-imports
4
- import { renderDuoChatMarkdownPreview } from '../../../markdown_renderer';
2
+ import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
3
+ import MarkdownRenderer from '../markdown_renderer.vue';
5
4
 
6
5
  export default {
7
6
  name: 'DuoBaseMessage',
8
7
  components: {
9
8
  GlIcon,
9
+ MarkdownRenderer,
10
10
  },
11
11
  directives: {
12
- SafeHtml,
13
12
  GlTooltip: GlTooltipDirective,
14
13
  },
15
14
  inject: {
@@ -23,10 +22,6 @@ export default {
23
22
  element.classList.add('gl-markdown', 'gl-compact-markdown', 'gl-text-sm');
24
23
  },
25
24
  },
26
- renderMarkdown: {
27
- from: 'renderMarkdown',
28
- default: () => renderDuoChatMarkdownPreview,
29
- },
30
25
  },
31
26
  props: {
32
27
  /**
@@ -41,11 +36,25 @@ export default {
41
36
  },
42
37
  },
43
38
  computed: {
39
+ isDoneStreaming() {
40
+ // Agentic chat format
41
+ if (Object.hasOwn(this.message, 'status')) {
42
+ return this.message.status === 'success';
43
+ }
44
+ // Classic chat format
45
+ return !this.isChunk;
46
+ },
47
+ isChunk() {
48
+ return typeof this.message.chunkId === 'number';
49
+ },
50
+ hasContentHtml() {
51
+ return this.message?.contentHtml?.length > 0;
52
+ },
44
53
  messageContent() {
45
- return this.renderMarkdown(this.message.content);
54
+ return this.hasContentHtml ? this.message.contentHtml : this.message?.content;
46
55
  },
47
56
  renderedError() {
48
- return this.renderMarkdown(this.message.errors?.join('; ') || '');
57
+ return this.message.errors?.join('; ') || '';
49
58
  },
50
59
  hasError() {
51
60
  return Boolean(this.message?.errors?.length);
@@ -61,7 +70,7 @@ export default {
61
70
  hydrateContentWithGFM() {
62
71
  if (this.$refs.content) {
63
72
  this.$nextTick(() => {
64
- this.renderGFM(this.$refs.content, this.message.role);
73
+ this.renderGFM(this.$refs.content.$el, this.message.role);
65
74
  });
66
75
  }
67
76
  },
@@ -73,6 +82,7 @@ export default {
73
82
  class="duo-chat-message gl-w-full gl-flex-grow gl-leading-20 gl-break-anywhere"
74
83
  :class="{
75
84
  'gl-items-top gl-flex': hasError,
85
+ 'duo-chat-message-complete': isDoneStreaming,
76
86
  }"
77
87
  >
78
88
  <gl-icon
@@ -83,10 +93,10 @@ export default {
83
93
  data-testid="error"
84
94
  />
85
95
  <div ref="content-wrapper" :class="{ 'has-error': hasError }">
86
- <div v-if="hasError" ref="error-message" v-safe-html="renderedError"></div>
96
+ <markdown-renderer v-if="hasError" ref="error-message" :markdown="renderedError" />
87
97
  <div v-else>
88
- <slot name="message" v-bind="{ content: message.content, htmlContent: messageContent }">
89
- <div ref="content" v-safe-html="messageContent"></div>
98
+ <slot name="message" v-bind="{ content: messageContent, isHtml: hasContentHtml }">
99
+ <markdown-renderer ref="content" :is-html="hasContentHtml" :markdown="messageContent" />
90
100
  </slot>
91
101
  </div>
92
102
  </div>
@@ -65,7 +65,7 @@ export default {
65
65
  >
66
66
  {{ title }}
67
67
  </figcaption>
68
- <pre-block ref="content" language="json" class="gl-m-0 !gl-border-0">{{
68
+ <pre-block ref="content" :languages="['json']" class="gl-m-0 !gl-border-0">{{
69
69
  prettyText
70
70
  }}</pre-block>
71
71
  </figure>
@@ -169,7 +169,7 @@ export default {
169
169
 
170
170
  <gl-accordion v-if="message || description" class="-gl-ml-2" :header-level="3">
171
171
  <gl-accordion-item v-if="description" class="gl-mb-3" :title="accordionTitle">
172
- <pre-block language="markdown">{{ description }}</pre-block>
172
+ <pre-block :languages="['markdown']">{{ description }}</pre-block>
173
173
  </gl-accordion-item>
174
174
  <gl-accordion-item v-if="withJsonView" :title="$options.i18n.EXPAND_JSON_VIEW_TITLE">
175
175
  <tool-params-json-view :tool-params="toolParams" />
@@ -70,6 +70,9 @@ export default {
70
70
  return sprintf(this.$options.i18n.UNKNOWN_FILE_ACTION_LABEL, { filePath });
71
71
  }
72
72
  },
73
+ getActionFileExtension({ file_path: filePath }) {
74
+ return filePath.split('.').pop() || 'plaintext';
75
+ },
73
76
  getActionContent({
74
77
  action,
75
78
  content,
@@ -168,7 +171,7 @@ export default {
168
171
  class="gl-mb-3"
169
172
  :title="$options.i18n.READ_COMMIT_MESSAGE"
170
173
  >
171
- <pre-block language="markdown">{{ commitMessage }}</pre-block>
174
+ <pre-block :languages="['markdown']">{{ commitMessage }}</pre-block>
172
175
  </gl-accordion-item>
173
176
  <gl-accordion-item v-if="actionsCount" :title="$options.i18n.EXPAND_CHANGES">
174
177
  <gl-accordion :header-level="4">
@@ -178,7 +181,9 @@ export default {
178
181
  class="gl-mb-3"
179
182
  :title="getActionTitle(action)"
180
183
  >
181
- <pre-block language="diff">{{ getActionContent(action) }}</pre-block>
184
+ <pre-block :languages="[getActionFileExtension(action), 'diff']">{{
185
+ getActionContent(action)
186
+ }}</pre-block>
182
187
  </gl-accordion-item>
183
188
  </gl-accordion>
184
189
  </gl-accordion-item>
@@ -1,8 +1,8 @@
1
1
  <script>
2
2
  import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
3
3
  import { translate } from '../../../../../utils/i18n';
4
+ import { highlightElement } from '../../../../../utils/highlight';
4
5
  import { copyToClipboard } from '../../utils';
5
- import { highlightElement } from '../services/highlight';
6
6
 
7
7
  export default {
8
8
  name: 'PreBlock',
@@ -13,10 +13,15 @@ export default {
13
13
  GlButton,
14
14
  },
15
15
  props: {
16
- language: {
17
- type: String,
16
+ languages: {
17
+ type: Array,
18
18
  required: false,
19
- default: 'plaintext',
19
+ default: () => [],
20
+ },
21
+ },
22
+ computed: {
23
+ languageClasses() {
24
+ return this.languages.map((lang) => `language-${lang}`);
20
25
  },
21
26
  },
22
27
  async mounted() {
@@ -30,7 +35,7 @@ export default {
30
35
  const codeElement = this.$el.querySelector('code');
31
36
 
32
37
  if (codeElement) {
33
- highlightElement(codeElement);
38
+ highlightElement(codeElement, this.languages);
34
39
  }
35
40
  } catch (error) {
36
41
  // Silently fail if highlight.js is not available
@@ -77,5 +82,5 @@ export default {
77
82
  />
78
83
  </span>
79
84
 
80
- <code ref="codeElement" :class="`language-${language}`" class="!gl-leading-20 gl-break-all"><slot></slot></code></pre>
85
+ <code ref="codeElement" :class="languageClasses" class="!gl-leading-20 gl-break-all"><slot></slot></code></pre>
81
86
  </template>
@@ -32,7 +32,7 @@ export default {
32
32
  <figcaption class="gl-text-subtle">
33
33
  {{ $options.i18n.REQUEST_TEXT }}
34
34
  </figcaption>
35
- <pre-block v-if="hasToolParams" language="json" data-testid="tool-parameters">{{
35
+ <pre-block v-if="hasToolParams" :languages="['json']" data-testid="tool-parameters">{{
36
36
  JSON.stringify(toolParams, null, 2)
37
37
  }}</pre-block>
38
38
  <span v-else class="gl-text-sm gl-text-gray-500" data-testid="no-parameters-message">
@@ -3,7 +3,7 @@ import { GlButton, GlAvatar } from '@gitlab/ui';
3
3
  import { sprintf, translate } from '../../../../utils/i18n';
4
4
  import { formatLocalizedDate } from '../../../../utils/date';
5
5
  import DuoChatThreadsEmpty from './duo_chat_threads_empty.vue';
6
- import DuoChatThreadsSkeleton from './duo_chat_threads_skeleton.vue';
6
+ import DuoChatThreadsSkeleton from './duo_chat_threads_skeleton_loader.vue';
7
7
 
8
8
  const i18n = {
9
9
  CHAT_HISTORY_INFO: translate(
@@ -8,6 +8,8 @@ const i18n = {
8
8
  export default {
9
9
  name: 'DuoChatThreadsSkeleton',
10
10
  i18n,
11
+ groups: 3,
12
+ threadItems: 2,
11
13
  };
12
14
  </script>
13
15
 
@@ -16,17 +18,15 @@ export default {
16
18
  <span class="gl-sr-only" role="status" aria-live="polite" aria-atomic="true">{{
17
19
  $options.i18n.LOADING_THREADS
18
20
  }}</span>
19
- <div v-for="groupIndex in 3" :key="groupIndex" class="gl-mb-3">
21
+ <div v-for="groupIndex in $options.groups" :key="groupIndex" class="gl-mb-3">
20
22
  <div
21
23
  class="gl-animate-skeleton-loader gl-mb-3 gl-h-4 !gl-max-w-26 gl-rounded-default"
22
- style="animation-delay: 0ms"
23
24
  data-testid="chat-threads-skeleton-date-header"
24
25
  ></div>
25
26
  <div
26
- v-for="itemIndex in 2"
27
+ v-for="itemIndex in $options.threadItems"
27
28
  :key="`group${groupIndex}-item${itemIndex}`"
28
29
  class="gl-mb-5 gl-flex gl-items-center gl-gap-3"
29
- style="animation-delay: 0ms"
30
30
  data-testid="chat-threads-skeleton-item"
31
31
  >
32
32
  <div class="gl-animate-skeleton-loader gl-h-8 gl-w-8 gl-shrink-0 gl-rounded-full"></div>
@@ -732,7 +732,7 @@ export default {
732
732
  'resizable-content': shouldRenderResizable,
733
733
  'duo-chat-drawer': !shouldRenderResizable,
734
734
  }"
735
- class="markdown-code-block duo-chat gl-bottom-0 gl-flex gl-max-h-full gl-flex-col"
735
+ class="duo-chat gl-bottom-0 gl-flex gl-max-h-full gl-flex-col"
736
736
  role="complementary"
737
737
  data-testid="chat-component"
738
738
  >
@@ -1,13 +1,32 @@
1
1
  // eslint-disable-next-line no-restricted-imports
2
- import { Marked } from 'marked';
2
+ import { Marked, Renderer } from 'marked';
3
3
  import markedBidi from 'marked-bidi';
4
+ import { markedHighlight } from 'marked-highlight';
4
5
  import DOMPurify from 'dompurify';
6
+ import { highlightCode } from '../../utils/highlight';
5
7
 
8
+ const baseOptions = {
9
+ async: true,
10
+ breaks: false,
11
+ gfm: false,
12
+ };
13
+ const baseRenderer = new Renderer({ ...baseOptions });
14
+ const customCodeRenderer = {
15
+ code(...args) {
16
+ const baseCode = baseRenderer.code(...args);
17
+
18
+ // Insert copy-code elements inside the <pre> tag
19
+ return baseCode
20
+ .replace(
21
+ '<pre>',
22
+ '<div class="gl-relative gl-flex js-markdown-code duo-message-pre-block !focus:gl-focus" tabindex="0">\n<copy-code></copy-code>\n<insert-code-snippet></insert-code-snippet>\n<pre class="code code-syntax-highlight-theme duo-message-pre-block gl-grow gl-overflow-auto gl-grid">'
23
+ )
24
+ .replace('</pre>', '</pre></div>');
25
+ },
26
+ };
6
27
  const duoMarked = new Marked([
7
28
  {
8
- async: false,
9
- breaks: false,
10
- gfm: false,
29
+ ...baseOptions,
11
30
  },
12
31
  markedBidi(),
13
32
  ]);
@@ -134,13 +153,13 @@ function isHtml(markup) {
134
153
  return Array.from(doc.body.childNodes).some((n) => n.nodeType === Node.ELEMENT_NODE);
135
154
  }
136
155
 
137
- export function renderDuoChatMarkdownPreview(md, { trustedUrls = [] } = {}) {
156
+ export async function renderDuoChatMarkdownPreview(md, { trustedUrls = [] } = {}) {
138
157
  if (!md) return '';
139
158
 
140
159
  DOMPurify.addHook('beforeSanitizeElements', handleImageElements);
141
160
  DOMPurify.addHook('afterSanitizeAttributes', sanitizeLinksHook(trustedUrls));
142
161
 
143
- const parsedMarkdown = isHtml(md) ? md : duoMarked.parse(md.toString());
162
+ const parsedMarkdown = isHtml(md) ? md : await duoMarked.parse(md.toString());
144
163
 
145
164
  const sanitized = DOMPurify.sanitize(parsedMarkdown, config);
146
165
 
@@ -149,6 +168,18 @@ export function renderDuoChatMarkdownPreview(md, { trustedUrls = [] } = {}) {
149
168
  return sanitized;
150
169
  }
151
170
 
171
+ duoMarked.use(
172
+ markedHighlight({
173
+ async: true,
174
+ langPrefix: 'hljs language-',
175
+ highlight(code, lang) {
176
+ return highlightCode(code, [lang]);
177
+ },
178
+ })
179
+ );
180
+
181
+ duoMarked.use({ renderer: customCodeRenderer });
182
+
152
183
  export function addDuoMarkdownPlugin(plugin) {
153
184
  duoMarked.use(plugin);
154
185
  }
@@ -91,7 +91,7 @@ export const MOCK_TOOL_MESSAGE = {
91
91
 
92
92
  export const MOCK_AGENT_MESSAGE = {
93
93
  id: '123',
94
- content: `I'll write a simple Python function with comments for you. Here's an example:\n\n<div class="gl-relative markdown-code-block js-markdown-code"><pre><code class="language-python"><span class="hljs-keyword">def</span> <span class="hljs-title function_">factorial</span>(<span class="hljs-params">n</span>):
94
+ contentHtml: `I'll write a simple Python function with comments for you. Here's an example:\n\n<div class="gl-relative markdown-code-block js-markdown-code"><pre><code class="language-python"><span class="hljs-keyword">def</span> <span class="hljs-title function_">factorial</span>(<span class="hljs-params">n</span>):
95
95
  <span class="hljs-string">&quot;&quot;&quot;
96
96
  Calculate the factorial of a non-negative integer.
97
97
 
@@ -118,6 +118,35 @@ export const MOCK_AGENT_MESSAGE = {
118
118
  message_type: MESSAGE_MODEL_ROLES.agent,
119
119
  };
120
120
 
121
+ export const MOCK_AGENT_MESSAGE_WITH_MARKDOWN = {
122
+ status: 'success',
123
+ content:
124
+ "The `GENERATE_SERIES` function in PostgreSQL creates a set of values from a start to an end point, which is incredibly useful for generating sequences, filling gaps in data, or creating test datasets.\n\n#### Basic Syntax\n\n```sql\n-- Generate integers from 1 to 10\nSELECT * FROM GENERATE_SERIES(1, 10);\n\n-- Generate with a step value (every 2)\nSELECT * FROM GENERATE_SERIES(1, 10, 2);\n-- Returns: 1, 3, 5, 7, 9\n```\n\n#### Common Use Cases\n\n**1. Generate date ranges:**\n```sql\n-- Generate all dates in January 2025\nSELECT GENERATE_SERIES(\n '2025-01-01'::timestamp,\n '2025-01-31'::timestamp,\n '1 day'::interval\n) AS date;\n```\n\n**2. Fill missing data gaps:**\n```sql\n-- Find missing IDs in a sequence\nSELECT missing_id\nFROM GENERATE_SERIES(1, 100) AS missing_id\nWHERE missing_id NOT IN (SELECT id FROM your_table);\n```\n\n**3. Create test data:**\n```sql\n-- Generate 1000 rows of test data\nSELECT \n id,\n 'User ' || id AS username,\n NOW() - (id || ' days')::interval AS created_at\nFROM GENERATE_SERIES(1, 1000) AS id;\n```\n\n**4. Time-based aggregations:**\n```sql\n-- Group data by hour, even for hours with no data\nSELECT \n hour_slot,\n COUNT(orders.id) AS order_count\nFROM GENERATE_SERIES(\n '2025-01-01 00:00'::timestamp,\n '2025-01-01 23:00'::timestamp,\n '1 hour'::interval\n) AS hour_slot\nLEFT JOIN orders ON DATE_TRUNC('hour', orders.created_at) = hour_slot\nGROUP BY hour_slot\nORDER BY hour_slot;\n```\n\n**Key points:**\n- Works with integers, bigints, numeric types, and timestamps\n- The step parameter is optional (defaults to 1 for numbers, 1 day for timestamps)\n- For descending sequences, use a negative step: `GENERATE_SERIES(10, 1, -1)`\n- Can be used in `FROM` clauses, joins, or subqueries",
125
+ timestamp: '2025-11-29T13:32:50.545772+00:00',
126
+ tool_info: null,
127
+ message_type: 'agent',
128
+ correlation_id: null,
129
+ message_sub_type: null,
130
+ additional_context: null,
131
+ };
132
+
133
+ export const MOCK_AGENT_MESSAGE_WITH_LONG_CODE_BLOCK = {
134
+ status: 'success',
135
+ content: `
136
+ \`\`\`javascript
137
+ ${Array.from({ length: 500 })
138
+ .map(() => 'console.log("Hello world")')
139
+ .join('\n\n')}
140
+ \`\`\`
141
+ `,
142
+ timestamp: '2025-11-29T13:32:50.545772+00:00',
143
+ tool_info: null,
144
+ message_type: 'agent',
145
+ correlation_id: null,
146
+ message_sub_type: null,
147
+ additional_context: null,
148
+ };
149
+
121
150
  export const MOCK_TOOL_MESSAGE_WITH_LINK = {
122
151
  id: '123',
123
152
  content: "Search for 'duo.*chat.*message' in directory",
@@ -738,7 +738,7 @@ export default {
738
738
  <template>
739
739
  <div
740
740
  id="chat-component"
741
- class="markdown-code-block duo-chat web-only gl-bottom-0 gl-flex gl-max-h-full gl-flex-col"
741
+ class="duo-chat web-only gl-bottom-0 gl-flex gl-max-h-full gl-flex-col"
742
742
  role="complementary"
743
743
  data-testid="chat-component"
744
744
  >
@@ -81,7 +81,7 @@ export default {
81
81
  @resize:end="updateSize"
82
82
  >
83
83
  <aside
84
- class="markdown-code-block duo-chat gl-align-items gl-bottom-0 gl-flex gl-h-full gl-max-h-full gl-flex-row gl-border-subtle"
84
+ class="duo-chat gl-align-items gl-bottom-0 gl-flex gl-h-full gl-max-h-full gl-flex-row gl-border-subtle"
85
85
  >
86
86
  <main
87
87
  class="content flex-none gl-border-r gl-flex gl-h-full gl-min-w-0 gl-grow gl-overflow-y-auto gl-border-subtle"