Archived
Add copy action to chat messages
This commit is contained in:
@@ -39,6 +39,7 @@ export class ChatView extends LitElement {
|
|||||||
@state() private openGroupKeys = new Set<string>();
|
@state() private openGroupKeys = new Set<string>();
|
||||||
@state() private loadedScrollPercent = 100;
|
@state() private loadedScrollPercent = 100;
|
||||||
@state() private expandedMetaKey: string | undefined;
|
@state() private expandedMetaKey: string | undefined;
|
||||||
|
@state() private copiedMessageKey: string | undefined;
|
||||||
private suppressScrollSave = false;
|
private suppressScrollSave = false;
|
||||||
private saveScrollTimer?: number;
|
private saveScrollTimer?: number;
|
||||||
private lastScrollTop = 0;
|
private lastScrollTop = 0;
|
||||||
@@ -188,7 +189,22 @@ export class ChatView extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div class="msg-header">
|
<div class="msg-header">
|
||||||
<b class="label">${message.role}</b>
|
<b class="label">${message.role}</b>
|
||||||
<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 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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -199,6 +215,37 @@ export class ChatView extends LitElement {
|
|||||||
this.expandedMetaKey = expanded ? undefined : key;
|
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 } {
|
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
|
||||||
const timestamp = message.meta?.timestamp;
|
const timestamp = message.meta?.timestamp;
|
||||||
const model = this.modelLabel(message);
|
const model = this.modelLabel(message);
|
||||||
|
|||||||
@@ -151,12 +151,18 @@ export const chatStyles = css`
|
|||||||
.session-activity span, .session-activity small { color: #8b949e; }
|
.session-activity span, .session-activity small { color: #8b949e; }
|
||||||
.history-boundary small { color: #6e7681; }
|
.history-boundary small { color: #6e7681; }
|
||||||
.msg-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
|
.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; }
|
.label { display: block; color: #8b949e; font-size: 12px; text-transform: uppercase; }
|
||||||
.msg-header .label { margin: 0; }
|
.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-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; }
|
.msg-meta:focus { outline: 1px solid #30363d; outline-offset: 3px; border-radius: 4px; }
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
|
.msg-actions { opacity: 1; }
|
||||||
.msg-meta { opacity: .75; max-width: 26px; }
|
.msg-meta { opacity: .75; max-width: 26px; }
|
||||||
.msg-meta::before { content: "ⓘ"; font-size: 13px; }
|
.msg-meta::before { content: "ⓘ"; font-size: 13px; }
|
||||||
.msg-meta:focus, .msg-meta.expanded { opacity: 1; max-width: 75%; }
|
.msg-meta:focus, .msg-meta.expanded { opacity: 1; max-width: 75%; }
|
||||||
|
|||||||
Reference in New Issue
Block a user