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 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);
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
Reference in New Issue
Block a user