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' });