From f2a05c8bdd0689e9805aa3a9c8a633ed6ec9f466 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Mon, 28 Sep 2026 23:27:58 +0200 Subject: [PATCH] Add response copying and native iOS text selection Add response and code-block copy controls with two-second feedback. Use a local Expo UITextView module for iOS selection handles and partial-range copying, preserving attributed formatting and confirmed link navigation. Retain Android selectable Text and fall back on older iOS builds. Bump preview runtime to 0.1.5, iOS build 8, and Android version code 7. Record pending native compilation and device verification. --- apps/mobile/app.config.ts | 6 +- .../expo-module.config.json | 6 + .../ios/TranscriptSelection.podspec | 15 ++ .../ios/TranscriptSelectionModule.swift | 34 +++++ .../ios/TranscriptSelectionView.swift | 99 +++++++++++++ apps/mobile/package.json | 2 +- .../src/components/copy-text-button.test.tsx | 38 +++++ .../src/components/copy-text-button.tsx | 68 +++++++++ .../selectable-transcript-text.test.tsx | 63 +++++++++ .../components/selectable-transcript-text.tsx | 132 ++++++++++++++++++ .../src/screens/session-transcript.test.tsx | 43 +++++- .../mobile/src/screens/session-transcript.tsx | 48 +++++-- .../src/screens/transcript-markdown.test.tsx | 25 +++- .../src/screens/transcript-markdown.tsx | 59 +++++--- docs/COMPATIBILITY.md | 18 +++ 15 files changed, 618 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/modules/transcript-selection/expo-module.config.json create mode 100644 apps/mobile/modules/transcript-selection/ios/TranscriptSelection.podspec create mode 100644 apps/mobile/modules/transcript-selection/ios/TranscriptSelectionModule.swift create mode 100644 apps/mobile/modules/transcript-selection/ios/TranscriptSelectionView.swift create mode 100644 apps/mobile/src/components/copy-text-button.test.tsx create mode 100644 apps/mobile/src/components/copy-text-button.tsx create mode 100644 apps/mobile/src/components/selectable-transcript-text.test.tsx create mode 100644 apps/mobile/src/components/selectable-transcript-text.tsx 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