fix(chat): copy button never copied anything — inline onclick died on apostrophes - #307
Merged
Conversation
… apostrophes
Reported repeatedly, on both phone and desktop, for user and assistant messages.
Root cause, and why the earlier fix missed it:
encodeURIComponent() does NOT escape an apostrophe. "'" is an unreserved mark
(A-Z a-z 0-9 - _ . ! ~ * ' ( )) so it passes through untouched:
encodeURIComponent("I don't know") -> "I%20don't%20know"
That string was then embedded in a single-quoted JS string inside an HTML
attribute, which the apostrophe terminates:
onclick="copyMsgText(decodeURIComponent('I don't know'),this)"
-> SyntaxError: missing ) after argument list
The handler never parsed, so the click silently did nothing. Most sentences
contain an apostrophe, hence "it never works". It looked device-independent
because the PWA's Chat nav link serves this same codec_chat.html, so phone and
desktop hit one identical defect. codec_dashboard.html had already been fixed
with bindCopyButtons(); codec_chat.html was never converted, and its copy, EDIT
and SPEAK buttons all shared the broken pattern.
Fix: emit bare buttons carrying only data-act, then bind them with
addEventListener over a closure on the raw text. No escaping anywhere, and
message content can never become executable (the old form was also an injection
surface).
Second, independent defect on mobile: the execCommand fallback — reached
whenever navigator.clipboard is unavailable, which is ANY non-secure origin, so
plain-http LAN access lands there — set pointer-events:none, which silently
blocks the iOS selection, and used .select(), which iOS ignores. Hardened in
both files: contentEditable + Range selection, no pointer-events:none, 16px
font-size to stop viewport zoom.
Verified with a jsdom harness driving the real markup: 6/6 cases deliver the
exact text on click (apostrophes, mixed quotes, accented French, multiline, an
XSS payload, plain text); the old implementation raises SyntaxError on the same
input. tests/test_copy_button_binding.py locks the contract in and was confirmed
to fail against the original markup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported many times, on phone and desktop, for both user and assistant messages. Here's why it kept surviving "fixes".
Root cause
encodeURIComponent()does not escape an apostrophe —'is an unreserved mark (A-Z a-z 0-9 - _ . ! ~ * ' ( )), so it passes through untouched:That was embedded in a single-quoted JS string inside an HTML attribute, which the apostrophe terminates:
onclick="copyMsgText(decodeURIComponent('I don't know'),this)" → SyntaxError: missing ) after argument listThe handler never parsed, so the click silently did nothing. Most sentences contain an apostrophe — hence "never works."
Why it looked device-independent
The PWA's Chat nav link serves
/chat→ the samecodec_chat.html. Phone and desktop were hitting one identical defect.codec_dashboard.htmlhad already been converted tobindCopyButtons();codec_chat.htmlnever was — and its copy, edit and speak buttons all shared the broken pattern.Fix
Emit bare buttons carrying only
data-act, then bind withaddEventListenerover a closure on the raw text. No escaping anywhere, and message content can no longer become executable — the old form was also an injection surface.Second, independent defect (mobile)
The
execCommandfallback — reached whenevernavigator.clipboardis unavailable, which is any non-secure origin, so plain-http LAN access lands there — setpointer-events:none(silently blocks the iOS selection) and used.select()(iOS ignores it). Hardened in both files:contentEditable+ Range selection, nopointer-events:none, 16px font-size to stop viewport zoom.Verification
jsdom harness driving the real markup:
node --checktests/test_copy_button_binding.py(7 tests) locks the contract — confirmed to fail against the original markup, so it's a real guard, not a rubber stamp🤖 Generated with Claude Code