mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
* fix(mobile): dismiss the keyboard after sending to an agent Sending a message left the software keyboard up, covering the reply the user was waiting on. Drop it once the send is accepted, on all three send paths: the terminal live input, the buffered command input, and the chat composer. Gated on the tab being an agent session. A plain shell keeps the keyboard so back-to-back commands stay typeable, a rejected send keeps it so the handed-back draft stays editable, and the accessory shortcut row is untouched because dismissing would pull away the row being tapped. * fix(mobile): gate keyboard dismissal on accepted sends * fix(mobile): fence keyboard dismissal completions * fix(mobile): fence stale send completions * test(mobile): update terminal guard expectations * fix(mobile): restore rejected buffered drafts by origin * fix(mobile): preserve intentional buffered draft clears * fix(mobile): harden send dismissal authority * test(mobile): preserve Strict Mode send dismissal * fix(mobile): preserve drafts across terminal remints * fix(mobile): preserve draft ownership through terminal races * fix(mobile): harden draft recovery and send freshness * fix(mobile): fence route reuse and native draft clears * fix(mobile): preserve native draft edits before clear * test(mobile): pin the terminal-list sweep that bounds buffered drafts `bufferedTerminalDraftState.pruneDrafts(retainedHandles)` is the only bound on two structures that live as long as the session screen — the buffered-draft record and the pending-restoration map — and nothing failed when it was deleted or when it was pointed at the raw `terminal.list` handles instead of the retained set. Both mutations reddened 0 of 3,949 mobile tests. Adds the wiring pin (both mutations now redden it) plus two behavioural tests showing why the argument matters: `terminal.list` omits a chat-covered handle while the desktop graph reloads, so the raw list drops a draft the user is still holding while the retained set keeps it. --------- Co-authored-by: Merge Sim <merge@sim.local> Co-authored-by: Merge Sim <sim@local>
675 lines
23 KiB
TypeScript
675 lines
23 KiB
TypeScript
import { createElement, StrictMode, type ComponentProps } from 'react'
|
||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||
import { Keyboard } from 'react-native'
|
||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||
import { radii, spacing } from '../theme/mobile-theme'
|
||
import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer'
|
||
|
||
const getNoComposerEditGeneration = () => 0
|
||
|
||
function MobileNativeChatComposer({
|
||
getComposerEditGeneration = getNoComposerEditGeneration,
|
||
...props
|
||
}: Omit<ComponentProps<typeof NativeChatComposer>, 'getComposerEditGeneration'> & {
|
||
getComposerEditGeneration?: () => number
|
||
}): React.JSX.Element {
|
||
return createElement(NativeChatComposer, { ...props, getComposerEditGeneration })
|
||
}
|
||
|
||
vi.mock('react-native', async () => {
|
||
const React = await import('react')
|
||
return {
|
||
ActivityIndicator: 'ActivityIndicator',
|
||
Image: 'Image',
|
||
Keyboard: { dismiss: vi.fn() },
|
||
Pressable: 'Pressable',
|
||
ScrollView: ({ children, ...props }: { children?: unknown }) =>
|
||
React.createElement('ScrollView', props, children),
|
||
StyleSheet: {
|
||
create: (styles: unknown) => styles,
|
||
hairlineWidth: 1
|
||
},
|
||
Text: 'Text',
|
||
TextInput: 'TextInput',
|
||
View: 'View'
|
||
}
|
||
})
|
||
|
||
vi.mock('lucide-react-native', () => ({
|
||
ArrowUp: 'ArrowUp',
|
||
Check: 'Check',
|
||
ChevronDown: 'ChevronDown',
|
||
ChevronLeft: 'ChevronLeft',
|
||
ChevronRight: 'ChevronRight',
|
||
ImagePlus: 'ImagePlus',
|
||
Mic: 'Mic',
|
||
Square: 'Square',
|
||
X: 'X'
|
||
}))
|
||
|
||
vi.mock('../components/BottomDrawer', async () => {
|
||
const React = await import('react')
|
||
return {
|
||
BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) =>
|
||
visible ? React.createElement('BottomDrawer', { visible }, children) : null
|
||
}
|
||
})
|
||
|
||
describe('MobileNativeChatComposer', () => {
|
||
let renderer: ReactTestRenderer | null = null
|
||
const getCurrentSendCompletionGeneration = () => 0
|
||
|
||
afterEach(() => {
|
||
act(() => renderer?.unmount())
|
||
renderer = null
|
||
})
|
||
|
||
async function render(
|
||
onSend: (text: string) => Promise<boolean>,
|
||
onChangeText: (text: string) => void,
|
||
isAttaching = false,
|
||
sendSurfaceId = 'tab-a',
|
||
getSendCompletionGeneration = () => 0
|
||
) {
|
||
let composerEditGeneration = 0
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: ' hello ',
|
||
onChangeText: (text) => {
|
||
composerEditGeneration += 1
|
||
onChangeText(text)
|
||
},
|
||
onSend,
|
||
sendSurfaceId,
|
||
getSendCompletionGeneration,
|
||
getComposerEditGeneration: () => composerEditGeneration,
|
||
isAttaching
|
||
})
|
||
)
|
||
})
|
||
}
|
||
|
||
function sendButton(): { props: { onPress: () => Promise<void> } } {
|
||
if (!renderer) {
|
||
throw new Error('Composer was not rendered')
|
||
}
|
||
return renderer.root.find(
|
||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message'
|
||
) as { props: { onPress: () => Promise<void> } }
|
||
}
|
||
|
||
it('reports an accepted send without owning route-scoped draft cleanup', async () => {
|
||
const onChangeText = vi.fn()
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await render(onSend, onChangeText)
|
||
|
||
await act(async () => sendButton().props.onPress())
|
||
|
||
// Verbatim: the send seam trims for the wire, so a rejected send can hand
|
||
// back the draft byte-for-byte (#14819).
|
||
expect(onSend).toHaveBeenCalledWith(' hello ')
|
||
expect(onChangeText).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('stacks the input above the composer action row', async () => {
|
||
await render(vi.fn().mockResolvedValue(true), vi.fn())
|
||
|
||
const composer = renderer!.root.findByProps({ testID: 'native-chat-composer' })
|
||
const inset = renderer!.root.findByProps({ testID: 'native-chat-composer-inset' })
|
||
const actions = renderer!.root.findByProps({ testID: 'native-chat-composer-actions' })
|
||
expect(composer.findAllByType('TextInput')).toHaveLength(1)
|
||
expect(composer.children[1]).toBe(actions)
|
||
expect(inset.props.style).toMatchObject({
|
||
paddingHorizontal: spacing.md,
|
||
paddingTop: spacing.sm,
|
||
paddingBottom: spacing.md
|
||
})
|
||
expect(composer.props.style).toMatchObject({
|
||
borderWidth: 1,
|
||
borderRadius: radii.card,
|
||
overflow: 'hidden'
|
||
})
|
||
})
|
||
|
||
it('preserves leading whitespace so prose is not turned into a slash command', async () => {
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: ' /clear is prose ',
|
||
onChangeText: vi.fn(),
|
||
onSend,
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration
|
||
})
|
||
)
|
||
})
|
||
await act(async () => sendButton().props.onPress())
|
||
expect(onSend).toHaveBeenCalledWith(' /clear is prose ')
|
||
})
|
||
|
||
it('locks the option pickers while a composer send is in flight', async () => {
|
||
// The reverse of the test below. The host spaces a send's body and its Enter
|
||
// ~500ms apart, so an apply tapped inside that window would be submitted as
|
||
// part of the user's prompt instead of running as its own command.
|
||
let releaseSend: ((accepted: boolean) => void) | undefined
|
||
const onSend = vi.fn(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
releaseSend = resolve
|
||
})
|
||
)
|
||
const controller = {
|
||
snapshot: [
|
||
{
|
||
id: 'model',
|
||
label: 'Model',
|
||
category: 'model' as const,
|
||
kind: {
|
||
type: 'select' as const,
|
||
choices: [
|
||
{ value: 'sonnet', label: 'Sonnet 5' },
|
||
{ value: 'opus', label: 'Opus 4.8' }
|
||
]
|
||
},
|
||
valueSource: 'unknown' as const,
|
||
settable: true
|
||
}
|
||
],
|
||
pendingId: null,
|
||
setOption: vi.fn(),
|
||
invokeAction: vi.fn(),
|
||
recordCommand: vi.fn()
|
||
}
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: 'run the tests',
|
||
onChangeText: vi.fn(),
|
||
onSend,
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
sessionOptions: { isWorking: false, controller }
|
||
})
|
||
)
|
||
})
|
||
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
|
||
renderer!.root.find(
|
||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
|
||
) as { props: { accessibilityState: { disabled: boolean } } }
|
||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||
// Start the send but don't await it — it stays in flight on purpose.
|
||
let pressed!: Promise<void>
|
||
await act(async () => {
|
||
pressed = sendButton().props.onPress()
|
||
await Promise.resolve()
|
||
})
|
||
expect(onSend).toHaveBeenCalled()
|
||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
|
||
await act(async () => {
|
||
releaseSend?.(true)
|
||
await pressed
|
||
})
|
||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||
})
|
||
|
||
it('blocks composer submission while a session-option command is pending', async () => {
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: 'hello',
|
||
onChangeText: vi.fn(),
|
||
onSend,
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
sessionOptions: {
|
||
isWorking: false,
|
||
controller: {
|
||
snapshot: [],
|
||
pendingId: 'model',
|
||
setOption: vi.fn(),
|
||
invokeAction: vi.fn(),
|
||
recordCommand: vi.fn()
|
||
}
|
||
}
|
||
})
|
||
)
|
||
})
|
||
expect(sendButton().props).toMatchObject({ disabled: true })
|
||
await act(async () => sendButton().props.onPress())
|
||
expect(onSend).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps the draft when the send is rejected', async () => {
|
||
const onChangeText = vi.fn()
|
||
const onSend = vi.fn().mockResolvedValue(false)
|
||
await render(onSend, onChangeText)
|
||
|
||
await act(async () => sendButton().props.onPress())
|
||
|
||
expect(onSend).toHaveBeenCalledWith(' hello ')
|
||
expect(onChangeText).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('disables send while an attachment path is still being injected', async () => {
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await render(onSend, vi.fn(), true)
|
||
|
||
expect(sendButton().props).toMatchObject({ disabled: true })
|
||
await act(async () => sendButton().props.onPress())
|
||
expect(onSend).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps the text input editable while the send is locked', async () => {
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: 'half-typed',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
disabled: true
|
||
})
|
||
)
|
||
})
|
||
// Revoking `editable` on a focused field resigns first responder on iOS and
|
||
// yanks the keyboard mid-typing (#10681) — the lock may only gate sending.
|
||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||
props: { editable?: boolean }
|
||
}
|
||
expect(input.props.editable).not.toBe(false)
|
||
expect(sendButton().props).toMatchObject({ disabled: true })
|
||
})
|
||
|
||
it('renders a removable thumbnail for each pending image attachment', async () => {
|
||
const onRemoveAttachment = vi.fn()
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
attachments: [
|
||
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
|
||
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
|
||
],
|
||
onRemoveAttachment
|
||
})
|
||
)
|
||
})
|
||
const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{
|
||
props: { source: { uri: string } }
|
||
}>
|
||
expect(thumbs.map((t) => t.props.source.uri)).toEqual(['file:///a.png', 'file:///b.png'])
|
||
|
||
const remove = renderer!.root.findAll(
|
||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Remove image'
|
||
) as Array<{ props: { onPress: () => void } }>
|
||
remove[1].props.onPress()
|
||
expect(onRemoveAttachment).toHaveBeenCalledWith('img-2')
|
||
})
|
||
|
||
it('enables send with an attached image even when the text is empty', async () => {
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '',
|
||
onChangeText: vi.fn(),
|
||
onSend,
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
|
||
})
|
||
)
|
||
})
|
||
expect(sendButton().props).toMatchObject({ disabled: false })
|
||
await act(async () => sendButton().props.onPress())
|
||
expect(onSend).toHaveBeenCalledWith('')
|
||
})
|
||
|
||
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
|
||
const onChangeText = vi.fn()
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '/c',
|
||
onChangeText,
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
agent: 'claude'
|
||
})
|
||
)
|
||
})
|
||
const input = () =>
|
||
renderer!.root.find((node) => node.type === 'TextInput') as {
|
||
props: {
|
||
selection?: { start: number; end: number }
|
||
onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void
|
||
}
|
||
}
|
||
// Uncontrolled selection until a suggestion is applied.
|
||
expect(input().props.selection).toBeUndefined()
|
||
// Place the caret at the end so the slash trigger is active and suggestions render.
|
||
await act(async () =>
|
||
input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } })
|
||
)
|
||
const firstSuggestion = renderer!.root.findAll(
|
||
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
|
||
)[0] as { props: { onPress: () => void } }
|
||
await act(async () => firstSuggestion.props.onPress())
|
||
expect(onChangeText).toHaveBeenCalledWith('/clear ')
|
||
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
|
||
expect(input().props.selection).toEqual({ start: 7, end: 7 })
|
||
// The next native selection event releases control so manual placement still works.
|
||
await act(async () =>
|
||
input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } })
|
||
)
|
||
expect(input().props.selection).toBeUndefined()
|
||
})
|
||
|
||
it('serves the active agent’s shared command catalog with descriptions', async () => {
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '/',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
agent: 'codex'
|
||
})
|
||
)
|
||
})
|
||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||
props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void }
|
||
}
|
||
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 1 } } }))
|
||
const texts = renderer!.root
|
||
.findAll((node) => node.type === 'Text')
|
||
.map((node) => (node.props as { children?: unknown }).children)
|
||
// Codex-only commands from the shared catalog, with their description rows —
|
||
// and none of the old hardcoded provider-agnostic list's phantom entries.
|
||
expect(texts).toContain('/permissions')
|
||
expect(texts).toContain('Choose what Codex is allowed to do')
|
||
expect(texts).not.toContain('/cost')
|
||
})
|
||
|
||
it('wires the mic for hold vs toggle dictation like the terminal composer', async () => {
|
||
const onMicPress = vi.fn()
|
||
const onMicPressIn = vi.fn()
|
||
const onMicPressOut = vi.fn()
|
||
const mic = () =>
|
||
renderer!.root.find(
|
||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate'
|
||
) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } }
|
||
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
onMicPress,
|
||
dictationMode: 'hold',
|
||
onMicPressIn,
|
||
onMicPressOut
|
||
})
|
||
)
|
||
})
|
||
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
|
||
expect(mic().props.onPress).toBeUndefined()
|
||
expect(mic().props.onPressIn).toBe(onMicPressIn)
|
||
expect(mic().props.onPressOut).toBe(onMicPressOut)
|
||
|
||
await act(async () => {
|
||
renderer!.update(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
onMicPress,
|
||
dictationMode: 'toggle',
|
||
onMicPressIn,
|
||
onMicPressOut
|
||
})
|
||
)
|
||
})
|
||
// Toggle mode: tap drives dictation, press-in/out inert.
|
||
expect(mic().props.onPress).toBe(onMicPress)
|
||
expect(mic().props.onPressIn).toBeUndefined()
|
||
expect(mic().props.onPressOut).toBeUndefined()
|
||
})
|
||
|
||
it('dismisses the keyboard once a send is accepted', async () => {
|
||
// Why: the reply the user is now waiting on sits behind the keyboard.
|
||
vi.mocked(Keyboard.dismiss).mockClear()
|
||
await render(vi.fn().mockResolvedValue(true), vi.fn())
|
||
|
||
await act(async () => sendButton().props.onPress())
|
||
|
||
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('dismisses an accepted send after Strict Mode replays mount effects', async () => {
|
||
vi.mocked(Keyboard.dismiss).mockClear()
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(
|
||
StrictMode,
|
||
null,
|
||
createElement(MobileNativeChatComposer, {
|
||
value: 'hello',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration
|
||
})
|
||
)
|
||
)
|
||
})
|
||
|
||
await act(async () => sendButton().props.onPress())
|
||
|
||
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('keeps the keyboard up when the send is rejected', async () => {
|
||
// A rejected send hands the draft back for editing, so yanking the keyboard
|
||
// would make the user re-open it to fix and retry.
|
||
vi.mocked(Keyboard.dismiss).mockClear()
|
||
await render(vi.fn().mockResolvedValue(false), vi.fn())
|
||
|
||
await act(async () => sendButton().props.onPress())
|
||
|
||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not dismiss a newly focused composer when an old accepted send settles', async () => {
|
||
vi.mocked(Keyboard.dismiss).mockClear()
|
||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||
const onSend = vi.fn(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
resolveSend = resolve
|
||
})
|
||
)
|
||
await render(onSend, vi.fn())
|
||
|
||
let pendingSend!: Promise<void>
|
||
await act(async () => {
|
||
pendingSend = sendButton().props.onPress()
|
||
await Promise.resolve()
|
||
})
|
||
await act(async () => {
|
||
renderer!.update(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: 'new surface draft',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
sendSurfaceId: 'tab-b',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration
|
||
})
|
||
)
|
||
})
|
||
await act(async () => {
|
||
resolveSend?.(true)
|
||
await pendingSend
|
||
})
|
||
|
||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not dismiss after a newer edit on the same surface', async () => {
|
||
vi.mocked(Keyboard.dismiss).mockClear()
|
||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||
const onSend = vi.fn(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
resolveSend = resolve
|
||
})
|
||
)
|
||
await render(onSend, vi.fn())
|
||
|
||
let pendingSend!: Promise<void>
|
||
await act(async () => {
|
||
pendingSend = sendButton().props.onPress()
|
||
await Promise.resolve()
|
||
})
|
||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||
props: { onChangeText: (text: string) => void }
|
||
}
|
||
await act(async () => input.props.onChangeText('newer draft'))
|
||
await act(async () => {
|
||
resolveSend?.(true)
|
||
await pendingSend
|
||
})
|
||
|
||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not dismiss after autocomplete mutates the same surface', async () => {
|
||
vi.mocked(Keyboard.dismiss).mockClear()
|
||
let editGeneration = 0
|
||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||
const onSend = vi.fn(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
resolveSend = resolve
|
||
})
|
||
)
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '/c',
|
||
onChangeText: () => {
|
||
editGeneration += 1
|
||
},
|
||
onSend,
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
getComposerEditGeneration: () => editGeneration,
|
||
agent: 'claude'
|
||
})
|
||
)
|
||
})
|
||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||
props: { onSelectionChange: (event: { nativeEvent: { selection: { end: number } } }) => void }
|
||
}
|
||
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }))
|
||
|
||
let pendingSend!: Promise<void>
|
||
await act(async () => {
|
||
pendingSend = sendButton().props.onPress()
|
||
await Promise.resolve()
|
||
})
|
||
const suggestion = renderer!.root.findAll(
|
||
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
|
||
)[0] as { props: { onPress: () => void } }
|
||
await act(async () => suggestion.props.onPress())
|
||
await act(async () => {
|
||
resolveSend?.(true)
|
||
await pendingSend
|
||
})
|
||
|
||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not dismiss after dictation mutates the controlled draft', async () => {
|
||
vi.mocked(Keyboard.dismiss).mockClear()
|
||
let editGeneration = 0
|
||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||
const onSend = vi.fn(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
resolveSend = resolve
|
||
})
|
||
)
|
||
const props = {
|
||
value: 'hello',
|
||
onChangeText: vi.fn(),
|
||
onSend,
|
||
sendSurfaceId: 'tab-a',
|
||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||
getComposerEditGeneration: () => editGeneration
|
||
}
|
||
await act(async () => {
|
||
renderer = create(createElement(MobileNativeChatComposer, props))
|
||
})
|
||
|
||
let pendingSend!: Promise<void>
|
||
await act(async () => {
|
||
pendingSend = sendButton().props.onPress()
|
||
await Promise.resolve()
|
||
})
|
||
editGeneration += 1
|
||
await act(async () => {
|
||
renderer!.update(
|
||
createElement(MobileNativeChatComposer, { ...props, value: 'hello dictated text' })
|
||
)
|
||
})
|
||
await act(async () => {
|
||
resolveSend?.(true)
|
||
await pendingSend
|
||
})
|
||
|
||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not dismiss after its retained route loses focus', async () => {
|
||
vi.mocked(Keyboard.dismiss).mockClear()
|
||
let generation = 0
|
||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||
const onSend = vi.fn(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
resolveSend = resolve
|
||
})
|
||
)
|
||
await render(onSend, vi.fn(), false, 'tab-a', () => generation)
|
||
|
||
let pendingSend!: Promise<void>
|
||
await act(async () => {
|
||
pendingSend = sendButton().props.onPress()
|
||
await Promise.resolve()
|
||
})
|
||
generation += 1
|
||
await act(async () => {
|
||
resolveSend?.(true)
|
||
await pendingSend
|
||
})
|
||
|
||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||
})
|
||
})
|