mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
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:
@@ -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 },
|
||||
|
||||
@@ -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`
|
||||
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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user