@gitlab/duo-ui 15.0.0 → 15.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [15.0.1](https://gitlab.com/gitlab-org/duo-ui/compare/v15.0.0...v15.0.1) (2025-11-25)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * Use a generic clipboard copy action ([b1eb7a3](https://gitlab.com/gitlab-org/duo-ui/commit/b1eb7a3b5d56c42346a569bbc14740aeb8a73fb4))
7
+
1
8
  # [15.0.0](https://gitlab.com/gitlab-org/duo-ui/compare/v14.3.0...v15.0.0) (2025-11-21)
2
9
 
3
10
 
@@ -1,6 +1,7 @@
1
1
  import Vue from 'vue';
2
2
  import { GlToast, GlAlert, GlButton, GlExperimentBadge, GlDisclosureDropdown, GlSafeHtmlDirective, GlTooltipDirective } from '@gitlab/ui';
3
3
  import { translate, sprintf } from '../../../../utils/i18n';
4
+ import { copyToClipboard } from '../utils';
4
5
  import { VIEW_TYPES } from './constants';
5
6
  import DuoChatHeaderAgentItem from './duo_chat_header_agent_item/duo_chat_header_agent_item';
6
7
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
@@ -133,7 +134,7 @@ var script = {
133
134
  },
134
135
  async copySessionIdToClipboard() {
135
136
  try {
136
- await navigator.clipboard.writeText(this.sessionId);
137
+ await copyToClipboard(this.sessionId, this.$el);
137
138
  this.$toast.show('Session ID copied to clipboard');
138
139
  } catch {
139
140
  this.$toast.show('Could not copy session ID');
@@ -1,6 +1,7 @@
1
1
  import Vue from 'vue';
2
2
  import { GlToast, GlAlert, GlAvatar, GlButton, GlDisclosureDropdown, GlSafeHtmlDirective, GlTooltipDirective } from '@gitlab/ui';
3
3
  import { translate, sprintf } from '../../../../utils/i18n';
4
+ import { copyToClipboard } from '../utils';
4
5
  import { VIEW_TYPES } from './constants';
5
6
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
6
7
 
@@ -140,7 +141,7 @@ var script = {
140
141
  },
141
142
  async copySessionIdToClipboard() {
142
143
  try {
143
- await navigator.clipboard.writeText(this.sessionId);
144
+ await copyToClipboard(this.sessionId, this.$el);
144
145
  this.$toast.show('Session ID copied to clipboard');
145
146
  } catch {
146
147
  this.$toast.show('Could not copy session ID');
@@ -1,6 +1,6 @@
1
+ import { copyToClipboard } from '../utils';
1
2
  import { createButton } from './buttons_utils';
2
3
  import { createTooltip } from './tooltips_utils';
3
- import { checkClipboardPermissions } from './utils';
4
4
 
5
5
  function _defineProperty(e, r, t) {
6
6
  return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
@@ -46,21 +46,11 @@ class CopyCodeElement extends HTMLElement {
46
46
  // not present in the DOM.
47
47
  const codeElement = this.getCodeElement();
48
48
  const textToCopy = codeElement.innerText;
49
- const hasClipboardPermission = await checkClipboardPermissions();
50
49
  try {
51
- codeElement.dispatchEvent(new CustomEvent('copy-code-snippet', {
52
- bubbles: true,
53
- cancelable: true,
54
- detail: {
55
- code: textToCopy
56
- }
57
- }));
58
- if (hasClipboardPermission) {
59
- await navigator.clipboard.writeText(textToCopy);
60
- }
61
- } catch (e) {
50
+ await copyToClipboard(textToCopy, codeElement);
51
+ } catch (error) {
62
52
  // eslint-disable-next-line no-console
63
- console.warn('Failed to copy snippet:', e);
53
+ console.warn('Failed to copy to clipboard:', error);
64
54
  }
65
55
  });
66
56
  }
@@ -5,10 +5,10 @@ import DuoChatContextItemSelections from '../duo_chat_context/duo_chat_context_i
5
5
  import { SELECTED_CONTEXT_ITEMS_DEFAULT_COLLAPSED, 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
+ import { concatUntilEmpty, copyToClipboard } from '../utils';
8
9
  import MessageFeedback from './message_feedback';
9
10
  import { CopyCodeElement } from './copy_code_element';
10
11
  import { InsertCodeSnippetElement } from './insert_code_snippet_element';
11
- import { concatUntilEmpty, checkClipboardPermissions } from './utils';
12
12
  import { DUO_CODE_SCRIM_BOTTOM_CLASS, DUO_CODE_SCRIM_OFFSET, DUO_CODE_SCRIM_TOP_CLASS } from './constants';
13
13
  import MessageMap from './message_types/message_map';
14
14
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
@@ -170,7 +170,7 @@ var script = {
170
170
  });
171
171
  },
172
172
  shouldShowCopyAction() {
173
- return Boolean(navigator.clipboard && !this.isChunk && this.isAssistantMessage);
173
+ return Boolean(!this.isChunk && this.isAssistantMessage);
174
174
  },
175
175
  shouldShowFeedbackLink() {
176
176
  return this.withFeedback && this.isNotChunkOrCancelled && this.isAssistantMessage;
@@ -273,18 +273,17 @@ var script = {
273
273
  }
274
274
  },
275
275
  async copyMessage() {
276
- const hasPermissions = await checkClipboardPermissions();
277
- if (!this.copied) {
278
- if (hasPermissions) {
279
- await navigator.clipboard.writeText(this.message.content);
280
- } else {
281
- this.$emit('copy-message', {
282
- detail: {
283
- message: this.message.content
284
- }
285
- });
286
- }
276
+ try {
277
+ await copyToClipboard(this.message.content, this.$el);
278
+ this.$emit('copy-message', {
279
+ detail: {
280
+ message: this.message.content
281
+ }
282
+ });
287
283
  this.copied = true;
284
+ } catch (error) {
285
+ // eslint-disable-next-line no-console
286
+ console.warn('Failed to copy message:', error);
288
287
  }
289
288
  },
290
289
  onOpenFilePath(e) {
@@ -1,6 +1,6 @@
1
1
  import { GlTooltipDirective, GlButton } from '@gitlab/ui';
2
2
  import { translate } from '../../../../../utils/i18n';
3
- import { checkClipboardPermissions } from '../../duo_chat_message/utils';
3
+ import { copyToClipboard } from '../../utils';
4
4
  import { highlightElement } from '../services/highlight';
5
5
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
6
6
 
@@ -39,26 +39,7 @@ var script = {
39
39
  async copyToClipboard() {
40
40
  try {
41
41
  const textToCopy = this.$refs.codeElement.textContent;
42
- const hasClipboardPermission = await checkClipboardPermissions();
43
- const event = new CustomEvent('copy-code-snippet', {
44
- bubbles: true,
45
- detail: {
46
- code: textToCopy
47
- }
48
- });
49
- // Emit custom event for VS Code and other environments
50
- // This bubbles up to parent components that handle clipboard operations
51
- this.$emit('copy-code-snippet', {
52
- detail: {
53
- code: textToCopy
54
- }
55
- });
56
- this.$el.dispatchEvent(event);
57
-
58
- // If we have clipboard permission, also copy directly
59
- if (hasClipboardPermission) {
60
- await navigator.clipboard.writeText(textToCopy);
61
- }
42
+ await copyToClipboard(textToCopy, this.$el);
62
43
  this.$emit('copied', this.title);
63
44
  setTimeout(() => {
64
45
  this.$refs.copyToClipboardButton.$el.blur();
@@ -0,0 +1,56 @@
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
+ /**
9
+ * Programmatically copies a text string to the clipboard. Attempts to copy in both secure and non-secure contexts as well as VSCode.
10
+ *
11
+ * Accepts a container element. This helps ensure the text can get copied to the clipboard correctly in non-secure
12
+ * environments, the container should be active (such as a button in a modal) to ensure the content can be copied.
13
+ *
14
+ * @param {String} text - Text to copy
15
+ * @param {HTMLElement} el - element that emits event for VSCode, and contains the fallback textarea.
16
+ */
17
+ const copyToClipboard = (textToCopy, el) => {
18
+ // Fist, emit a custom event for VS Code extensions and other environments
19
+ // This bubbles up to parent components that handle clipboard operations
20
+ el.dispatchEvent(new CustomEvent('copy-code-snippet', {
21
+ bubbles: true,
22
+ cancelable: true,
23
+ detail: {
24
+ code: textToCopy
25
+ }
26
+ }));
27
+
28
+ // Second, for modern browsers try a simple clipboard.writeText (works on https and localhost)
29
+ if (navigator.clipboard && window.isSecureContext) {
30
+ return navigator.clipboard.writeText(textToCopy);
31
+ }
32
+
33
+ // Third, try execCommand to copy from a dynamically created invisible textarea (for http and older browsers)
34
+ const textarea = document.createElement('textarea');
35
+ textarea.value = textToCopy;
36
+ textarea.style.position = 'absolute';
37
+ textarea.style.left = '-9999px'; // eslint-disable-line @gitlab/require-i18n-strings
38
+ textarea.style.top = '0';
39
+ textarea.setAttribute('readonly', ''); // prevent keyboard popup on mobile
40
+
41
+ // textarea must be in document to be selectable, but we add it to the button so it works in modals
42
+ el.appendChild(textarea);
43
+ textarea.select(); // for Safari
44
+ textarea.setSelectionRange(0, textarea.value.length); // for mobile devices
45
+
46
+ try {
47
+ const done = document.execCommand('copy');
48
+ el.removeChild(textarea);
49
+ return done ? Promise.resolve() : Promise.reject(new Error('Copy command failed'));
50
+ } catch (err) {
51
+ el.removeChild(textarea);
52
+ return Promise.reject(err);
53
+ }
54
+ };
55
+
56
+ export { concatUntilEmpty, copyToClipboard };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/duo-ui",
3
- "version": "15.0.0",
3
+ "version": "15.0.1",
4
4
  "description": "Duo UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,7 @@ import {
10
10
  GlDisclosureDropdown,
11
11
  } from '@gitlab/ui';
12
12
  import { sprintf, translate } from '../../../../utils/i18n';
13
+ import { copyToClipboard } from '../utils';
13
14
  import { VIEW_TYPES } from './constants';
14
15
  import DuoChatHeaderAgentItem from './duo_chat_header_agent_item/duo_chat_header_agent_item.vue';
15
16
 
@@ -150,7 +151,8 @@ export default {
150
151
  },
151
152
  async copySessionIdToClipboard() {
152
153
  try {
153
- await navigator.clipboard.writeText(this.sessionId);
154
+ await copyToClipboard(this.sessionId, this.$el);
155
+
154
156
  this.$toast.show('Session ID copied to clipboard');
155
157
  } catch {
156
158
  this.$toast.show('Could not copy session ID');
@@ -10,6 +10,7 @@ import {
10
10
  GlDisclosureDropdown,
11
11
  } from '@gitlab/ui';
12
12
  import { sprintf, translate } from '../../../../utils/i18n';
13
+ import { copyToClipboard } from '../utils';
13
14
  import { VIEW_TYPES } from './constants';
14
15
 
15
16
  export const i18n = {
@@ -157,7 +158,8 @@ export default {
157
158
  },
158
159
  async copySessionIdToClipboard() {
159
160
  try {
160
- await navigator.clipboard.writeText(this.sessionId);
161
+ await copyToClipboard(this.sessionId, this.$el);
162
+
161
163
  this.$toast.show('Session ID copied to clipboard');
162
164
  } catch {
163
165
  this.$toast.show('Could not copy session ID');
@@ -1,6 +1,6 @@
1
+ import { copyToClipboard } from '../utils';
1
2
  import { createButton } from './buttons_utils';
2
3
  import { createTooltip } from './tooltips_utils';
3
- import { checkClipboardPermissions } from './utils';
4
4
 
5
5
  export class CopyCodeElement extends HTMLElement {
6
6
  constructor() {
@@ -23,25 +23,12 @@ export class CopyCodeElement extends HTMLElement {
23
23
  // not present in the DOM.
24
24
  const codeElement = this.getCodeElement();
25
25
  const textToCopy = codeElement.innerText;
26
- const hasClipboardPermission = await checkClipboardPermissions();
27
26
 
28
27
  try {
29
- codeElement.dispatchEvent(
30
- new CustomEvent('copy-code-snippet', {
31
- bubbles: true,
32
- cancelable: true,
33
- detail: {
34
- code: textToCopy,
35
- },
36
- })
37
- );
38
-
39
- if (hasClipboardPermission) {
40
- await navigator.clipboard.writeText(textToCopy);
41
- }
42
- } catch (e) {
28
+ await copyToClipboard(textToCopy, codeElement);
29
+ } catch (error) {
43
30
  // eslint-disable-next-line no-console
44
- console.warn('Failed to copy snippet:', e);
31
+ console.warn('Failed to copy to clipboard:', error);
45
32
  }
46
33
  });
47
34
  }
@@ -15,10 +15,11 @@ import { MESSAGE_MODEL_ROLES, SELECTED_CONTEXT_ITEMS_DEFAULT_COLLAPSED } from '.
15
15
  import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_sources.vue';
16
16
  // eslint-disable-next-line no-restricted-imports
17
17
  import { renderDuoChatMarkdownPreview } from '../../markdown_renderer';
18
+ import { copyToClipboard, concatUntilEmpty } from '../utils';
18
19
  import MessageFeedback from './message_feedback.vue';
19
20
  import { CopyCodeElement } from './copy_code_element';
20
21
  import { InsertCodeSnippetElement } from './insert_code_snippet_element';
21
- import { checkClipboardPermissions, concatUntilEmpty } from './utils';
22
+
22
23
  import {
23
24
  DUO_CODE_SCRIM_BOTTOM_CLASS,
24
25
  DUO_CODE_SCRIM_OFFSET,
@@ -197,7 +198,7 @@ export default {
197
198
  );
198
199
  },
199
200
  shouldShowCopyAction() {
200
- return Boolean(navigator.clipboard && !this.isChunk && this.isAssistantMessage);
201
+ return Boolean(!this.isChunk && this.isAssistantMessage);
201
202
  },
202
203
  shouldShowFeedbackLink() {
203
204
  return this.withFeedback && this.isNotChunkOrCancelled && this.isAssistantMessage;
@@ -302,19 +303,18 @@ export default {
302
303
  }
303
304
  },
304
305
  async copyMessage() {
305
- const hasPermissions = await checkClipboardPermissions();
306
+ try {
307
+ await copyToClipboard(this.message.content, this.$el);
306
308
 
307
- if (!this.copied) {
308
- if (hasPermissions) {
309
- await navigator.clipboard.writeText(this.message.content);
310
- } else {
311
- this.$emit('copy-message', {
312
- detail: {
313
- message: this.message.content,
314
- },
315
- });
316
- }
309
+ this.$emit('copy-message', {
310
+ detail: {
311
+ message: this.message.content,
312
+ },
313
+ });
317
314
  this.copied = true;
315
+ } catch (error) {
316
+ // eslint-disable-next-line no-console
317
+ console.warn('Failed to copy message:', error);
318
318
  }
319
319
  },
320
320
  onOpenFilePath(e) {
@@ -1,7 +1,7 @@
1
1
  <script>
2
2
  import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
3
3
  import { translate } from '../../../../../utils/i18n';
4
- import { checkClipboardPermissions } from '../../duo_chat_message/utils';
4
+ import { copyToClipboard } from '../../utils';
5
5
  import { highlightElement } from '../services/highlight';
6
6
 
7
7
  export default {
@@ -41,27 +41,9 @@ export default {
41
41
  async copyToClipboard() {
42
42
  try {
43
43
  const textToCopy = this.$refs.codeElement.textContent;
44
- const hasClipboardPermission = await checkClipboardPermissions();
45
44
 
46
- const event = new CustomEvent('copy-code-snippet', {
47
- bubbles: true,
48
- detail: {
49
- code: textToCopy,
50
- },
51
- });
52
- // Emit custom event for VS Code and other environments
53
- // This bubbles up to parent components that handle clipboard operations
54
- this.$emit('copy-code-snippet', {
55
- detail: {
56
- code: textToCopy,
57
- },
58
- });
59
- this.$el.dispatchEvent(event);
45
+ await copyToClipboard(textToCopy, this.$el);
60
46
 
61
- // If we have clipboard permission, also copy directly
62
- if (hasClipboardPermission) {
63
- await navigator.clipboard.writeText(textToCopy);
64
- }
65
47
  this.$emit('copied', this.title);
66
48
  setTimeout(() => {
67
49
  this.$refs.copyToClipboardButton.$el.blur();
@@ -0,0 +1,60 @@
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
+ };
10
+
11
+ /**
12
+ * Programmatically copies a text string to the clipboard. Attempts to copy in both secure and non-secure contexts as well as VSCode.
13
+ *
14
+ * Accepts a container element. This helps ensure the text can get copied to the clipboard correctly in non-secure
15
+ * environments, the container should be active (such as a button in a modal) to ensure the content can be copied.
16
+ *
17
+ * @param {String} text - Text to copy
18
+ * @param {HTMLElement} el - element that emits event for VSCode, and contains the fallback textarea.
19
+ */
20
+ export const copyToClipboard = (textToCopy, el) => {
21
+ // Fist, emit a custom event for VS Code extensions and other environments
22
+ // This bubbles up to parent components that handle clipboard operations
23
+ el.dispatchEvent(
24
+ new CustomEvent('copy-code-snippet', {
25
+ bubbles: true,
26
+ cancelable: true,
27
+ detail: {
28
+ code: textToCopy,
29
+ },
30
+ })
31
+ );
32
+
33
+ // Second, for modern browsers try a simple clipboard.writeText (works on https and localhost)
34
+ if (navigator.clipboard && window.isSecureContext) {
35
+ return navigator.clipboard.writeText(textToCopy);
36
+ }
37
+
38
+ // Third, try execCommand to copy from a dynamically created invisible textarea (for http and older browsers)
39
+ const textarea = document.createElement('textarea');
40
+ textarea.value = textToCopy;
41
+ textarea.style.position = 'absolute';
42
+ textarea.style.left = '-9999px'; // eslint-disable-line @gitlab/require-i18n-strings
43
+ textarea.style.top = '0';
44
+ textarea.setAttribute('readonly', ''); // prevent keyboard popup on mobile
45
+
46
+ // textarea must be in document to be selectable, but we add it to the button so it works in modals
47
+ el.appendChild(textarea);
48
+
49
+ textarea.select(); // for Safari
50
+ textarea.setSelectionRange(0, textarea.value.length); // for mobile devices
51
+
52
+ try {
53
+ const done = document.execCommand('copy');
54
+ el.removeChild(textarea);
55
+ return done ? Promise.resolve() : Promise.reject(new Error('Copy command failed'));
56
+ } catch (err) {
57
+ el.removeChild(textarea);
58
+ return Promise.reject(err);
59
+ }
60
+ };
@@ -1,21 +0,0 @@
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
- const checkClipboardPermissions = async () => {
8
- try {
9
- if (!navigator.clipboard || !navigator.permissions) {
10
- return false;
11
- }
12
- const permission = await navigator.permissions.query({
13
- name: 'clipboard-write'
14
- });
15
- return permission.state === 'granted';
16
- } catch (error) {
17
- return false;
18
- }
19
- };
20
-
21
- export { checkClipboardPermissions, concatUntilEmpty };
@@ -1,22 +0,0 @@
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
- };
10
-
11
- export const checkClipboardPermissions = async () => {
12
- try {
13
- if (!navigator.clipboard || !navigator.permissions) {
14
- return false;
15
- }
16
-
17
- const permission = await navigator.permissions.query({ name: 'clipboard-write' });
18
- return permission.state === 'granted';
19
- } catch (error) {
20
- return false;
21
- }
22
- };