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: '|';