Merge remote-tracking branch 'origin/main' into brennanb2025/agent-launch-host-prompt-delivery

Brings main to 40f9e7a3fc, which fixes main's own locale tests; no conflicts.
This commit is contained in:
Brennan Benson
2026-10-05 02:24:48 -07:00
182 changed files with 9438 additions and 790 deletions
@@ -7,6 +7,7 @@ const openURL = vi.fn(() => Promise.resolve())
vi.mock('react-native', () => ({
Linking: { openURL: (url: string) => openURL(url) },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
+39 -5
View File
@@ -1,5 +1,6 @@
import { openExternalLink } from '../platform/external-link'
import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher'
import { INLINE_TEXT_SELECTION } from './inline-text-selection'
import { MobileSelectableText } from './MobileSelectableText'
import {
Fragment,
@@ -33,6 +34,8 @@ type Props = {
fallback?: string
/** Enables iOS range selection for native-chat transcript prose. */
rangeSelectable?: boolean
/** Forward long presses from interactive Android transcript spans to the message. */
onLongPress?: () => void
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
* the chat view passes >1 so agent prose reads larger than the compact base. */
textScale?: number
@@ -47,11 +50,28 @@ const MAX_TABLE_ROWS = 40
const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
const MarkdownTextContext = createContext<ComponentType<TextProps>>(NativeText)
type MarkdownTextSetup = {
TextComponent: ComponentType<TextProps>
/** Disable native selection only within the Android transcript. */
androidTranscript: boolean
onLongPress?: () => void
}
const MarkdownTextContext = createContext<MarkdownTextSetup>({
TextComponent: NativeText,
androidTranscript: false
})
function MarkdownText(props: TextProps): React.JSX.Element {
const TextComponent = useContext(MarkdownTextContext)
return createElement(TextComponent, props)
const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext)
if (!androidTranscript) {
return createElement(TextComponent, props)
}
// Override selection without changing the nested spans' inherited behavior.
return createElement(TextComponent, {
...props,
...(props.selectable === true ? { selectable: false } : {}),
...(onLongPress && props.onPress ? { onLongPress } : {})
})
}
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
@@ -215,6 +235,9 @@ function MobileMarkdownContent({
textScale = 1,
onOpenFile
}: Props) {
// Interactive children own their touches and must forward the row action.
const setup = useContext(MarkdownTextContext)
const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined
const text = content?.trim() ?? ''
const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text])
const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText])
@@ -288,6 +311,7 @@ function MobileMarkdownContent({
key={index}
style={styles.imageFrame}
onPress={() => openMarkdownHref(block.url, onOpenFile)}
onLongPress={rowLongPress}
>
<NativeText style={styles.link}>{block.alt || 'Open image'}</NativeText>
<NativeText style={styles.imageCaption} numberOfLines={1}>
@@ -380,9 +404,19 @@ function MobileMarkdownContent({
}
function MobileMarkdownInner(props: Props): React.JSX.Element | null {
const TextComponent = props.rangeSelectable ? MobileSelectableText : NativeText
const { rangeSelectable = false, onLongPress } = props
// Other Markdown surfaces retain their existing selection behavior.
const androidTranscript = rangeSelectable && !INLINE_TEXT_SELECTION
const setup = useMemo<MarkdownTextSetup>(
() => ({
TextComponent: rangeSelectable && !androidTranscript ? MobileSelectableText : NativeText,
androidTranscript,
...(onLongPress ? { onLongPress } : {})
}),
[rangeSelectable, androidTranscript, onLongPress]
)
return (
<MarkdownTextContext.Provider value={TextComponent}>
<MarkdownTextContext.Provider value={setup}>
<MobileMarkdownContent {...props} />
</MarkdownTextContext.Provider>
)
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({ Platform: { OS: 'android' } }))
const { INLINE_TEXT_SELECTION, inlineTextSelectionAllowed } =
await import('./inline-text-selection')
describe('inline text selection policy', () => {
it('keeps inline selection everywhere but Android', () => {
expect(inlineTextSelectionAllowed('ios')).toBe(true)
expect(inlineTextSelectionAllowed('web')).toBe(true)
expect(inlineTextSelectionAllowed('android')).toBe(false)
})
it('resolves the running platform once', () => {
expect(INLINE_TEXT_SELECTION).toBe(false)
})
})
@@ -0,0 +1,8 @@
import { Platform } from 'react-native'
// Android double-tap selection can mistake successive scroll flicks for word selection.
export function inlineTextSelectionAllowed(os: string): boolean {
return os !== 'android'
}
export const INLINE_TEXT_SELECTION = inlineTextSelectionAllowed(Platform.OS)
@@ -0,0 +1,88 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdown } from './MobileMarkdown'
// Why Android here: the sibling selectable test runs as iOS and proves prose IS selectable there;
// this one proves the transcript carries no selectable span on Android, where a selectable
// TextView selects words while the transcript scrolls — and that other surfaces are untouched.
vi.mock('react-native', () => ({
Linking: { openURL: () => Promise.resolve() },
Platform: { OS: 'android' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
const CONTENT = [
'# Heading',
'A paragraph with **bold**, `code` and https://example.com/link.',
'> quoted',
'- item one',
'| a | b |',
'| - | - |',
'| 1 | 2 |',
'```',
'fenced()',
'```'
].join('\n')
describe('MobileMarkdown on Android', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(props: Parameters<typeof MobileMarkdown>[0]): ReactTestInstance[] {
act(() => {
renderer = create(createElement(MobileMarkdown, props))
})
return renderer!.root.findAll((node) => String(node.type) === 'Text')
}
const tappable = (nodes: ReactTestInstance[]) =>
nodes.filter((node) => typeof node.props.onPress === 'function')
it('renders the transcript with no selectable span, leaving untouched spans alone', () => {
const all = render({ content: CONTENT, rangeSelectable: true })
expect(all.length).toBeGreaterThan(5)
expect(all.filter((node) => node.props.selectable === true)).toHaveLength(0)
// Inline spans (bold, code, links) never asked for selection; writing `false` onto them
// would map to `userSelect: none` on the web, so the gate must leave them alone.
const links = tappable(all)
expect(links.length).toBeGreaterThan(0)
for (const node of links) {
expect(node.props.selectable).toBeUndefined()
}
})
it('routes a long press on a tappable span or image to the row, so neither swallows it', () => {
const onLongPress = vi.fn()
const content = `${CONTENT}\n\n![diagram](https://example.com/diagram.png)`
const all = render({ content, rangeSelectable: true, onLongPress })
const links = tappable(all)
expect(links.length).toBeGreaterThan(0)
for (const node of links) {
expect(node.props.onLongPress).toBe(onLongPress)
}
expect(all.filter((node) => !node.props.onPress && node.props.onLongPress)).toHaveLength(0)
const images = renderer!.root.findAll(
(node) => String(node.type) === 'Pressable' && typeof node.props.onPress === 'function'
)
expect(images.length).toBeGreaterThan(0)
for (const node of images) {
expect(node.props.onLongPress).toBe(onLongPress)
}
})
it('keeps other surfaces (task comments, previews) selectable as before', () => {
const all = render({ content: CONTENT })
expect(all.filter((node) => node.props.selectable === true).length).toBeGreaterThan(0)
})
})
@@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown'
vi.mock('react-native', () => ({
Linking: { openURL: vi.fn() },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
@@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown'
vi.mock('react-native', () => ({
Linking: { openURL: () => Promise.resolve() },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
@@ -0,0 +1,121 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
// Why a separate file: the sibling suite runs as iOS, where the row has no long press at all.
// This one runs as Android and pins the wiring from the bubble's long press to the actions sheet.
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
React.createElement('Text', props, children)
return {
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'android' },
Pressable: 'Pressable',
Text,
View: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ChevronDown: 'ChevronDown',
Copy: 'Copy',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench',
ChevronRight: 'ChevronRight'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
const message: NativeChatMessage = {
id: 'a1',
role: 'assistant',
blocks: [{ type: 'text', text: 'Read https://example.com then reply.' }],
timestamp: null,
source: 'transcript'
}
describe('MobileNativeChatMessage on Android', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function byType(type: string): ReactTestInstance[] {
return renderer!.root.findAll((node) => String(node.type) === type)
}
it('opens the actions sheet from a long press on the bubble, and closes it again', () => {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message }))
})
expect(byType('MessageActionsSheet')).toHaveLength(0)
const [bubble] = byType('Pressable')
expect(typeof bubble!.props.onLongPress).toBe('function')
// The markdown gets the same handler, so a link span or image under the finger opens the sheet.
const [markdown] = byType('MobileMarkdown')
expect(markdown!.props.onLongPress).toBe(bubble!.props.onLongPress)
expect(markdown!.props.rangeSelectable).toBe(true)
act(() => bubble!.props.onLongPress())
const [sheet] = byType('MessageActionsSheet')
expect(sheet!.props.message).toBe(message)
act(() => sheet!.props.onClose())
expect(byType('MessageActionsSheet')).toHaveLength(0)
})
it('disables inline selection for host notices too', () => {
act(() => {
renderer = create(
createElement(MobileNativeChatMessage, {
message: {
...message,
role: 'system',
blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }]
}
})
)
})
const texts = byType('Text').filter((node) => node.props.selectable !== undefined)
expect(texts).toHaveLength(1)
expect(texts[0]!.props.selectable).toBe(false)
act(() => byType('Pressable')[0]!.props.onLongPress())
expect(byType('MessageActionsSheet')).toHaveLength(1)
})
it('renders the user bubble without inline selection', () => {
act(() => {
renderer = create(
createElement(MobileNativeChatMessage, { message: { ...message, id: 'u1', role: 'user' } })
)
})
const texts = byType('Text').filter((node) => node.props.selectable !== undefined)
expect(texts.length).toBeGreaterThan(0)
for (const node of texts) {
expect(node.props.selectable).toBe(false)
}
})
})
@@ -22,6 +22,7 @@ vi.mock('react-native', async () => {
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
Text,
View: ({ children, ...props }: { children?: unknown }) =>
@@ -40,6 +41,9 @@ vi.mock('lucide-react-native', () => ({
ChevronRight: 'ChevronRight'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
+51 -7
View File
@@ -1,6 +1,8 @@
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { memo } from 'react'
import { Image, Text as NativeText, View } from 'react-native'
import { memo, useCallback, useState, type ComponentProps, type ReactNode } from 'react'
import { Image, Text as NativeText, Pressable, View } from 'react-native'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
@@ -20,17 +22,23 @@ function Prose({
block,
invert,
fontScale,
onOpenFile
onOpenFile,
onLongPress
}: {
block: NativeChatBlock
invert?: boolean
fontScale: number
onOpenFile?: (relativePath: string) => void
/** Android only: routes a long press on a link span to the row's actions sheet. */
onLongPress?: () => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
if (isAgentSessionHostStatusPresentation(block.presentation)) {
return (
<Text selectable style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}
>
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
</Text>
)
@@ -39,7 +47,10 @@ function Prose({
// markdown renderer's light-on-dark palette.
if (invert) {
return (
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}
>
{block.text}
</Text>
)
@@ -50,6 +61,7 @@ function Prose({
rangeSelectable
textScale={1.25 * fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
)
}
@@ -76,6 +88,25 @@ function Prose({
return null
}
// Keep the existing responder hierarchy on platforms with inline selection.
function Content({
onLongPress,
style,
children
}: {
onLongPress?: () => void
style: ComponentProps<typeof View>['style']
children: ReactNode
}): React.JSX.Element {
return onLongPress ? (
<Pressable onLongPress={onLongPress} style={style}>
{children}
</Pressable>
) : (
<View style={style}>{children}</View>
)
}
function MobileNativeChatMessageImpl({
message,
toolsExpanded = false,
@@ -129,6 +160,11 @@ function MobileNativeChatMessageImpl({
!turnExpanded &&
!toolsExpanded
const showToolRun = tools.length > 0 && !settledToolsHidden
// Mount selection UI only for the message being copied.
const [actionsOpen, setActionsOpen] = useState(false)
// Keep the memoized Markdown context stable as the message streams.
const openActions = useCallback(() => setActionsOpen(true), [])
const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions
const statusRow = turnStatus ? (
<MobileNativeChatTurnStatus
@@ -144,7 +180,8 @@ function MobileNativeChatMessageImpl({
{/* A turn with no user bubble carries its bar above its first row. */}
{turnStatusAbove ? statusRow : null}
<View style={[styles.row, isUser && styles.rowUser]}>
<View
<Content
onLongPress={onLongPress}
style={[styles.content, isUser && styles.userBubble, isReasoning && styles.reasoning]}
>
{prose.map((block, index) => (
@@ -154,6 +191,7 @@ function MobileNativeChatMessageImpl({
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
))}
{showToolRun ? (
@@ -168,8 +206,14 @@ function MobileNativeChatMessageImpl({
onOpenFile={onOpenFile}
/>
) : null}
</View>
</Content>
</View>
{actionsOpen ? (
<MobileNativeChatMessageActionsSheet
message={message}
onClose={() => setActionsOpen(false)}
/>
) : null}
{turnStatusAbove ? null : statusRow}
</>
)
@@ -0,0 +1,126 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
const { writeText, alert } = vi.hoisted(() => ({ writeText: vi.fn(), alert: vi.fn() }))
vi.mock('react-native', () => ({
Alert: { alert },
ActivityIndicator: 'ActivityIndicator',
Modal: 'Modal',
Pressable: 'Pressable',
ScrollView: 'ScrollView',
Text: 'Text',
View: 'View',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 24, bottom: 16, left: 0, right: 0 })
}))
vi.mock('lucide-react-native', () => ({
Copy: 'Copy',
TextSelect: 'TextSelect',
X: 'X',
Edit3: 'Edit3',
Trash2: 'Trash2'
}))
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText }) }))
// Keep the real drawer lifecycle; only native animation completion is driven by the test.
vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedDrawer' }))
const message: NativeChatMessage = {
id: 'reply',
source: 'transcript',
role: 'assistant',
timestamp: null,
blocks: [{ type: 'text', text: 'First words' }]
}
describe('message selection actions', () => {
let renderer: ReactTestRenderer | null = null
const onClose = vi.fn()
const nodes = (type: string) => renderer!.root.findAll((node) => String(node.type) === type)
const render = (value = message) => {
act(() => {
renderer = create(
createElement(MobileNativeChatMessageActionsSheet, { message: value, onClose })
)
})
}
const pressAction = (index: number) => act(() => nodes('Pressable')[index]!.props.onPress())
const finishClosing = () => act(() => nodes('MountedDrawer')[0]!.props.onHidden())
beforeEach(() => {
vi.clearAllMocks()
writeText.mockResolvedValue(undefined)
})
afterEach(() => act(() => renderer?.unmount()))
it('waits for drawer removal, then selects a stable snapshot while the reply streams', () => {
render()
act(() =>
renderer!.update(
createElement(MobileNativeChatMessageActionsSheet, {
message: {
...message,
blocks: [{ type: 'text', text: 'First words plus streamed text' }]
},
onClose
})
)
)
pressAction(1)
expect(nodes('MountedDrawer')[0]!.props.visible).toBe(false)
expect(nodes('Modal')).toHaveLength(0)
expect(onClose).not.toHaveBeenCalled()
finishClosing()
expect(nodes('MountedDrawer')).toHaveLength(0)
expect(nodes('Modal')).toHaveLength(1)
const selected = nodes('Text').find((node) => node.props.selectable)
expect(selected?.props.children).toBe('First words')
act(() => nodes('Modal')[0]!.props.onRequestClose())
expect(onClose).toHaveBeenCalledOnce()
})
it('closes selection with the accessible close button', () => {
render()
pressAction(1)
finishClosing()
const close = nodes('Pressable').find((node) => node.props.accessibilityLabel === 'Close')
expect(close?.props.accessibilityRole).toBe('button')
act(() => close!.props.onPress())
expect(onClose).toHaveBeenCalledOnce()
})
it('copies prose and dismisses only after the drawer closes', () => {
render()
pressAction(0)
expect(writeText).toHaveBeenCalledWith('First words')
expect(onClose).not.toHaveBeenCalled()
finishClosing()
expect(onClose).toHaveBeenCalledOnce()
expect(nodes('Modal')).toHaveLength(0)
})
it('surfaces clipboard rejection', async () => {
writeText.mockRejectedValue(new Error('Clipboard unavailable'))
render()
await act(async () => pressAction(0))
expect(alert).toHaveBeenCalledWith('Copy failed', 'Clipboard unavailable')
})
it('dismisses without selecting when the drawer is cancelled', () => {
render()
act(() => nodes('MountedDrawer')[0]!.props.onClose())
finishClosing()
expect(onClose).toHaveBeenCalledOnce()
expect(nodes('Modal')).toHaveLength(0)
})
it('disables actions when the message has no copyable prose', () => {
render({ ...message, blocks: [{ type: 'image-ref', path: '/image.png' }] })
expect(nodes('Pressable').map((node) => node.props.disabled)).toEqual([true, true])
expect(writeText).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,119 @@
import { useRef, useState } from 'react'
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Copy, TextSelect, X } from 'lucide-react-native'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
import { useClipboardWriter } from '../platform/clipboard'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
type Props = {
/** The long-pressed message. The owner mounts this only while the sheet is open. */
message: NativeChatMessage
onClose: () => void
}
export function MobileNativeChatMessageActionsSheet({
message,
onClose
}: Props): React.JSX.Element {
const clipboard = useClipboardWriter()
const [sheetVisible, setSheetVisible] = useState(true)
const [selecting, setSelecting] = useState(false)
// Wait for the drawer to unmount before presenting another native modal.
const selectRequested = useRef(false)
// Streaming updates must not reset an active native text selection.
const [text] = useState(() => nativeChatMessagePlainText(message))
const closeSheet = () => setSheetVisible(false)
return (
<>
<BottomDrawer
visible={sheetVisible}
onClose={closeSheet}
onAfterClose={() => (selectRequested.current ? setSelecting(true) : onClose())}
dragContentToDismiss
>
<ActionSheetContent
onClose={closeSheet}
actions={[
{
label: 'Copy message',
icon: Copy,
disabled: text.length === 0,
onPress: () => {
void clipboard.writeText(text).catch((error: unknown) => {
Alert.alert(
'Copy failed',
error instanceof Error ? error.message : 'The clipboard rejected the text.'
)
})
}
},
{
label: 'Select text',
icon: TextSelect,
disabled: text.length === 0,
onPress: () => {
selectRequested.current = true
}
}
]}
/>
</BottomDrawer>
{selecting ? <SelectTextScreen text={text} onClose={onClose} /> : null}
</>
)
}
function SelectTextScreen({
text,
onClose
}: {
text: string
onClose: () => void
}): React.JSX.Element {
const insets = useSafeAreaInsets()
return (
<Modal visible animationType="slide" onRequestClose={onClose}>
<View style={[styles.screen, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.header}>
<Text style={styles.title}>Select text</Text>
<Pressable
onPress={onClose}
hitSlop={12}
accessibilityLabel="Close"
accessibilityRole="button"
style={styles.close}
>
<X size={20} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.body}>
<Text selectable style={styles.text}>
{text}
</Text>
</ScrollView>
</View>
</Modal>
)
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.bgBase },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
title: { color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '600' },
close: { padding: spacing.xs },
body: { padding: spacing.lg },
text: { color: colors.textPrimary, fontSize: typography.bodySize, lineHeight: 22 }
})
@@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => {
expect(onToggleExpanded).toHaveBeenCalledOnce()
})
it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => {
// The crash's notice row explains the cut; the turn bar reads like any finished turn.
it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => {
const crashed = render({
startedAt: Date.now(),
workedSeconds: 12,
verdict: 'interruption',
onToggleExpanded: vi.fn()
})
expect(labels(crashed.root)).toEqual(['Failed after 12s'])
expect(labels(crashed.root)).toEqual(['Worked for 12s'])
act(() => crashed.unmount())
const stopped = render({
startedAt: Date.now(),
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
describe('nativeChatMessagePlainText', () => {
it('joins the prose blocks, keeps their whitespace, and leaves tool calls out', () => {
const blocks: NativeChatBlock[] = [
{ type: 'text', text: ' indented first line\n' },
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
{ type: 'text', text: ' \n' },
{ type: 'text', text: 'Second, with `code`.' }
]
expect(nativeChatMessagePlainText({ blocks })).toBe(
' indented first line\n\n\nSecond, with `code`.'
)
})
it('copies the displayed host notice instead of its fallback wire text', () => {
expect(
nativeChatMessagePlainText({
blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }]
})
).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
})
it('is empty for a message with no prose', () => {
expect(
nativeChatMessagePlainText({ blocks: [{ type: 'tool-call', name: 'Read', input: {} }] })
).toBe('')
})
})
@@ -0,0 +1,20 @@
import {
AGENT_SESSION_HOST_STATUS_COPY,
isAgentSessionHostStatusPresentation
} from '../../../src/shared/agent-session-host-status-rows'
import { isTextBlock } from '../../../src/shared/native-chat-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
/** Copy the displayed prose, excluding tool activity and image attachments. */
export function nativeChatMessagePlainText(message: Pick<NativeChatMessage, 'blocks'>): string {
// Preserve indentation in code and nested lists.
return message.blocks
.filter(isTextBlock)
.map((block) =>
isAgentSessionHostStatusPresentation(block.presentation)
? AGENT_SESSION_HOST_STATUS_COPY[block.presentation]
: block.text
)
.filter((text) => text.trim().length > 0)
.join('\n\n')
}
@@ -0,0 +1,132 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }
}))
const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' }
const ITEMS: AgentJournalRenderItem[] = [
{
itemId: 'user-1',
revision: 0,
sequence: 1,
observedAt: 1,
turnScope: { kind: 'thread' },
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }
},
{
itemId: 'turn-1',
revision: 1,
sequence: 2,
observedAt: 2,
turnScope: { kind: 'thread' },
body: {
kind: 'turn',
turnId: 'turn-1',
userItemId: 'user-1',
state: 'interrupted',
startedAt: 2,
completedAt: 9
}
},
{
itemId: 'reply-1',
revision: 0,
sequence: 3,
observedAt: 3,
turnScope: SCOPE,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] }
}
]
function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent {
return {
type: 'snapshot',
sessionId: 'session-1',
fence: 3,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
fence: 3,
direction: 'tail',
items,
removedItemIds: [],
submissions: [],
window: {
oldest: { epoch: 'epoch-1', sequence: 1 },
newest: { epoch: 'epoch-1', sequence: 3 },
nextCursor: { epoch: 'epoch-1', sequence: 4 }
},
liveCursor: { epoch: 'epoch-1', sequence: 3 },
hasOlder: false,
hasNewer: false
}
}
}
describe('useMobileStructuredAgentSession transcript', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => {
let listener: ((value: unknown) => void) | null = null
const client: Pick<RpcClient, 'sendRequest' | 'subscribe'> = {
sendRequest: vi.fn(async (): Promise<RpcResponse> => ({
id: 'response-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})),
subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
listener = onData
return () => {}
})
}
const rendered: { hook: ReturnType<typeof useMobileStructuredAgentSession> | null } = {
hook: null
}
function Harness(): null {
rendered.hook = useMobileStructuredAgentSession({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided.
client: client as RpcClient,
sessionId: 'session-1',
enabled: true,
connected: true,
hostSupport: null,
agent: 'codex',
onSendError: vi.fn()
})
return null
}
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshot(ITEMS)))
await vi.waitFor(() =>
expect(rendered.hook?.session.messages.at(-1)).toMatchObject({
role: 'system',
blocks: [
{
text: 'Codex stopped while this response was in progress. You can continue in this conversation.',
tone: 'error'
}
]
})
)
// The turn bars place rows by the same items, so the notice joins the cut turn.
expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE })
})
})
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working'
import {
activeStructuredAgentSessionTurnId,
@@ -157,16 +159,28 @@ export function useMobileStructuredAgentSession(args: {
onSendError
})
// What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no
// row gets.
const transcriptItems = useMemo(
() =>
withNativeChatCutTurnNotices(state.items, {
agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude']
}),
[agent, state.items]
)
const messages = useMemo(
// Off: the phone hands a rejected message back to its composer, so a row would show it twice.
() =>
projectStructuredAgentSessionMessages(state.items, [], state.submissions, {
projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, {
rejectedInPlace: false
}),
[state.items, state.submissions]
[transcriptItems, state.submissions]
)
const turnId = activeStructuredAgentSessionTurnId(state.items)
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
const turnTiming = useMobileStructuredAgentTurnTiming(
{ ...state, items: transcriptItems },
turnId
)
const activityText =
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
const thinking = isStructuredAgentSessionThinking(state.items)