From 8b56a6ad0334f6f3027c9aa701b71efb68451a4a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 9 May 2026 23:01:25 +0200 Subject: [PATCH] Add copy action to chat messages --- src/client/src/components/ChatView.ts | 49 ++++++++++++++++++++++++++- src/client/src/components/shared.ts | 8 ++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 344148c..589a72b 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -39,6 +39,7 @@ export class ChatView extends LitElement { @state() private openGroupKeys = new Set(); @state() private loadedScrollPercent = 100; @state() private expandedMetaKey: string | undefined; + @state() private copiedMessageKey: string | undefined; private suppressScrollSave = false; private saveScrollTimer?: number; private lastScrollTop = 0; @@ -188,7 +189,22 @@ export class ChatView extends LitElement { return html`
${message.role} - { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short} +
+ ${this.renderMessageActions(message, key)} + { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short} +
+
+ `; + } + + private renderMessageActions(message: ChatLine, key: string) { + if (!this.isCopyableMessage(message)) return null; + const copied = this.copiedMessageKey === key; + return html` +
+
`; } @@ -199,6 +215,37 @@ export class ChatView extends LitElement { this.expandedMetaKey = expanded ? undefined : key; } + private isCopyableMessage(message: ChatLine): boolean { + return (message.role === "user" || message.role === "assistant") && this.messageCopyText(message) !== ""; + } + + private messageCopyText(message: ChatLine): string { + return message.parts + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text.trim()) + .filter((text) => text !== "") + .join("\n\n"); + } + + private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise { + event.stopPropagation(); + const ok = await this.writeClipboard(this.messageCopyText(message)); + if (!ok) return; + this.copiedMessageKey = key; + window.setTimeout(() => { + if (this.copiedMessageKey === key) this.copiedMessageKey = undefined; + }, 1200); + } + + private async writeClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } + } + private messageMetaLabel(message: ChatLine): { short: string; full: string } { const timestamp = message.meta?.timestamp; const model = this.modelLabel(message); diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 6dcbe7c..713c165 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -151,12 +151,18 @@ export const chatStyles = css` .session-activity span, .session-activity small { color: #8b949e; } .history-boundary small { color: #6e7681; } .msg-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 8px; } + .msg-header-trailing { min-width: 0; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; } + .msg-actions { display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; } + .msg-action { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #8b949e; padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } + .msg-action:hover, .msg-action:focus { color: #e6edf3; border-color: #58a6ff; } + .msg:hover > .msg-header .msg-actions, .msg:focus-within > .msg-header .msg-actions, .group-msg:hover > .msg-header .msg-actions, .group-msg:focus-within > .msg-header .msg-actions { opacity: 1; } .label { display: block; color: #8b949e; font-size: 12px; text-transform: uppercase; } .msg-header .label { margin: 0; } .msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: #6e7681; padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease, max-width .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; } - .msg:hover > .msg-header .msg-meta, .msg:focus-within > .msg-header .msg-meta, .msg-meta:focus, .msg-meta.expanded { opacity: 1; } + .msg:hover > .msg-header .msg-meta, .msg:focus-within > .msg-header .msg-meta, .group-msg:hover > .msg-header .msg-meta, .group-msg:focus-within > .msg-header .msg-meta, .msg-meta:focus, .msg-meta.expanded { opacity: 1; } .msg-meta:focus { outline: 1px solid #30363d; outline-offset: 3px; border-radius: 4px; } @media (hover: none) { + .msg-actions { opacity: 1; } .msg-meta { opacity: .75; max-width: 26px; } .msg-meta::before { content: "ⓘ"; font-size: 13px; } .msg-meta:focus, .msg-meta.expanded { opacity: 1; max-width: 75%; }