Skip to content

fix(chat): copy button never copied anything — inline onclick died on apostrophes - #307

Merged
AVADSA25 merged 1 commit into
mainfrom
fix/chat-copy-button
Aug 4, 2026
Merged

fix(chat): copy button never copied anything — inline onclick died on apostrophes#307
AVADSA25 merged 1 commit into
mainfrom
fix/chat-copy-button

Conversation

@AVADSA25

@AVADSA25 AVADSA25 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

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:

encodeURIComponent("I don't know")  ->  I%20don't%20know

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 list

The 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 same codec_chat.html. Phone and desktop were hitting one identical defect. codec_dashboard.html had already been converted to bindCopyButtons(); codec_chat.html never was — and its copy, edit and speak buttons all shared the broken pattern.

Fix

Emit bare buttons carrying only data-act, then bind with addEventListener over 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 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 (silently blocks the iOS selection) and used .select() (iOS ignores it). Hardened in both files: contentEditable + Range selection, no pointer-events:none, 16px font-size to stop viewport zoom.

Verification

jsdom harness driving the real markup:

PASS  "I don't know"
PASS  "Here's a 'quoted' word and a \"double\" one"
PASS  "Ligne avec apostrophe: l'été, c'est fini"
PASS  "multi\nline\ntext with don't"
PASS  "</script><img src=x onerror=alert(1)>  don't"
PASS  "plain text no punctuation"
6/6 clicked -> exact text delivered

old implementation, same input -> SyntaxError (click did nothing)
  • Inline JS of both files syntax-checked with node --check
  • tests/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
  • Full suite: 2737 passed, 78 skipped

🤖 Generated with Claude Code

… 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>
@AVADSA25
AVADSA25 merged commit 05769b1 into main Aug 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants