diff --git a/.changeset/bright-replies-copy.md b/.changeset/bright-replies-copy.md
new file mode 100644
index 00000000..3463dc20
--- /dev/null
+++ b/.changeset/bright-replies-copy.md
@@ -0,0 +1,5 @@
+---
+"@chatcops/widget": minor
+---
+
+Add a localized copy action to completed assistant replies.
diff --git a/api/widget.api.md b/api/widget.api.md
index 5cd97472..46da2679 100644
--- a/api/widget.api.md
+++ b/api/widget.api.md
@@ -214,6 +214,9 @@ export interface WidgetLocaleStrings {
retryButton: string;
messageWaiting: string;
regenerateButton: string;
+ copyButton: string;
+ copySuccess: string;
+ copyFailure: string;
suggestionsLabel: string;
unreadCount: string;
sourcesLabel: string;
diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts
index 2f933bdd..0ca54366 100644
--- a/packages/widget/src/dom/messages.ts
+++ b/packages/widget/src/dom/messages.ts
@@ -2,6 +2,7 @@ import { renderMarkdown } from '../markdown.js';
const RETRY_ICON = '';
const REGENERATE_ICON = '';
+const COPY_ICON = '';
export type MessageStatus = 'streaming' | 'complete' | 'error';
export type MessageErrorType = 'rate_limit' | 'network' | 'provider_error' | 'timeout' | 'budget' | 'budget_daily' | 'unavailable' | 'cut_off';
@@ -24,6 +25,9 @@ export interface MessageActions {
regenerateLabel?: string;
typingLabel?: string;
sourcesLabel?: string;
+ copyLabel?: string;
+ copySuccessLabel?: string;
+ copyFailureLabel?: string;
}
/** How close to the bottom (px) the log has to be for new text to scroll it. */
@@ -59,6 +63,7 @@ export class Messages {
private suggestionsEl: HTMLDivElement | null = null;
private disclosureEl: HTMLElement | null = null;
private actions?: MessageActions;
+ private announcementVersion = 0;
constructor(parent: HTMLElement, actions?: MessageActions, options: { streamIntervalMs?: number } = {}) {
this.actions = actions;
@@ -77,12 +82,14 @@ export class Messages {
}
beginReply(): void {
+ this.announcementVersion++;
this.announcement.textContent = '';
this.container.setAttribute('aria-live', 'off');
}
finishReply(msg?: MessageData): void {
if (msg?.role === 'assistant' && (msg.status === 'complete' || msg.status === 'error')) {
+ this.announcementVersion++;
const rendered = this.findMessage(msg.id);
const content = rendered?.querySelector('.cc-message-content')?.textContent?.trim() ?? '';
const error = rendered?.querySelector('.cc-message-error-note')?.textContent?.trim() ?? '';
@@ -226,6 +233,7 @@ export class Messages {
}
clear(): void {
+ this.announcementVersion++;
for (const id of [...this.streams.keys()]) this.endStream(id);
this.container.innerHTML = '';
this.typingEl = null;
@@ -378,21 +386,57 @@ export class Messages {
}
private createActionsContainer(msg: MessageData): HTMLDivElement | null {
- if (msg.status !== 'error' || !this.actions?.onRetry) {
+ const canCopy = msg.role === 'assistant' && msg.id !== 'welcome' && msg.status !== 'streaming' && msg.status !== 'error' && msg.content.trim() !== '';
+ const canRetry = msg.status === 'error' && this.actions?.onRetry;
+ if (!canCopy && !canRetry) {
return null;
}
const actions = document.createElement('div');
actions.className = 'cc-message-actions';
- actions.appendChild(this.createActionButton({
- className: 'cc-message-retry',
- label: this.actions.retryLabel ?? 'Retry',
- icon: RETRY_ICON,
- onClick: () => this.actions?.onRetry?.(msg.id),
- }));
+ if (canCopy) {
+ actions.appendChild(this.createActionButton({
+ className: 'cc-message-copy',
+ label: this.actions?.copyLabel ?? 'Copy reply',
+ icon: COPY_ICON,
+ onClick: () => this.copyReply(msg.id),
+ }));
+ }
+ if (canRetry) {
+ actions.appendChild(this.createActionButton({
+ className: 'cc-message-retry',
+ label: this.actions?.retryLabel ?? 'Retry',
+ icon: RETRY_ICON,
+ onClick: () => this.actions?.onRetry?.(msg.id),
+ }));
+ }
return actions;
}
+ /** Start the clipboard write in the click handler so it keeps user activation. */
+ private copyReply(messageId: string): void {
+ const content = this.findMessage(messageId)?.querySelector('.cc-message-content');
+ const text = content?.innerText.trim() ?? '';
+ const version = this.announcementVersion;
+ const announce = (message: string) => {
+ if (version === this.announcementVersion) this.announcement.textContent = message;
+ };
+
+ try {
+ const clipboard = navigator.clipboard;
+ if (!clipboard?.writeText || !text) {
+ announce(this.actions?.copyFailureLabel ?? 'Could not copy reply.');
+ return;
+ }
+ Promise.resolve(clipboard.writeText(text)).then(
+ () => announce(this.actions?.copySuccessLabel ?? 'Reply copied.'),
+ () => announce(this.actions?.copyFailureLabel ?? 'Could not copy reply.'),
+ );
+ } catch {
+ announce(this.actions?.copyFailureLabel ?? 'Could not copy reply.');
+ }
+ }
+
private ensureActionsContainer(msgEl: HTMLDivElement): HTMLDivElement {
const existing = msgEl.querySelector('.cc-message-actions') as HTMLDivElement | null;
if (existing) {
diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts
index fc66bac8..633eb915 100644
--- a/packages/widget/src/i18n.ts
+++ b/packages/widget/src/i18n.ts
@@ -31,6 +31,9 @@ export interface WidgetLocaleStrings {
retryButton: string;
messageWaiting: string;
regenerateButton: string;
+ copyButton: string;
+ copySuccess: string;
+ copyFailure: string;
suggestionsLabel: string;
unreadCount: string;
sourcesLabel: string;
@@ -75,6 +78,9 @@ const en: WidgetLocaleStrings = {
messageWaiting: "Will send when you're back online",
retryButton: 'Retry',
regenerateButton: 'Regenerate',
+ copyButton: 'Copy reply',
+ copySuccess: 'Reply copied.',
+ copyFailure: 'Could not copy reply.',
suggestionsLabel: 'Suggested questions',
unreadCount: '{count} unread',
sourcesLabel: 'Sources',
diff --git a/packages/widget/src/locales/ar.ts b/packages/widget/src/locales/ar.ts
index 7595e7dd..0f3dccee 100644
--- a/packages/widget/src/locales/ar.ts
+++ b/packages/widget/src/locales/ar.ts
@@ -30,6 +30,9 @@ const ar: Partial = {
preChatInvalidEmail: 'يُرجى إدخال عنوان بريد إلكتروني صالح.',
retryButton: 'إعادة المحاولة',
regenerateButton: 'إنشاء رد جديد',
+ copyButton: 'نسخ الرد',
+ copySuccess: 'تم نسخ الرد.',
+ copyFailure: 'تعذر نسخ الرد.',
suggestionsLabel: 'أسئلة مقترحة',
unreadCount: 'عدد الرسائل غير المقروءة: {count}',
sourcesLabel: 'المصادر',
diff --git a/packages/widget/src/locales/de.ts b/packages/widget/src/locales/de.ts
index fde60028..53f4a727 100644
--- a/packages/widget/src/locales/de.ts
+++ b/packages/widget/src/locales/de.ts
@@ -30,6 +30,9 @@ const de: Partial = {
preChatInvalidEmail: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
retryButton: 'Erneut versuchen',
regenerateButton: 'Erneut generieren',
+ copyButton: 'Antwort kopieren',
+ copySuccess: 'Antwort kopiert.',
+ copyFailure: 'Antwort konnte nicht kopiert werden.',
unreadCount: 'Ungelesene Nachrichten: {count}',
sourcesLabel: 'Quellen',
disclosure: 'Sie chatten mit einem KI-Assistenten. Er kann Fehler machen.',
diff --git a/packages/widget/src/locales/es.ts b/packages/widget/src/locales/es.ts
index e3d344d9..a0e9b235 100644
--- a/packages/widget/src/locales/es.ts
+++ b/packages/widget/src/locales/es.ts
@@ -30,6 +30,9 @@ const es: Partial = {
preChatInvalidEmail: 'Introduce un correo electrónico válido.',
retryButton: 'Reintentar',
regenerateButton: 'Volver a generar',
+ copyButton: 'Copiar respuesta',
+ copySuccess: 'Respuesta copiada.',
+ copyFailure: 'No se pudo copiar la respuesta.',
unreadCount: '{count} sin leer',
sourcesLabel: 'Fuentes',
disclosure: 'Estás chateando con un asistente de IA. Puede cometer errores.',
diff --git a/packages/widget/src/locales/fr.ts b/packages/widget/src/locales/fr.ts
index 858c1ce1..a0bfc249 100644
--- a/packages/widget/src/locales/fr.ts
+++ b/packages/widget/src/locales/fr.ts
@@ -30,6 +30,9 @@ const fr: Partial = {
preChatInvalidEmail: 'Veuillez saisir une adresse e-mail valide.',
retryButton: 'Réessayer',
regenerateButton: 'Générer à nouveau',
+ copyButton: 'Copier la réponse',
+ copySuccess: 'Réponse copiée.',
+ copyFailure: 'Impossible de copier la réponse.',
unreadCount: 'Messages non lus : {count}',
sourcesLabel: 'Sources',
disclosure: 'Vous échangez avec un assistant IA. Il peut faire des erreurs.',
diff --git a/packages/widget/src/locales/he.ts b/packages/widget/src/locales/he.ts
index 888b0ce6..47c07092 100644
--- a/packages/widget/src/locales/he.ts
+++ b/packages/widget/src/locales/he.ts
@@ -30,6 +30,9 @@ const he: Partial = {
preChatInvalidEmail: 'הזינו כתובת אימייל תקינה.',
retryButton: 'ניסיון חוזר',
regenerateButton: 'יצירה מחדש',
+ copyButton: 'העתקת התשובה',
+ copySuccess: 'התשובה הועתקה.',
+ copyFailure: 'לא ניתן להעתיק את התשובה.',
suggestionsLabel: 'שאלות מוצעות',
unreadCount: 'הודעות שלא נקראו: {count}',
sourcesLabel: 'מקורות',
diff --git a/packages/widget/src/locales/hi.ts b/packages/widget/src/locales/hi.ts
index 0937045c..2a1d2ed5 100644
--- a/packages/widget/src/locales/hi.ts
+++ b/packages/widget/src/locales/hi.ts
@@ -30,6 +30,9 @@ const hi: Partial = {
preChatInvalidEmail: 'कृपया मान्य ईमेल पता दर्ज करें।',
retryButton: 'फिर कोशिश करें',
regenerateButton: 'जवाब फिर से तैयार करें',
+ copyButton: 'जवाब कॉपी करें',
+ copySuccess: 'जवाब कॉपी हो गया।',
+ copyFailure: 'जवाब कॉपी नहीं हो सका।',
sourcesLabel: 'स्रोत',
disclosure: 'आप एक एआई सहायक से बातचीत कर रहे हैं। यह गलतियाँ कर सकता है।',
disclosureLearnMore: 'और जानें',
diff --git a/packages/widget/src/locales/it.ts b/packages/widget/src/locales/it.ts
index 21e7e247..3ddaca11 100644
--- a/packages/widget/src/locales/it.ts
+++ b/packages/widget/src/locales/it.ts
@@ -30,6 +30,9 @@ const it: Partial = {
preChatInvalidEmail: 'Inserisci un indirizzo email valido.',
retryButton: 'Riprova',
regenerateButton: 'Rigenera',
+ copyButton: 'Copia risposta',
+ copySuccess: 'Risposta copiata.',
+ copyFailure: 'Impossibile copiare la risposta.',
suggestionsLabel: 'Domande suggerite',
unreadCount: 'Messaggi non letti: {count}',
sourcesLabel: 'Fonti',
diff --git a/packages/widget/src/locales/ja.ts b/packages/widget/src/locales/ja.ts
index 2616c13f..411db8e7 100644
--- a/packages/widget/src/locales/ja.ts
+++ b/packages/widget/src/locales/ja.ts
@@ -30,6 +30,9 @@ const ja: Partial = {
preChatInvalidEmail: '有効なメールアドレスを入力してください。',
retryButton: '再試行',
regenerateButton: '再生成',
+ copyButton: '回答をコピー',
+ copySuccess: '回答をコピーしました。',
+ copyFailure: '回答をコピーできませんでした。',
suggestionsLabel: 'おすすめの質問',
unreadCount: '未読メッセージ {count} 件',
sourcesLabel: '情報源',
diff --git a/packages/widget/src/locales/ko.ts b/packages/widget/src/locales/ko.ts
index 7ec7e2f3..de70cbbc 100644
--- a/packages/widget/src/locales/ko.ts
+++ b/packages/widget/src/locales/ko.ts
@@ -30,6 +30,9 @@ const ko: Partial = {
preChatInvalidEmail: '유효한 이메일 주소를 입력해 주세요.',
retryButton: '다시 시도',
regenerateButton: '다시 생성',
+ copyButton: '답변 복사',
+ copySuccess: '답변을 복사했습니다.',
+ copyFailure: '답변을 복사하지 못했습니다.',
suggestionsLabel: '추천 질문',
unreadCount: '읽지 않은 메시지 {count}개',
sourcesLabel: '출처',
diff --git a/packages/widget/src/locales/nl.ts b/packages/widget/src/locales/nl.ts
index 385041a7..69c91d9d 100644
--- a/packages/widget/src/locales/nl.ts
+++ b/packages/widget/src/locales/nl.ts
@@ -30,6 +30,9 @@ const nl: Partial = {
preChatInvalidEmail: 'Vul een geldig e-mailadres in.',
retryButton: 'Opnieuw proberen',
regenerateButton: 'Opnieuw genereren',
+ copyButton: 'Antwoord kopiëren',
+ copySuccess: 'Antwoord gekopieerd.',
+ copyFailure: 'Antwoord kopiëren is mislukt.',
suggestionsLabel: 'Voorgestelde vragen',
unreadCount: 'Ongelezen berichten: {count}',
sourcesLabel: 'Bronnen',
diff --git a/packages/widget/src/locales/pt.ts b/packages/widget/src/locales/pt.ts
index 154eb9d5..5c06f68d 100644
--- a/packages/widget/src/locales/pt.ts
+++ b/packages/widget/src/locales/pt.ts
@@ -30,6 +30,9 @@ const pt: Partial = {
preChatInvalidEmail: 'Insira um e-mail válido.',
retryButton: 'Tentar novamente',
regenerateButton: 'Gerar novamente',
+ copyButton: 'Copiar resposta',
+ copySuccess: 'Resposta copiada.',
+ copyFailure: 'Não foi possível copiar a resposta.',
suggestionsLabel: 'Perguntas sugeridas',
unreadCount: 'Não lidas: {count}',
sourcesLabel: 'Fontes',
diff --git a/packages/widget/src/locales/ur.ts b/packages/widget/src/locales/ur.ts
index 7db3a019..c0266078 100644
--- a/packages/widget/src/locales/ur.ts
+++ b/packages/widget/src/locales/ur.ts
@@ -30,6 +30,9 @@ const ur: Partial = {
preChatInvalidEmail: 'براہِ کرم درست ای میل درج کریں',
retryButton: 'دوبارہ کوشش کریں',
regenerateButton: 'جواب دوبارہ بنائیں',
+ copyButton: 'جواب کاپی کریں',
+ copySuccess: 'جواب کاپی ہو گیا۔',
+ copyFailure: 'جواب کاپی نہیں ہو سکا۔',
unreadCount: '{count} غیر پڑھے',
};
diff --git a/packages/widget/src/locales/zh.ts b/packages/widget/src/locales/zh.ts
index 64e38c3d..2a23a21e 100644
--- a/packages/widget/src/locales/zh.ts
+++ b/packages/widget/src/locales/zh.ts
@@ -30,6 +30,9 @@ const zh: Partial = {
preChatInvalidEmail: '请输入有效的电子邮件地址。',
retryButton: '重试',
regenerateButton: '重新生成',
+ copyButton: '复制回复',
+ copySuccess: '已复制回复。',
+ copyFailure: '无法复制回复。',
suggestionsLabel: '推荐问题',
unreadCount: '{count}条未读消息',
sourcesLabel: '来源',
diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css
index f9e70fa7..c71291f4 100644
--- a/packages/widget/src/styles/widget.css
+++ b/packages/widget/src/styles/widget.css
@@ -302,7 +302,8 @@
.cc-consent-btn,
.cc-message-retry,
-.cc-message-regenerate {
+.cc-message-regenerate,
+.cc-message-copy {
display: inline-flex;
align-items: center;
gap: 4px;
@@ -319,13 +320,15 @@
.cc-consent-btn:hover,
.cc-message-retry:hover,
-.cc-message-regenerate:hover {
+.cc-message-regenerate:hover,
+.cc-message-copy:hover {
background: var(--cc-bg-input);
color: var(--cc-text);
}
.cc-message-retry svg,
-.cc-message-regenerate svg {
+.cc-message-regenerate svg,
+.cc-message-copy svg {
fill: currentColor;
}
@@ -607,6 +610,7 @@
.cc-suggestion:focus-visible,
.cc-message-retry:focus-visible,
.cc-message-regenerate:focus-visible,
+.cc-message-copy:focus-visible,
.cc-bubble-trigger:focus-visible,
.cc-bubble-close:focus-visible,
.cc-input:focus-visible {
diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts
index ee91327b..650dd8bd 100644
--- a/packages/widget/src/widget.ts
+++ b/packages/widget/src/widget.ts
@@ -615,6 +615,9 @@ export class Widget {
regenerateLabel: l.regenerateButton,
typingLabel: l.typingIndicator,
sourcesLabel: l.sourcesLabel,
+ copyLabel: l.copyButton,
+ copySuccessLabel: l.copySuccess,
+ copyFailureLabel: l.copyFailure,
};
}
diff --git a/packages/widget/tests/dom/messages.test.ts b/packages/widget/tests/dom/messages.test.ts
index 4409b2e8..d51e1971 100644
--- a/packages/widget/tests/dom/messages.test.ts
+++ b/packages/widget/tests/dom/messages.test.ts
@@ -1,17 +1,24 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Messages } from '../../src/dom/messages.js';
describe('Messages', () => {
let parent: HTMLDivElement;
let messages: Messages;
+ let clipboardDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
+ clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
document.body.innerHTML = '';
parent = document.createElement('div');
document.body.appendChild(parent);
messages = new Messages(parent);
});
+ afterEach(() => {
+ if (clipboardDescriptor) Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
+ else Reflect.deleteProperty(navigator, 'clipboard');
+ });
+
it('creates a messages container', () => {
expect(parent.querySelector('.cc-messages')).toBeTruthy();
});
@@ -179,6 +186,60 @@ describe('Messages', () => {
expect(parent.querySelector('.cc-message-retry')).toBeNull();
});
+ it('copies completed assistant replies as readable text and announces success', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
+ parent.innerHTML = '';
+ messages = new Messages(parent, {
+ copyLabel: 'Copiar respuesta',
+ copySuccessLabel: 'Respuesta copiada.',
+ copyFailureLabel: 'No se pudo copiar.',
+ });
+ messages.addMessage({
+ id: 'copy-1',
+ role: 'assistant',
+ content: 'First line\n\n```js\nconst answer = 42;\nreturn answer;\n```',
+ status: 'complete',
+ });
+ const content = parent.querySelector('[data-id="copy-1"] .cc-message-content');
+ if (!content) throw new Error('no reply content');
+ Object.defineProperty(content, 'innerText', {
+ configurable: true,
+ value: 'First line\n\nconst answer = 42;\nreturn answer;',
+ });
+
+ const button = parent.querySelector('.cc-message-copy');
+ expect(button?.textContent).toContain('Copiar respuesta');
+ expect(button?.type).toBe('button');
+ button?.click();
+ expect(writeText).toHaveBeenCalledWith('First line\n\nconst answer = 42;\nreturn answer;');
+ await vi.waitFor(() => expect(parent.querySelector('.cc-reply-announcement')?.textContent).toBe('Respuesta copiada.'));
+ expect(parent.querySelectorAll('.cc-reply-announcement')).toHaveLength(1);
+ });
+
+ it('hides the copy action while streaming and adds it when complete', () => {
+ messages.addMessage({ id: 'copy-stream', role: 'assistant', content: 'Partial', status: 'streaming' });
+ expect(parent.querySelector('.cc-message-copy')).toBeNull();
+
+ messages.updateMessage({ id: 'copy-stream', role: 'assistant', content: 'Finished', status: 'complete' });
+ expect(parent.querySelector('.cc-message-copy')).toBeTruthy();
+ });
+
+ it('announces failure when the clipboard is unavailable or rejects the write', async () => {
+ parent.innerHTML = '';
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
+ messages = new Messages(parent, { copyFailureLabel: 'Copy failed.' });
+ messages.addMessage({ id: 'copy-no-clipboard', role: 'assistant', content: 'Answer', status: 'complete' });
+ parent.querySelector('.cc-message-copy')?.click();
+ expect(parent.querySelector('.cc-reply-announcement')?.textContent).toBe('Copy failed.');
+
+ const writeText = vi.fn().mockRejectedValue(new Error('denied'));
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
+ parent.querySelector('.cc-message-copy')?.click();
+ await vi.waitFor(() => expect(parent.querySelector('.cc-reply-announcement')?.textContent).toBe('Copy failed.'));
+ expect(writeText).toHaveBeenCalledWith('Answer');
+ });
+
it('removes a message by id', () => {
messages.addMessage({ id: 'remove-1', role: 'assistant', content: 'Remove me' });