From 27ee3b2c1044be99f6419fc3b4ea7bada208268d Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 20:53:49 -0800 Subject: [PATCH] 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). --- docs/plans/state.json | 10 +++++- src/gateway/ui/pages/chat.js | 67 ++++++++++++++++++++++++++++++++++++ src/gateway/ui/style.css | 59 +++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 3ecb877..304b15e 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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" + ] } } }, diff --git a/src/gateway/ui/pages/chat.js b/src/gateway/ui/pages/chat.js index db0abff..0417b67 100644 --- a/src/gateway/ui/pages/chat.js +++ b/src/gateway/ui/pages/chat.js @@ -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 = ``; +const COPY_ICON = ``; +const CHECK_ICON = ``; +const EDIT_ICON = ``; // ── Page Export ────────────────────────────────────────────── diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css index f0cc747..1e93faf 100644 --- a/src/gateway/ui/style.css +++ b/src/gateway/ui/style.css @@ -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: '|';