diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 1280f1f..af13b65 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -41,7 +41,7 @@ if (projectId) {
const config: ExpoConfig = {
name: appName,
slug,
- version: "0.1.4",
+ version: "0.1.5",
newArchEnabled: true,
platforms: ["ios", "android"],
icon: "./assets/icon.png",
@@ -54,7 +54,7 @@ const config: ExpoConfig = {
: { enabled: false },
ios: {
bundleIdentifier: iosBundleIdentifier,
- buildNumber: "7",
+ buildNumber: "8",
supportsTablet: true,
config: {
usesNonExemptEncryption: false,
@@ -70,7 +70,7 @@ const config: ExpoConfig = {
android: {
package: androidPackage,
...(googleServicesFile ? { googleServicesFile } : {}),
- versionCode: 6,
+ versionCode: 7,
allowBackup: false,
predictiveBackGestureEnabled: false,
softwareKeyboardLayoutMode: "resize",
diff --git a/apps/mobile/modules/transcript-selection/expo-module.config.json b/apps/mobile/modules/transcript-selection/expo-module.config.json
new file mode 100644
index 0000000..53405db
--- /dev/null
+++ b/apps/mobile/modules/transcript-selection/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["apple"],
+ "apple": {
+ "modules": ["TranscriptSelectionModule"]
+ }
+}
diff --git a/apps/mobile/modules/transcript-selection/ios/TranscriptSelection.podspec b/apps/mobile/modules/transcript-selection/ios/TranscriptSelection.podspec
new file mode 100644
index 0000000..c2bd5ea
--- /dev/null
+++ b/apps/mobile/modules/transcript-selection/ios/TranscriptSelection.podspec
@@ -0,0 +1,15 @@
+Pod::Spec.new do |s|
+ s.name = 'TranscriptSelection'
+ s.version = '1.0.0'
+ s.summary = 'Native selectable transcript text'
+ s.description = 'Read-only attributed UITextView with native range selection.'
+ s.license = { :type => 'MIT' }
+ s.author = 'Omnicus'
+ s.homepage = 'https://github.com/omnicus/opencode2-mobile'
+ s.platforms = { :ios => '15.1' }
+ s.source = { :git => 'https://github.com/omnicus/opencode2-mobile.git' }
+ s.static_framework = true
+ s.dependency 'ExpoModulesCore'
+ s.swift_version = '5.9'
+ s.source_files = '**/*.{h,m,mm,swift}'
+end
diff --git a/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionModule.swift b/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionModule.swift
new file mode 100644
index 0000000..823d1a1
--- /dev/null
+++ b/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionModule.swift
@@ -0,0 +1,34 @@
+import ExpoModulesCore
+
+public class TranscriptSelectionModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("TranscriptSelection")
+ View(TranscriptSelectionView.self) {
+ Events("onMeasure", "onLink")
+ Prop("runs") { (view: TranscriptSelectionView, runs: [SelectionRun]) in
+ view.runs = runs
+ }
+ Prop("unwrapped") { (view: TranscriptSelectionView, value: Bool) in
+ view.unwrapped = value
+ }
+ OnViewDidUpdateProps { (view: TranscriptSelectionView) in
+ view.updateText()
+ }
+ }
+ }
+}
+
+struct SelectionRun: Record {
+ @Field var text: String = ""
+ @Field var color: Double = 4294967295
+ @Field var background: Double? = nil
+ @Field var fontSize: Double = 17
+ @Field var lineHeight: Double = 26
+ @Field var bold: Bool = false
+ @Field var italic: Bool = false
+ @Field var mono: Bool = false
+ @Field var strike: Bool = false
+ @Field var underline: Bool = false
+ @Field var link: Bool = false
+ @Field var alignment: String = "left"
+}
diff --git a/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionView.swift b/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionView.swift
new file mode 100644
index 0000000..e5f3568
--- /dev/null
+++ b/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionView.swift
@@ -0,0 +1,99 @@
+import ExpoModulesCore
+import UIKit
+
+final class TranscriptSelectionView: ExpoView, UITextViewDelegate {
+ let onMeasure = EventDispatcher()
+ let onLink = EventDispatcher()
+ var runs: [SelectionRun] = []
+ var unwrapped = false
+ private let textView = SelectionTextView()
+ private var lastSize = CGSize.zero
+
+ required init(appContext: AppContext? = nil) {
+ super.init(appContext: appContext)
+ textView.isEditable = false
+ textView.isSelectable = true
+ textView.isScrollEnabled = false
+ textView.backgroundColor = .clear
+ textView.textContainerInset = .zero
+ textView.textContainer.lineFragmentPadding = 0
+ textView.dataDetectorTypes = []
+ textView.linkTextAttributes = [:]
+ textView.delegate = self
+ addSubview(textView)
+ }
+
+ func updateText() {
+ let attributed = NSMutableAttributedString(string: "")
+ for (index, run) in runs.enumerated() {
+ let size = CGFloat(run.fontSize)
+ let weight: UIFont.Weight = run.bold ? .semibold : .regular
+ var font = run.mono ? UIFont.monospacedSystemFont(ofSize: size, weight: weight)
+ : UIFont.systemFont(ofSize: size, weight: weight)
+ if run.italic, let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.traitItalic)) {
+ font = UIFont(descriptor: descriptor, size: size)
+ }
+ let paragraph = NSMutableParagraphStyle()
+ paragraph.minimumLineHeight = CGFloat(run.lineHeight)
+ paragraph.maximumLineHeight = CGFloat(run.lineHeight)
+ paragraph.alignment = run.alignment == "right" ? .right : run.alignment == "center" ? .center : .natural
+ var attributes: [NSAttributedString.Key: Any] = [
+ .font: font, .foregroundColor: color(run.color), .paragraphStyle: paragraph
+ ]
+ if let background = run.background { attributes[.backgroundColor] = color(background) }
+ if run.strike { attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
+ if run.underline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue }
+ if run.link { attributes[.link] = URL(string: "transcript-link://run/\(index)") }
+ attributed.append(NSAttributedString(string: run.text, attributes: attributes))
+ }
+ // Unrelated React updates must not clear an active selection. Preserve its range
+ // when streaming appends text; clamp only if the server shortens the content.
+ if textView.attributedText?.isEqual(to: attributed) != true {
+ let selected = textView.selectedRange
+ textView.attributedText = attributed
+ let location = min(selected.location, attributed.length)
+ textView.selectedRange = NSRange(location: location, length: min(selected.length, attributed.length - location))
+ }
+ setNeedsLayout()
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ guard bounds.width > 0 else { return }
+ let width = unwrapped ? CGFloat(1_000_000) : bounds.width
+ let fitted = textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
+ let size = CGSize(width: unwrapped ? ceil(fitted.width) : bounds.width, height: ceil(fitted.height))
+ textView.frame = CGRect(origin: .zero, size: size)
+ if size != lastSize {
+ lastSize = size
+ onMeasure(["width": size.width, "height": size.height])
+ }
+ }
+
+ func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
+ // Route taps through the existing confirmation handler. Never open links here.
+ if interaction == .invokeDefaultAction, let index = Int(URL.lastPathComponent) {
+ onLink(["index": index])
+ }
+ return false
+ }
+
+ private func color(_ value: Double) -> UIColor {
+ let bits = UInt32(truncatingIfNeeded: Int64(value))
+ return UIColor(red: CGFloat((bits >> 16) & 255) / 255,
+ green: CGFloat((bits >> 8) & 255) / 255,
+ blue: CGFloat(bits & 255) / 255,
+ alpha: CGFloat((bits >> 24) & 255) / 255)
+ }
+}
+
+private final class SelectionTextView: UITextView {
+ override func copy(_ sender: Any?) {
+ let source = (text ?? "") as NSString
+ let range = selectedRange
+ guard range.location != NSNotFound, range.length > 0,
+ range.location <= source.length, range.length <= source.length - range.location else { return }
+ // Keep native range selection, but do not copy internal link-dispatch URLs.
+ UIPasteboard.general.string = source.substring(with: range)
+ }
+}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 0547f78..86a37dd 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode2-mobile/mobile",
- "version": "0.1.4",
+ "version": "0.1.5",
"private": true,
"main": "index.ts",
"scripts": {
diff --git a/apps/mobile/src/components/copy-text-button.test.tsx b/apps/mobile/src/components/copy-text-button.test.tsx
new file mode 100644
index 0000000..d81ff4f
--- /dev/null
+++ b/apps/mobile/src/components/copy-text-button.test.tsx
@@ -0,0 +1,38 @@
+import { afterEach, beforeEach, expect, jest, test } from "@jest/globals";
+import { act, fireEvent, render, screen } from "@testing-library/react-native";
+import * as Clipboard from "expo-clipboard";
+
+import { CopyTextButton } from "./copy-text-button";
+
+jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn() }));
+
+beforeEach(() => {
+ jest.mocked(Clipboard.setStringAsync).mockReset().mockResolvedValue(true);
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+test("restores the copy label two seconds after a successful copy", async () => {
+ jest.useFakeTimers();
+ render();
+ await act(async () => fireEvent.press(screen.getByRole("button", { name: "Copy code" })));
+ expect(screen.getByText("Copied")).toBeOnTheScreen();
+ act(() => jest.advanceTimersByTime(1_999));
+ expect(screen.getByText("Copied")).toBeOnTheScreen();
+ act(() => jest.advanceTimersByTime(1));
+ expect(screen.queryByText("Copied")).toBeNull();
+ expect(screen.getByText("Copy code")).toBeOnTheScreen();
+});
+
+test("copies exact text and reports a failed write without claiming success", async () => {
+ jest.mocked(Clipboard.setStringAsync).mockResolvedValueOnce(false);
+ render();
+ fireEvent.press(screen.getByRole("button", { name: "Copy text" }));
+ await screen.findByText("Retry copy");
+ expect(screen.queryByText("Copied")).toBeNull();
+ fireEvent.press(screen.getByRole("button", { name: "Copy text" }));
+ await screen.findByText("Copied");
+ expect(Clipboard.setStringAsync).toHaveBeenLastCalledWith(" first\n\nsecond\n");
+});
diff --git a/apps/mobile/src/components/copy-text-button.tsx b/apps/mobile/src/components/copy-text-button.tsx
new file mode 100644
index 0000000..30e315b
--- /dev/null
+++ b/apps/mobile/src/components/copy-text-button.tsx
@@ -0,0 +1,68 @@
+import * as Clipboard from "expo-clipboard";
+import { useEffect, useRef, useState } from "react";
+import { AccessibilityInfo, Pressable, StyleSheet, Text } from "react-native";
+
+import { palette, space, typography } from "../theme";
+
+export function CopyTextButton({
+ text,
+ label = "Copy text",
+ iconOnly = false,
+}: {
+ text: string;
+ label?: string;
+ iconOnly?: boolean;
+}) {
+ const [result, setResult] = useState<{ text: string; status: "copied" | "failed" }>();
+ const pending = useRef(false);
+ const status = result?.text === text ? result.status : undefined;
+ useEffect(() => {
+ if (result?.status !== "copied") return;
+ const timer = setTimeout(() => setResult(undefined), 2_000);
+ return () => clearTimeout(timer);
+ }, [result]);
+ async function copy() {
+ if (pending.current) return;
+ pending.current = true;
+ try {
+ const copied = await Clipboard.setStringAsync(text);
+ if (!copied) throw new Error("CLIPBOARD_WRITE_FAILED");
+ setResult({ text, status: "copied" });
+ AccessibilityInfo.announceForAccessibility("Copied to clipboard");
+ } catch {
+ setResult({ text, status: "failed" });
+ AccessibilityInfo.announceForAccessibility("Could not copy. Try again.");
+ } finally {
+ pending.current = false;
+ }
+ }
+ return (
+ void copy()}
+ style={styles.action}
+ >
+ {iconOnly && !status ? (
+
+ ) : (
+
+ {status === "copied" ? "Copied" : status === "failed" ? "Retry copy" : label}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ action: {
+ minHeight: 44,
+ minWidth: 44,
+ justifyContent: "center",
+ alignSelf: "flex-start",
+ paddingHorizontal: space.xs,
+ },
+ label: { ...typography.caption, color: palette.dim },
+});
+
+import Feather from "@expo/vector-icons/Feather";
diff --git a/apps/mobile/src/components/selectable-transcript-text.test.tsx b/apps/mobile/src/components/selectable-transcript-text.test.tsx
new file mode 100644
index 0000000..643a98b
--- /dev/null
+++ b/apps/mobile/src/components/selectable-transcript-text.test.tsx
@@ -0,0 +1,63 @@
+import { afterEach, expect, jest, test } from "@jest/globals";
+import { fireEvent, render, screen } from "@testing-library/react-native";
+import { createElement as mockCreateElement } from "react";
+import { View as MockView, Platform, Text } from "react-native";
+import { TranscriptMarkdown } from "../screens/transcript-markdown";
+import { SelectableTranscriptText, selectionRuns } from "./selectable-transcript-text";
+
+jest.mock("expo", () => ({
+ requireOptionalNativeModule: () => ({}),
+ requireNativeView: () => (props: object) =>
+ mockCreateElement(MockView, { ...props, testID: "native-selection" }),
+}));
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+test("builds attributed runs with nested formatting, dynamic type and safe link callbacks", () => {
+ const open = jest.fn();
+ const { runs, actions } = selectionRuns(
+ <>
+ Normal{" "}
+
+ bold nested
+
+ link
+ >,
+ { color: "#ffffff", fontSize: 18, lineHeight: 27 },
+ 1.5,
+ );
+ expect(runs.map((run) => run.text).join("")).toBe("Normal bold nestedlink");
+ expect(runs[0]).toMatchObject({ fontSize: 27, lineHeight: 40.5, bold: false, link: false });
+ expect(runs.find((run) => run.text === "nested")).toMatchObject({ bold: true, italic: true });
+ expect(runs.find((run) => run.text === "link")).toMatchObject({ link: true });
+ actions[runs.findIndex((run) => run.link)]?.();
+ expect(open).toHaveBeenCalledTimes(1);
+});
+
+test("the Markdown call site supplies actual inline content to the iOS view", () => {
+ const open = jest.fn();
+ render(
+ ,
+ );
+ const view = screen.getByTestId("native-selection");
+ const runs = view.props.runs as ReturnType["runs"];
+ expect(runs.map((run) => run.text).join("")).toBe("Hello bold and Docs.");
+ expect(runs.find((run) => run.text === "bold")?.bold).toBe(true);
+ fireEvent(view, "link", { nativeEvent: { index: runs.findIndex((run) => run.link) } });
+ expect(open).toHaveBeenCalledWith("https://example.test/docs");
+ fireEvent(view, "measure", { nativeEvent: { width: 320, height: 78 } });
+ expect(view).toHaveStyle({ height: 78 });
+});
+
+test("Android retains its native selectable Text renderer", () => {
+ jest.replaceProperty(Platform, "OS", "android");
+ render(Mark part of this text);
+ expect(screen.queryByTestId("native-selection")).toBeNull();
+ expect(screen.getByText("Mark part of this text")).toHaveProp("selectable", true);
+});
diff --git a/apps/mobile/src/components/selectable-transcript-text.tsx b/apps/mobile/src/components/selectable-transcript-text.tsx
new file mode 100644
index 0000000..32f87b0
--- /dev/null
+++ b/apps/mobile/src/components/selectable-transcript-text.tsx
@@ -0,0 +1,132 @@
+import { requireNativeView, requireOptionalNativeModule } from "expo";
+import { Children, isValidElement, type ReactNode, useMemo, useState } from "react";
+import {
+ Platform,
+ processColor,
+ StyleSheet,
+ Text,
+ type TextProps,
+ type TextStyle,
+ useWindowDimensions,
+ type ViewProps,
+} from "react-native";
+
+type Run = {
+ text: string;
+ color: number;
+ background?: number;
+ fontSize: number;
+ lineHeight: number;
+ bold: boolean;
+ italic: boolean;
+ mono: boolean;
+ strike: boolean;
+ underline: boolean;
+ link: boolean;
+ alignment: string;
+};
+type NativeProps = ViewProps & {
+ runs: Run[];
+ unwrapped: boolean;
+ onMeasure: (event: { nativeEvent: { width: number; height: number } }) => void;
+ onLink: (event: { nativeEvent: { index: number } }) => void;
+};
+// Older installed builds and Expo Go retain the standard renderer until rebuilt.
+const NativeText =
+ Platform.OS === "ios" && requireOptionalNativeModule("TranscriptSelection")
+ ? requireNativeView("TranscriptSelection")
+ : undefined;
+
+export function selectionRuns(children: ReactNode, base: TextStyle, scale: number) {
+ const runs: Run[] = [];
+ const actions: Array<(() => void) | undefined> = [];
+ function visit(content: ReactNode, style: TextStyle, action?: () => void) {
+ Children.forEach(content, (child) => {
+ if (typeof child === "string" || typeof child === "number") {
+ const fontSize = Number(style.fontSize ?? 17) * scale;
+ const color = processColor(style.color ?? "#ffffff");
+ const background = processColor(style.backgroundColor);
+ runs.push({
+ text: String(child),
+ color: typeof color === "number" ? color : 0xffffffff,
+ ...(typeof background === "number" ? { background } : {}),
+ fontSize,
+ lineHeight: Number(style.lineHeight ?? Number(style.fontSize ?? 17) * 1.5) * scale,
+ bold: style.fontWeight === "bold" || Number(style.fontWeight) >= 600,
+ italic: style.fontStyle === "italic",
+ mono: /menlo|monospace|courier/i.test(style.fontFamily ?? ""),
+ strike: style.textDecorationLine?.includes("line-through") ?? false,
+ underline: style.textDecorationLine?.includes("underline") ?? false,
+ link: Boolean(action),
+ alignment: style.textAlign ?? "left",
+ });
+ actions.push(action);
+ } else if (
+ isValidElement<{ children?: ReactNode; style?: TextProps["style"]; onPress?: () => void }>(
+ child,
+ )
+ ) {
+ visit(
+ child.props.children,
+ { ...style, ...StyleSheet.flatten(child.props.style) },
+ child.props.onPress ?? action,
+ );
+ }
+ });
+ }
+ visit(children, base);
+ return { runs, actions };
+}
+
+export function SelectableTranscriptText({
+ children,
+ style,
+ unwrapped = false,
+ ...props
+}: TextProps & { unwrapped?: boolean }) {
+ const { fontScale } = useWindowDimensions();
+ const [size, setSize] = useState<{ width: number; height: number }>();
+ const flatStyle = StyleSheet.flatten(style) ?? {};
+ const { runs, actions } = useMemo(
+ () =>
+ selectionRuns(
+ children,
+ StyleSheet.flatten(style) ?? {},
+ props.allowFontScaling === false ? 1 : fontScale,
+ ),
+ [children, style, fontScale, props.allowFontScaling],
+ );
+ if (Platform.OS !== "ios" || !NativeText)
+ return (
+
+ {children}
+
+ );
+ return (
+ {
+ if (
+ Number.isFinite(nativeEvent.height) &&
+ nativeEvent.height >= 0 &&
+ Number.isFinite(nativeEvent.width)
+ ) {
+ setSize((current) =>
+ current?.width === nativeEvent.width && current.height === nativeEvent.height
+ ? current
+ : nativeEvent,
+ );
+ }
+ }}
+ onLink={({ nativeEvent }) => actions[nativeEvent.index]?.()}
+ style={{
+ ...flatStyle,
+ height: size?.height ?? Number(flatStyle.lineHeight ?? 26) * fontScale,
+ ...(unwrapped ? { width: size?.width ?? 1 } : {}),
+ }}
+ />
+ );
+}
diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx
index 6d9c8d7..7a9fe3e 100644
--- a/apps/mobile/src/screens/session-transcript.test.tsx
+++ b/apps/mobile/src/screens/session-transcript.test.tsx
@@ -1,6 +1,7 @@
import { afterEach, expect, jest, test } from "@jest/globals";
import type { SessionMessageInfo } from "@opencode2-mobile/opencode-adapter";
-import { fireEvent, render, screen } from "@testing-library/react-native";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react-native";
+import * as Clipboard from "expo-clipboard";
import { Alert, Linking, View } from "react-native";
import { resetTranscriptPerformanceMetrics } from "../state/transcript-performance";
@@ -13,6 +14,32 @@ import {
afterEach(resetTranscriptPerformanceMetrics);
+jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn(async () => true) }));
+
+test("one response copy action combines prose parts without reasoning or model metadata", async () => {
+ const message = messages.find((item) => item.type === "assistant");
+ if (!message) throw new Error("fixture");
+ render(
+ ,
+ );
+ expect(screen.getAllByRole("button", { name: "Copy response" })).toHaveLength(1);
+ fireEvent.press(screen.getByRole("button", { name: "Copy response" }));
+ await waitFor(() =>
+ expect(Clipboard.setStringAsync).toHaveBeenLastCalledWith(
+ "First paragraph\n\nSecond paragraph",
+ ),
+ );
+});
+
test("inline code preserves literal markup and does not create links inside code", () => {
const message = messages.find((item) => item.type === "assistant");
if (!message) throw new Error("fixture");
@@ -38,6 +65,20 @@ test("inline code preserves literal markup and does not create links inside code
expect(screen.getByText("Important")).toHaveStyle({ fontWeight: "700" });
});
+test("message text supports native selection without separate copy or selection controls", () => {
+ const message = messages.find((item) => item.type === "assistant");
+ if (!message) throw new Error("fixture");
+ const text = "First paragraph\n\nSecond paragraph";
+ render();
+ for (const paragraph of ["First paragraph", "Second paragraph"]) {
+ let node = screen.getByText(paragraph);
+ while (!node.props.selectable && node.parent) node = node.parent;
+ expect(node.props.selectable).toBe(true);
+ }
+ expect(screen.queryByRole("button", { name: "Select text" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Copy text" })).toBeNull();
+});
+
test("activity summaries count operations rather than inventing file counts", () => {
const message = messages.find((item) => item.type === "assistant");
if (!message) throw new Error("fixture");
diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx
index afab1b1..372ccc5 100644
--- a/apps/mobile/src/screens/session-transcript.tsx
+++ b/apps/mobile/src/screens/session-transcript.tsx
@@ -3,6 +3,8 @@ import { memo, useEffect, useState } from "react";
import { Alert, Linking, Pressable, StyleSheet, Text, View } from "react-native";
import { applicationName } from "../application-name";
+import { CopyTextButton } from "../components/copy-text-button";
+import { SelectableTranscriptText } from "../components/selectable-transcript-text";
import { recordTranscriptRowCommit } from "../state/transcript-performance";
import { markdownPalette, palette, radius, space, typeRamp, typography } from "../theme";
import {
@@ -202,6 +204,19 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({
);
case "assistant": {
const visibleContent = message.content.slice(0, maxAssistantParts);
+ const responseParts = visibleContent.flatMap((part) =>
+ part.type === "text" ? [part.text] : [],
+ );
+ let copyTruncated = message.content
+ .slice(maxAssistantParts)
+ .some((part) => part.type === "text");
+ const responseText = responseParts
+ .map((text) => {
+ const safe = sanitizeTranscriptText(text.slice(0, maxSanitizedInput), maxVisibleText + 1);
+ copyTruncated ||= safe.length > maxVisibleText || text.length > maxSanitizedInput;
+ return safe.slice(0, maxVisibleText);
+ })
+ .join("\n\n");
return (
{(detailed
@@ -300,6 +315,13 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({
{hasNarrativeContent(message) ? (
) : null}
+ {responseText ? (
+
+ ) : null}
);
}
@@ -606,20 +628,20 @@ function ToolDisclosure({
/>
{expanded
? presentation.files.map((file) => (
-
{file}
-
+
))
: null}
{expanded && presentation.command ? (
-
+
{`$ ${presentation.command}`}
-
+
) : null}
{expanded
? keyToolContent(visibleContent).map(({ item, key }) =>
@@ -630,12 +652,16 @@ function ToolDisclosure({
text={parseSubagentProtocolText(item.text).text}
/>
) : (
-
+
{sanitizeTranscriptText(
item.name?.trim() ? basename(item.name.trim()) : "File result",
256,
)}
-
+
),
)
: null}
@@ -732,9 +758,9 @@ function ShellDisclosure({ largeText, message }: { largeText: boolean; message:
onPress={() => setExpanded((current) => !current)}
/>
{expanded ? (
-
+
{`$ ${message.command}`}
-
+
) : null}
{expanded && message.output?.output ? (
@@ -1031,9 +1057,9 @@ function InlineMarkdownText({
function LinkifiedText({ style, text }: { style: object; text: string }) {
return (
-
-
-
+
+ {LinkifiedTextContent({ text })}
+
);
}
diff --git a/apps/mobile/src/screens/transcript-markdown.test.tsx b/apps/mobile/src/screens/transcript-markdown.test.tsx
index 33647fc..2ebc461 100644
--- a/apps/mobile/src/screens/transcript-markdown.test.tsx
+++ b/apps/mobile/src/screens/transcript-markdown.test.tsx
@@ -1,9 +1,32 @@
import { expect, jest, test } from "@jest/globals";
-import { fireEvent, render, screen } from "@testing-library/react-native";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react-native";
+import * as Clipboard from "expo-clipboard";
import { TranscriptMarkdown } from "./transcript-markdown";
const body = { fontSize: 17, lineHeight: 26 };
+jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn(async () => true) }));
+
+test("copies only the selected code block, preserving indentation and newlines", async () => {
+ render(
+ ,
+ );
+ const copy = screen.getAllByRole("button", { name: "Copy code" })[0];
+ if (!copy) throw new Error("Missing code copy action");
+ fireEvent.press(copy);
+ await waitFor(() =>
+ expect(Clipboard.setStringAsync).toHaveBeenLastCalledWith(" first();\n\n second();\n"),
+ );
+ expect(screen.getByText(" first();\n\n second();", { exact: true })).toHaveProp(
+ "selectable",
+ true,
+ );
+});
+
test("keeps emphasis local to its nested content and paragraph", () => {
render(
+
{prefix ? {`${prefix} `} : null}
-
-
+ {InlineContent({ tokens, onOpenLink: props.onOpenLink })}
+
);
}
@@ -149,12 +150,14 @@ function Blocks({ blocks, ...props }: Omit & { blocks: Node[] })
switch (token.type) {
case "inline":
return (
-
+
+ {InlineContent({ tokens: token.children ?? [], onOpenLink: props.onOpenLink })}
+
);
case "paragraph_open":
case "heading_open":
return (
- & { blocks: Node[] })
/h[4-6]/.test(token.tag) && styles.headingSmall,
]}
>
-
-
+ {Blocks({ blocks: children, ...props })}
+
);
case "fence":
case "code_block": {
@@ -179,15 +182,22 @@ function Blocks({ blocks, ...props }: Omit & { blocks: Node[] })
accessibilityLabel={language ? `Code block, ${language}` : "Code block"}
style={styles.codeBlock}
>
- {language ? (
-
- {language.toLocaleUpperCase()}
-
- ) : null}
+
+ {language ? (
+
+ {language.toLocaleUpperCase()}
+
+ ) : null}
+
+
-
+
{token.content.replace(/\n$/, "")}
-
+
);
@@ -222,9 +232,9 @@ function Blocks({ blocks, ...props }: Omit & { blocks: Node[] })
return ;
default:
return (
-
+
{token.content}
-
+
);
}
});
@@ -245,7 +255,7 @@ function MarkdownTable({ sections, ...props }: Omit & { sections:
{row.children.map((cell) => (
- & { sections:
cell.token.attrGet("style") === "text-align:center" && styles.alignCenter,
]}
>
-
-
+ {Blocks({ blocks: cell.children, ...props })}
+
))}
@@ -292,6 +302,13 @@ const styles = StyleSheet.create({
gap: space.xs,
},
codeLanguage: { ...typography.label, color: palette.dim },
+ codeHeader: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: space.sm,
+ },
codeText: { ...typography.code, color: palette.ink },
list: { gap: space.sm },
listRow: { flexDirection: "row", gap: space.sm },
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index 5454216..140dcaa 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -12,6 +12,24 @@ the fail-closed database backup-exclusion startup guard. Statements marked
pending in older dated entries describe the status at the time of that probe;
later entries supersede them.
+## 2026-09-28: transcript range selection, pending native verification
+
+The user reported that long-pressing transcript text on iOS offered Copy without
+selection handles. The installed React Native iOS paragraph implementation exposes
+a whole-text copy menu rather than range selection.
+
+An Apple-only local Expo view now uses a non-editable, selectable `UITextView` for
+transcript text, preserving attributed formatting and routing link taps through
+the existing confirmation callback. Android continues to use selectable native
+React Native Text. Selection is scoped to each rendered text block or table cell.
+
+The iOS module requires a new native build and runtime fingerprint; OTA alone does
+not install it. Older builds fall back to their existing text renderer. Native
+compilation and physical-device verification are pending. Verify selection handles,
+copying a partial range, scrolling during selection, link confirmation, streaming,
+large text, and VoiceOver/TalkBack before claiming device support. JavaScript tests
+and Hermes exports do not establish these native behaviors.
+
## 2026-09-27: iOS multiline composer sizing
A physical iPhone recording showed typed text wrapping beyond the visible input