From dac420840d01784fb7ca42e655b9a62763933bb9 Mon Sep 17 00:00:00 2001 From: Nige Date: Sun, 15 Mar 2026 05:52:47 +0000 Subject: [PATCH] fix(web-chat): normalize chat copy to plain text (#1114) * fix(web-chat): force plain-text clipboard copy from chat messages * test(e2e): make chat copy test target deterministic message --- src/channels/web/static/app.js | 16 +++++++++++++ tests/e2e/scenarios/test_chat.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index ceab682a..0624d07a 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -600,6 +600,22 @@ document.getElementById('chat-input').addEventListener('paste', (e) => { } }); +const chatMessagesEl = document.getElementById('chat-messages'); +chatMessagesEl.addEventListener('copy', (e) => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) return; + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + if (!anchorNode || !focusNode) return; + if (!chatMessagesEl.contains(anchorNode) || !chatMessagesEl.contains(focusNode)) return; + const text = selection.toString(); + if (!text || !e.clipboardData) return; + // Force plain-text clipboard output so dark-theme styling never leaks on paste. + e.preventDefault(); + e.clipboardData.clearData(); + e.clipboardData.setData('text/plain', text); +}); + function addGeneratedImage(dataUrl, path) { const container = document.getElementById('chat-messages'); const card = document.createElement('div'); diff --git a/tests/e2e/scenarios/test_chat.py b/tests/e2e/scenarios/test_chat.py index 24b3d98d..440eb18e 100644 --- a/tests/e2e/scenarios/test_chat.py +++ b/tests/e2e/scenarios/test_chat.py @@ -74,3 +74,44 @@ async def test_empty_message_not_sent(page): await page.wait_for_timeout(2000) final_count = await page.locator(f"{SEL['message_user']}, {SEL['message_assistant']}").count() assert final_count == initial_count, "Empty message should not create new messages" + + +async def test_copy_from_chat_forces_plain_text(page): + """Copying selected chat text should populate plain text clipboard data only.""" + await page.evaluate("addMessage('assistant', 'Copy me into Sheets')") + + copied = await page.evaluate( + """ + () => { + const content = Array.from(document.querySelectorAll('#chat-messages .message.assistant .message-content')) + .find((el) => (el.textContent || '').includes('Copy me into Sheets')); + if (!content) return {ok: false, reason: 'no content'}; + const range = document.createRange(); + range.selectNodeContents(content); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + + const store = {}; + const evt = new Event('copy', { bubbles: true, cancelable: true }); + evt.clipboardData = { + clearData: () => { Object.keys(store).forEach((k) => delete store[k]); }, + setData: (t, v) => { store[t] = v; }, + getData: (t) => store[t] || '', + }; + + content.dispatchEvent(evt); + return { + ok: true, + defaultPrevented: evt.defaultPrevented, + text: store['text/plain'] || '', + html: store['text/html'] || '', + }; + } + """ + ) + + assert copied["ok"], copied.get("reason", "copy setup failed") + assert copied["defaultPrevented"] is True + assert "Copy me into Sheets" in copied["text"] + assert copied["html"] == ""