Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import {
VariantPicker,
ImageAttachments,
SessionInfo,
SelectableTextModal,
type SlashCommand,
type Attachment,
} from "../../src/components/chat"
import { extractCopyText, hasCopyableText } from "../../src/lib/message-copy-text"
import { useSessions } from "../../src/stores/sessions"
import { useEvents, refreshPending } from "../../src/stores/events"
import { useConnections } from "../../src/stores/connections"
Expand Down Expand Up @@ -85,6 +87,10 @@ export default function SessionScreen() {
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInfo, setShowInfo] = useState(false)
// Non-null when the select-text sheet is open; holds the message's source
// text. Kept as the text itself rather than a messageID so the sheet keeps
// showing a stable snapshot even if the message streams or is reverted.
const [selectableText, setSelectableText] = useState<string | null>(null)

const {
currentSession,
Expand Down Expand Up @@ -222,9 +228,35 @@ export default function SessionScreen() {
// closing over props) so MessageBubble's custom memo comparator can bail
// safely without risking a stale handler.
const handleMessageLongPress = useCallback((messageID: string) => {
Alert.alert(t("session.alerts.messageActionsTitle"), undefined, [
{ text: t("common.cancel"), style: "cancel" },
{
const state = useSessions.getState()
const parts = state.parts[messageID]
const isUser = state.messages.find((m) => m.id === messageID)?.role === "user"
const copyText = extractCopyText(parts)
const canCopy = hasCopyableText(parts)

const actions: Parameters<typeof Alert.alert>[2] = [{ text: t("common.cancel"), style: "cancel" }]

// Copy/select come first because they apply to both roles. For assistant
// messages they are the *only* copy path: Markdown.tsx strips `selectable`
// from rendered prose to avoid facebook/react-native#46999 inside the
// transcript FlatList.
if (canCopy) {
actions.push({
text: t("session.actions.copyMessage"),
onPress: () => {
Clipboard.setStringAsync(copyText).catch(() => {})
},
})
actions.push({
text: t("session.actions.selectText"),
onPress: () => setSelectableText(copyText),
})
}

// Edit/revert stays user-only — reverting to an assistant message is not
// a supported operation.
if (isUser) {
actions.push({
text: t("session.actions.editMessage"),
onPress: () => {
const doRevert = async () => {
Expand All @@ -247,8 +279,14 @@ export default function SessionScreen() {
}
doRevert()
},
},
])
})
}

// Nothing but Cancel means there is no action worth interrupting the
// user for (e.g. a tool-only message with no prose).
if (actions.length === 1) return

Alert.alert(t("session.alerts.messageActionsTitle"), undefined, actions)
}, [applyRevertResult, t])

const scrollToBottom = useCallback((animated = true) => {
Expand Down Expand Up @@ -626,6 +664,14 @@ export default function SessionScreen() {
onClose={() => setShowInfo(false)}
/>

{/* Select/copy sheet for message text. Rendered here, outside the
transcript FlatList, so `selectable` actually works on Android. */}
<SelectableTextModal
visible={selectableText !== null}
text={selectableText ?? ""}
onClose={() => setSelectableText(null)}
/>

{/* SSE reconnect/connected banner */}
{reconnectAttempts > 0 && (
<View style={[s.banner, s.bannerReconnecting]}>
Expand Down
15 changes: 9 additions & 6 deletions src/components/chat/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ interface Props {
message: Message
parts: Part[]
isDark: boolean
// Only wired up for user messages — long-press opens the "Edit message" /
// revert action sheet. Identified by messageID (not a closure over parts)
// so it stays correct even if the memo below bails on a stale render.
// Long-press opens the message action sheet. For user messages that sheet
// offers "Edit message" / revert; for both roles it offers copy and
// select-text (the only copy path assistant prose has — see
// src/lib/message-copy-text.ts). Identified by messageID (not a closure
// over parts) so it stays correct even if the memo below bails on a stale
// render.
onLongPress?: (messageID: string) => void
}

Expand All @@ -37,9 +40,9 @@ export const MessageBubble = memo(

return (
<TouchableOpacity
activeOpacity={isUser && onLongPress ? 0.7 : 1}
onLongPress={isUser && onLongPress ? () => onLongPress(message.id) : undefined}
disabled={!isUser || !onLongPress}
activeOpacity={onLongPress ? 0.7 : 1}
onLongPress={onLongPress ? () => onLongPress(message.id) : undefined}
disabled={!onLongPress}
style={[
s.bubble,
isUser ? s.user : s.assistant,
Expand Down
110 changes: 110 additions & 0 deletions src/components/chat/SelectableTextModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useState } from "react"
import { View, Text, Modal, ScrollView, TouchableOpacity, StyleSheet, useColorScheme } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import * as Clipboard from "expo-clipboard"
import { useTranslation } from "react-i18next"

interface Props {
visible: boolean
text: string
onClose: () => void
}

// Renders a message's source text in a fully `selectable` <Text> so the user
// can drag-select a portion of it and use the platform copy affordance.
//
// The critical detail is *where* this renders. Assistant prose in the chat
// transcript is a row of the session screen's inverted FlatList, and a
// `selectable` <Text> in that position hits facebook/react-native#46999 on
// Android — selection state (and accessibility-tree exposure) never applies
// correctly, which is exactly why Markdown.tsx's CustomRenderer strips the
// prop. A <Modal> renders into its own host view outside that FlatList, so
// `selectable` behaves normally here.
export function SelectableTextModal({ visible, text, onClose }: Props) {
const isDark = useColorScheme() === "dark"
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const [copied, setCopied] = useState(false)

const copyAll = async () => {
try {
await Clipboard.setStringAsync(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {}
}

// onRequestClose covers the Android hardware/gesture back action, which
// would otherwise leave the modal stuck open.
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<View style={s.backdrop}>
<View style={[s.sheet, isDark && s.sheetDark]} testID="selectable-text-modal">
<View style={[s.header, isDark && s.headerDark]}>
<Text style={[s.title, isDark && s.titleDark]}>{t("session.selectText.title")}</Text>
<View style={s.headerActions}>
<TouchableOpacity onPress={copyAll} hitSlop={8} testID="selectable-text-copy-all">
<Text style={s.copyBtn}>{copied ? t("session.selectText.copied") : t("session.selectText.copyAll")}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onClose} hitSlop={8} testID="selectable-text-close">
<Ionicons name="close" size={22} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
</View>
</View>

<ScrollView style={s.body} contentContainerStyle={s.bodyContent}>
<Text style={[s.text, isDark && s.textDark]} selectable testID="selectable-text-content">
{text}
</Text>
</ScrollView>

{/* Under edge-to-edge the sheet extends beneath the system
navigation bar, so a fixed paddingBottom leaves this hint drawn
behind it (verified on an Android 12 emulator). Pad by the real
bottom inset instead, with a floor so it still breathes on
devices reporting inset 0. */}
<Text style={[s.hint, isDark && s.hintDark, { paddingBottom: Math.max(insets.bottom, 12) + 8 }]}>
{t("session.selectText.hint")}
</Text>
</View>
</View>
</Modal>
)
}

const s = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
sheet: {
backgroundColor: "#ffffff",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "85%",
minHeight: "50%",
},
sheetDark: { backgroundColor: "#141420" },

header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e5e5",
},
headerDark: { borderBottomColor: "#2a2a2a" },
headerActions: { flexDirection: "row", alignItems: "center", gap: 16 },
title: { fontSize: 16, fontWeight: "600", color: "#0a0a0a" },
titleDark: { color: "#ffffff" },
copyBtn: { fontSize: 14, color: "#8b5cf6", fontWeight: "600" },

body: { flexGrow: 0 },
bodyContent: { padding: 16 },
text: { fontSize: 15, lineHeight: 22, color: "#0a0a0a" },
textDark: { color: "#e5e5e5" },

// paddingBottom is applied inline from the safe-area inset — see render.
hint: { fontSize: 11, color: "#999999", textAlign: "center", paddingHorizontal: 16, paddingTop: 8 },
hintDark: { color: "#666666" },
})
1 change: 1 addition & 0 deletions src/components/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export { ImageAttachments, type Attachment } from "./ImageAttachments"
export { DirectorySwitcher } from "./DirectorySwitcher"
export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet"
export { SessionInfo } from "./SessionInfo"
export { SelectableTextModal } from "./SelectableTextModal"
10 changes: 9 additions & 1 deletion src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@
},
"actions": {
"editMessage": "Edit message",
"replace": "Replace"
"replace": "Replace",
"copyMessage": "Copy message",
"selectText": "Select text"
},
"alerts": {
"messageActionsTitle": "Message actions",
Expand Down Expand Up @@ -123,6 +125,12 @@
"imageFailedMessage": "One or more images could not be processed. Please try a different photo.",
"speechErrorTitle": "Voice input failed",
"speechErrorMessage": "Could not use voice input. Check your microphone permission and try again."
},
"selectText": {
"title": "Select text",
"copyAll": "Copy all",
"copied": "Copied!",
"hint": "Press and hold the text to select part of it."
}
},
"connection": {
Expand Down
10 changes: 9 additions & 1 deletion src/lib/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@
},
"actions": {
"editMessage": "编辑消息",
"replace": "替换"
"replace": "替换",
"copyMessage": "复制消息",
"selectText": "选择文本"
},
"alerts": {
"messageActionsTitle": "消息操作",
Expand Down Expand Up @@ -123,6 +125,12 @@
"imageFailedMessage": "一张或多张图片无法处理。请尝试其他照片。",
"speechErrorTitle": "语音输入失败",
"speechErrorMessage": "无法使用语音输入。请检查麦克风权限后重试。"
},
"selectText": {
"title": "选择文本",
"copyAll": "全部复制",
"copied": "已复制!",
"hint": "长按文本可选择其中一部分。"
}
},
"connection": {
Expand Down
64 changes: 64 additions & 0 deletions src/lib/message-copy-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { extractCopyText, extractReasoningText, hasCopyableText } from "./message-copy-text.ts"
import type { Part } from "./sdk.ts"

test("extractCopyText: joins multiple text parts with newlines", () => {
const parts: Part[] = [
{ id: "p1", messageID: "m1", type: "text", text: "hello" },
{ id: "p2", messageID: "m1", type: "text", text: "world" },
]
assert.equal(extractCopyText(parts), "hello\nworld")
})

test("extractCopyText: preserves markdown source rather than rendered output", () => {
const parts: Part[] = [{ id: "p1", messageID: "m1", type: "text", text: "# Title\n\n**bold** and `code`" }]
assert.equal(extractCopyText(parts), "# Title\n\n**bold** and `code`")
})

test("extractCopyText: excludes reasoning and tool parts", () => {
const parts: Part[] = [
{ id: "p1", messageID: "m1", type: "reasoning", text: "thinking..." },
{ id: "p2", messageID: "m1", type: "tool", tool: "bash" },
{ id: "p3", messageID: "m1", type: "text", text: "final answer" },
]
assert.equal(extractCopyText(parts), "final answer")
})

test("extractCopyText: skips text parts with empty/missing text", () => {
const parts: Part[] = [
{ id: "p1", messageID: "m1", type: "text", text: "" },
{ id: "p2", messageID: "m1", type: "text" },
{ id: "p3", messageID: "m1", type: "text", text: "kept" },
]
assert.equal(extractCopyText(parts), "kept")
})

test("extractCopyText: tolerates undefined and empty parts", () => {
assert.equal(extractCopyText(undefined), "")
assert.equal(extractCopyText([]), "")
})

test("extractReasoningText: collects only reasoning parts", () => {
const parts: Part[] = [
{ id: "p1", messageID: "m1", type: "reasoning", text: "step one" },
{ id: "p2", messageID: "m1", type: "reasoning", text: "step two" },
{ id: "p3", messageID: "m1", type: "text", text: "answer" },
]
assert.equal(extractReasoningText(parts), "step one\nstep two")
})

test("hasCopyableText: false for tool-only, whitespace-only, empty and undefined", () => {
assert.equal(hasCopyableText([{ id: "p1", messageID: "m1", type: "tool", tool: "bash" }]), false)
assert.equal(hasCopyableText([{ id: "p1", messageID: "m1", type: "text", text: " \n\t " }]), false)
assert.equal(hasCopyableText([]), false)
assert.equal(hasCopyableText(undefined), false)
})

test("hasCopyableText: true when any text part has content", () => {
const parts: Part[] = [
{ id: "p1", messageID: "m1", type: "tool", tool: "bash" },
{ id: "p2", messageID: "m1", type: "text", text: "answer" },
]
assert.equal(hasCopyableText(parts), true)
})
39 changes: 39 additions & 0 deletions src/lib/message-copy-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Pure helper: turn a message's parts into the plain text a user would
// expect "Copy message" to put on the clipboard.
//
// Why this exists: assistant prose is rendered through
// src/components/markdown/Markdown.tsx, whose CustomRenderer deliberately
// strips react-native-marked's `selectable` prop from every plain-text node
// to dodge facebook/react-native#46999 (selectable <Text> inside a FlatList
// row misapplies selection state on Android). That left assistant replies
// with no copy path at all — code blocks have CodeBlock's Copy button and
// user messages are plainly `selectable`, but prose had nothing.
//
// Rather than re-enabling `selectable` inside the FlatList row (which is what
// the RN bug punishes), the copy path reads the source text straight from the
// parts. Kept dependency-free so it's testable under plain `node --test`.
import type { Part } from "./sdk"

// Text and reasoning are the two part types rendered as prose. Reasoning is
// visually collapsible (ReasoningBlock) and is not what someone means by
// "copy this message", so it is excluded by default and offered separately.
export function extractCopyText(parts: Part[] | undefined): string {
return (parts || [])
.filter((p) => p.type === "text" && p.text)
.map((p) => p.text)
.join("\n")
}

export function extractReasoningText(parts: Part[] | undefined): string {
return (parts || [])
.filter((p) => p.type === "reasoning" && p.text)
.map((p) => p.text)
.join("\n")
}

// True when there is anything worth offering a copy/select action for.
// Guards the long-press handler so an empty or tool-only message doesn't
// open an action sheet whose actions would all be no-ops.
export function hasCopyableText(parts: Part[] | undefined): boolean {
return extractCopyText(parts).trim().length > 0
}