Skip to content

feat(chat): make assistant message text copyable and selectable - #184

Closed
omgoshjosh wants to merge 2 commits into
dzianisv:mainfrom
omgoshjosh:fix/copy-select-assistant-text
Closed

feat(chat): make assistant message text copyable and selectable#184
omgoshjosh wants to merge 2 commits into
dzianisv:mainfrom
omgoshjosh:fix/copy-select-assistant-text

Conversation

@omgoshjosh

Copy link
Copy Markdown

Assistant prose currently has no copy path at all on Android.

Why

src/components/markdown/Markdown.tsx defines a CustomRenderer whose whole purpose is to strip the selectable prop that react-native-marked hardcodes on every plain-text node, because a selectable <Text> inside a FlatList row hits facebook/react-native#46999 on Android. Chat messages are rows of the session screen's inverted FlatList, so every markdown node hits it.

That workaround is correct as far as it goes. But its stated justification —

Code content is still copyable via CodeBlock's explicit Copy button, so dropping selectable on plain text costs little.

— undercounts the cost. Current coverage:

Content Copyable? How
User message text Yes <Text selectable> in MessageBubble
Fenced code blocks Yes CodeBlock's Copy button
Tool call output Yes <Text selectable> in ToolCallCard
Assistant prose No

So the one thing users most want to copy — the assistant's actual answer — can't be selected, copied or shared by any means.

Approach

Rather than re-enabling selectable inside the FlatList row (exactly what RN#46999 punishes), add a copy path that reads the source text from the message parts:

  • message-copy-text.ts — pure extractCopyText / extractReasoningText / hasCopyableText over a message's parts. Dependency-free, unit-tested.
  • SelectableTextModal — renders that text in a selectable <Text> inside a <Modal>. The modal is the point: it renders into its own host view outside the transcript FlatList, so RN#46999 does not apply and real partial selection works.
  • MessageBubble — long-press enabled for both roles (was user-only).
  • session/[id].tsx — the action sheet offers "Copy message" and "Select text" for either role; "Edit message" stays user-only since reverting to an assistant message isn't supported. Returns early when a message has no prose, so tool-only messages don't open an empty sheet.

i18n strings added to both en and zh-Hans (catalog-parity test enforced).

The second commit fixes a safe-area bug in the new modal: under edge-to-edge the sheet extends beneath the navigation bar, so a fixed paddingBottom left the hint drawn behind it. It now pads by the real bottom inset.

Verification

Verified on an Android 12 emulator against a live server:

  1. Long-press an assistant message → "Message actions" sheet with Copy message / Select text (and correctly no "Edit message")
  2. Select text → modal shows the markdown source
  3. Long-press inside the modal → selection handles plus Android's native Copy / Share / Select all menu

Partial selection of assistant prose now works where it was previously impossible.

tsc --noEmit clean; full suite passes (294 tests) on this branch.

Note

app/demo.tsx renders MessageBubble without onLongPress, so the demo conversation is still uncopyable. Deliberately left out to keep this reviewable — happy to add it here or in a follow-up, whichever you prefer.

Joshua Castaneda and others added 2 commits August 15, 2026 10:45
Assistant prose had no copy path at all. src/components/markdown/Markdown.tsx
defines a CustomRenderer whose entire purpose is to strip the `selectable`
prop that react-native-marked hardcodes on every plain-text node, because a
selectable <Text> nested in a FlatList row hits react/react-native#46999
on Android. Chat messages are rows of the session screen's inverted FlatList,
so every markdown text node hits it.

That workaround is correct as far as it goes, but its stated justification --
"code content is still copyable via CodeBlock's explicit Copy button, so
dropping `selectable` on plain text costs little" -- undercounts the cost.
User messages are plainly `selectable` and tool output is `selectable`, but
assistant prose, the thing users most want to copy, could not be selected,
copied, or shared by any means.

Rather than re-enabling `selectable` inside the FlatList row (which is what
RN#46999 punishes), add a copy path that reads the source text from the
message parts:

- message-copy-text.ts: pure extractCopyText/extractReasoningText/
  hasCopyableText over a message's parts. Dependency-free, unit-tested.
- SelectableTextModal: renders that text in a `selectable` <Text> inside a
  <Modal>. The modal renders outside the transcript FlatList, so RN#46999
  does not apply and real partial selection works.
- MessageBubble: long-press enabled for both roles (was user-only).
- session/[id].tsx: the action sheet offers "Copy message" and "Select text"
  for either role; "Edit message" stays user-only since reverting to an
  assistant message is unsupported. Returns early when a message has no
  prose so tool-only messages don't open an empty sheet.

Known gap: app/demo.tsx renders MessageBubble without onLongPress, so the
demo conversation is still uncopyable. Left out to keep this reviewable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Under Expo's mandatory edge-to-edge display the modal sheet extends
beneath the system navigation bar, so the fixed paddingBottom:20 left
the hint drawn behind it -- confirmed on an Android 12 emulator.

Pad by the real safe-area bottom inset instead, with a floor so the hint
still has breathing room on devices reporting an inset of 0.

Same class of edge-to-edge inset bug as the composer/keyboard issue
(dzianisv#156).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@omgoshjosh

Copy link
Copy Markdown
Author

Folding this into #182 to keep the tracker tidy — same commit, same verification, just consolidated with the other two Android fixes rather than three separate PRs. Sorry for the noise.

@omgoshjosh omgoshjosh closed this Aug 15, 2026
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.

1 participant