Add copy action to chat messages

This commit is contained in:
Federico Jaramillo Martinez
2026-05-09 23:01:25 +02:00
parent 9665031ba4
commit 8b56a6ad03
2 changed files with 55 additions and 2 deletions
+47
View File
@@ -39,6 +39,7 @@ export class ChatView extends LitElement {
@state() private openGroupKeys = new Set<string>();
@state() private loadedScrollPercent = 100;
@state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | undefined;
private suppressScrollSave = false;
private saveScrollTimer?: number;
private lastScrollTop = 0;
@@ -188,8 +189,23 @@ export class ChatView extends LitElement {
return html`
<div class="msg-header">
<b class="label">${message.role}</b>
<div class="msg-header-trailing">
${this.renderMessageActions(message, key)}
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta.full} aria-label=${meta.full} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short}</span>
</div>
</div>
`;
}
private renderMessageActions(message: ChatLine, key: string) {
if (!this.isCopyableMessage(message)) return null;
const copied = this.copiedMessageKey === key;
return html`
<div class="msg-actions" aria-label="Message actions">
<button type="button" class="msg-action" title=${copied ? "Copied" : "Copy message"} aria-label=${`${copied ? "Copied" : "Copy"} ${message.role} message`} @click=${(event: MouseEvent) => { void this.copyMessage(message, key, event); }}>
<span aria-hidden="true">${copied ? "✓" : "⧉"}</span>
</button>
</div>
`;
}
@@ -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<ChatPart, { type: "text" }> => part.type === "text")
.map((part) => part.text.trim())
.filter((text) => text !== "")
.join("\n\n");
}
private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> {
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<boolean> {
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);
+7 -1
View File
@@ -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%; }