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:
William Valentin
2026-02-10 20:53:49 -08:00
parent 4c8ba3f20c
commit 27ee3b2c10
3 changed files with 135 additions and 1 deletions
+9 -1
View File
@@ -965,7 +965,7 @@
"webchat-slash-commands": {
"status": "completed",
"date": "2026-02-10",
"summary": "Slash commands, autocomplete popup, and web search button for the webchat SPA. 6 commands: /help, /reset, /compact, /usage, /status, /model. Search button toggles web search mode (prepends instruction to message). Backend agent.send extended with metadata for command routing.",
"summary": "Slash commands, autocomplete popup, web search button, and message action buttons (copy/edit) for the webchat SPA. 6 commands: /help, /reset, /compact, /usage, /status, /model. Search button toggles web search mode. Copy button on all messages, edit button on user messages populates input.",
"phases": {
"backend_command_handling": {
"status": "completed",
@@ -987,6 +987,14 @@
"files_modified": [
"src/gateway/ui/style.css"
]
},
"message_action_buttons": {
"status": "completed",
"description": "Discrete copy and edit buttons on messages. Copy on all messages (clipboard API with checkmark feedback), edit on user messages (populates input textarea). Hidden until hover on desktop, always visible on mobile.",
"files_modified": [
"src/gateway/ui/pages/chat.js",
"src/gateway/ui/style.css"
]
}
}
},
+67
View File
@@ -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 ──────────────────────────────────────────────
+59
View File
@@ -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: '|';