feat(webchat): add copy and edit buttons on chat messages
Copy button on all messages (clipboard API with checkmark feedback). Edit button on user messages populates the input textarea. Buttons appear on hover (desktop) or always visible (mobile).
This commit is contained in:
@@ -61,6 +61,12 @@ function createMessageEl(role, content) {
|
||||
} else {
|
||||
div.textContent = content;
|
||||
}
|
||||
|
||||
// Add action buttons (copy for all, edit for user)
|
||||
if (role !== 'system') {
|
||||
div.appendChild(createMessageActions(role));
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
@@ -104,6 +110,63 @@ function scrollToBottom() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message Action Buttons ──────────────────────────────────
|
||||
|
||||
function getMessageText(el) {
|
||||
// For user messages, textContent is the raw text.
|
||||
// For assistant/system messages rendered as markdown, extract plain text.
|
||||
return (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
function createMessageActions(role) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'message-actions';
|
||||
|
||||
// Copy button — all messages
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'msg-action-btn';
|
||||
copyBtn.title = 'Copy';
|
||||
copyBtn.innerHTML = COPY_ICON;
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const msg = bar.closest('.message');
|
||||
if (!msg) return;
|
||||
const text = getMessageText(msg);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
copyBtn.innerHTML = CHECK_ICON;
|
||||
copyBtn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = COPY_ICON;
|
||||
copyBtn.classList.remove('copied');
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
bar.appendChild(copyBtn);
|
||||
|
||||
// Edit button — user messages only
|
||||
if (role === 'user') {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'msg-action-btn';
|
||||
editBtn.title = 'Edit';
|
||||
editBtn.innerHTML = EDIT_ICON;
|
||||
editBtn.addEventListener('click', () => {
|
||||
const msg = bar.closest('.message');
|
||||
if (!msg) return;
|
||||
const text = getMessageText(msg);
|
||||
const input = _elements.input;
|
||||
if (input) {
|
||||
input.value = text;
|
||||
input.focus();
|
||||
// Trigger auto-resize
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
|
||||
}
|
||||
});
|
||||
bar.appendChild(editBtn);
|
||||
}
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── Search Mode ─────────────────────────────────────────────
|
||||
|
||||
function setSearchMode(active) {
|
||||
@@ -466,6 +529,7 @@ async function sendMessage(client, overrideText) {
|
||||
placeholder.classList.remove('streaming-cursor');
|
||||
const content = done?.content ?? done?.text ?? '(no response)';
|
||||
placeholder.innerHTML = renderMarkdown(content);
|
||||
placeholder.appendChild(createMessageActions('assistant'));
|
||||
setTimeout(highlightCode, 0);
|
||||
} catch (err) {
|
||||
placeholder.classList.remove('streaming-cursor');
|
||||
@@ -481,6 +545,9 @@ async function sendMessage(client, overrideText) {
|
||||
// ── Search SVG Icon ─────────────────────────────────────────
|
||||
|
||||
const SEARCH_ICON = `<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.82l4.15 4.15a.75.75 0 0 1-1.06 1.06l-4.15-4.15A5.5 5.5 0 1 1 8.5 3zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="currentColor"/></svg>`;
|
||||
const COPY_ICON = `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" fill="currentColor"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" fill="currentColor"/></svg>`;
|
||||
const CHECK_ICON = `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>`;
|
||||
const EDIT_ICON = `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm1.414 1.06a.25.25 0 0 0-.354 0L3.463 11.1l-.47 1.64 1.64-.47 8.61-8.61a.25.25 0 0 0 0-.354Z" fill="currentColor"/></svg>`;
|
||||
|
||||
// ── Page Export ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -553,6 +553,15 @@ header #status.status-ok {
|
||||
min-width: 70px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.msg-action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -1039,6 +1048,56 @@ tr:hover td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Message action buttons (copy, edit) */
|
||||
.message {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.message:hover .message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.msg-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.msg-action-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.msg-action-btn.copied {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.msg-action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Streaming text cursor */
|
||||
.streaming-cursor::after {
|
||||
content: '|';
|
||||
|
||||
Reference in New Issue
Block a user