Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-replies-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

Add a localized copy action to completed assistant replies.
3 changes: 3 additions & 0 deletions api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
58 changes: 51 additions & 7 deletions packages/widget/src/dom/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { renderMarkdown } from '../markdown.js';

const RETRY_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M17.65 6.35A7.96 7.96 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>';
const REGENERATE_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0020 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 004 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>';
const COPY_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>';

export type MessageStatus = 'streaming' | 'complete' | 'error';
export type MessageErrorType = 'rate_limit' | 'network' | 'provider_error' | 'timeout' | 'budget' | 'budget_daily' | 'unavailable' | 'cut_off';
Expand All @@ -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. */
Expand Down Expand Up @@ -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;
Expand All @@ -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() ?? '';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<HTMLElement>('.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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/widget/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const ar: Partial<WidgetLocaleStrings> = {
preChatInvalidEmail: 'يُرجى إدخال عنوان بريد إلكتروني صالح.',
retryButton: 'إعادة المحاولة',
regenerateButton: 'إنشاء رد جديد',
copyButton: 'نسخ الرد',
copySuccess: 'تم نسخ الرد.',
copyFailure: 'تعذر نسخ الرد.',
suggestionsLabel: 'أسئلة مقترحة',
unreadCount: 'عدد الرسائل غير المقروءة: {count}',
sourcesLabel: 'المصادر',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const de: Partial<WidgetLocaleStrings> = {
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.',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const es: Partial<WidgetLocaleStrings> = {
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.',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const fr: Partial<WidgetLocaleStrings> = {
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.',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/he.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const he: Partial<WidgetLocaleStrings> = {
preChatInvalidEmail: 'הזינו כתובת אימייל תקינה.',
retryButton: 'ניסיון חוזר',
regenerateButton: 'יצירה מחדש',
copyButton: 'העתקת התשובה',
copySuccess: 'התשובה הועתקה.',
copyFailure: 'לא ניתן להעתיק את התשובה.',
suggestionsLabel: 'שאלות מוצעות',
unreadCount: 'הודעות שלא נקראו: {count}',
sourcesLabel: 'מקורות',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const hi: Partial<WidgetLocaleStrings> = {
preChatInvalidEmail: 'कृपया मान्य ईमेल पता दर्ज करें।',
retryButton: 'फिर कोशिश करें',
regenerateButton: 'जवाब फिर से तैयार करें',
copyButton: 'जवाब कॉपी करें',
copySuccess: 'जवाब कॉपी हो गया।',
copyFailure: 'जवाब कॉपी नहीं हो सका।',
sourcesLabel: 'स्रोत',
disclosure: 'आप एक एआई सहायक से बातचीत कर रहे हैं। यह गलतियाँ कर सकता है।',
disclosureLearnMore: 'और जानें',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const it: Partial<WidgetLocaleStrings> = {
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',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const ja: Partial<WidgetLocaleStrings> = {
preChatInvalidEmail: '有効なメールアドレスを入力してください。',
retryButton: '再試行',
regenerateButton: '再生成',
copyButton: '回答をコピー',
copySuccess: '回答をコピーしました。',
copyFailure: '回答をコピーできませんでした。',
suggestionsLabel: 'おすすめの質問',
unreadCount: '未読メッセージ {count} 件',
sourcesLabel: '情報源',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const ko: Partial<WidgetLocaleStrings> = {
preChatInvalidEmail: '유효한 이메일 주소를 입력해 주세요.',
retryButton: '다시 시도',
regenerateButton: '다시 생성',
copyButton: '답변 복사',
copySuccess: '답변을 복사했습니다.',
copyFailure: '답변을 복사하지 못했습니다.',
suggestionsLabel: '추천 질문',
unreadCount: '읽지 않은 메시지 {count}개',
sourcesLabel: '출처',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const nl: Partial<WidgetLocaleStrings> = {
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',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const pt: Partial<WidgetLocaleStrings> = {
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',
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/ur.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const ur: Partial<WidgetLocaleStrings> = {
preChatInvalidEmail: 'براہِ کرم درست ای میل درج کریں',
retryButton: 'دوبارہ کوشش کریں',
regenerateButton: 'جواب دوبارہ بنائیں',
copyButton: 'جواب کاپی کریں',
copySuccess: 'جواب کاپی ہو گیا۔',
copyFailure: 'جواب کاپی نہیں ہو سکا۔',
unreadCount: '{count} غیر پڑھے',
};

Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const zh: Partial<WidgetLocaleStrings> = {
preChatInvalidEmail: '请输入有效的电子邮件地址。',
retryButton: '重试',
regenerateButton: '重新生成',
copyButton: '复制回复',
copySuccess: '已复制回复。',
copyFailure: '无法复制回复。',
suggestionsLabel: '推荐问题',
unreadCount: '{count}条未读消息',
sourcesLabel: '来源',
Expand Down
10 changes: 7 additions & 3 deletions packages/widget/src/styles/widget.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
Loading