fix(mobile): keep terminal wait feedback visible with a draft (#31658)

Keep terminal-wait feedback visible while a draft is present, preserving editing and existing send gating.

Adapted from https://github.com/stablyai/orca/pull/31528.

Co-authored-by: monsterlag <64719697+monsterlag@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-21 15:37:59 -07:00
committed by GitHub
co-authored by monsterlag
parent 15246eb4a4
commit 80b9dca13e
5 changed files with 113 additions and 18 deletions
@@ -266,26 +266,50 @@ describe('MobileNativeChatComposer', () => {
expect(onSend).not.toHaveBeenCalled()
})
it('keeps the text input editable while the send is locked', async () => {
it('retains an editable draft through a send lock and can send it after unlocking', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const onChangeText = vi.fn()
vi.mocked(Keyboard.dismiss).mockClear()
const draw = (disabled: boolean, value = 'half-typed') =>
createElement(MobileNativeChatComposer, {
value,
onChangeText,
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
disabled
})
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
})
)
renderer = create(draw(false))
})
const input = () =>
renderer!.root.find((node) => {
const type: unknown = node.type
return type === 'TextInput'
})
const originalInput = input()
await act(async () => input().props.onFocus())
await act(async () => renderer!.update(draw(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(input()).toBe(originalInput)
expect(input().props.editable).not.toBe(false)
expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
expect(onChangeText).not.toHaveBeenCalled()
expect(input().props.value).toBe('half-typed')
expect(Keyboard.dismiss).not.toHaveBeenCalled()
await act(async () => input().props.onChangeText('finished draft'))
expect(onChangeText).toHaveBeenCalledWith('finished draft')
await act(async () => renderer!.update(draw(true, 'finished draft')))
expect(input().props.value).toBe('finished draft')
await act(async () => renderer!.update(draw(false, 'finished draft')))
expect(input()).toBe(originalInput)
expect(sendButton().props).toMatchObject({ disabled: false })
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledExactlyOnceWith('finished draft')
})
it('hands its owner the text field, so Edit can focus it', async () => {
@@ -3,7 +3,9 @@ import { describe, expect, it, vi } from 'vitest'
import {
installChatViewHooks,
composer,
hostType,
render,
root,
update
} from './MobileNativeChatView.harness.test-fixture'
@@ -38,6 +40,51 @@ vi.mock('./MobileNativeChatComposer', async () => {
describe('MobileNativeChatView: composer lock and prompt cards', () => {
installChatViewHooks()
const lockLiveRegion = () =>
root().find(
(node) => hostType(node) === 'Text' && node.props.accessibilityLiveRegion === 'polite'
)
const waitingStatus = () =>
root().findAll(
(node) => hostType(node) === 'Text' && node.props.children === 'Waiting for terminal…'
)
it('updates an already mounted live region for a settled waiting lock with a draft', async () => {
vi.useFakeTimers()
try {
await render({ composerText: 'run the tests' })
const liveRegion = lockLiveRegion()
expect(liveRegion.children).toEqual([])
await update({ inputLockReason: 'waiting', composerText: 'run the tests' })
await act(async () => {
vi.advanceTimersByTime(599)
})
expect(lockLiveRegion()).toBe(liveRegion)
expect(liveRegion.children).toEqual([])
expect(waitingStatus()).toHaveLength(0)
await act(async () => {
vi.advanceTimersByTime(1)
})
expect(composer().props.value).toBe('run the tests')
expect(composer().props.disabled).toBe(true)
expect(waitingStatus()).toHaveLength(1)
expect(waitingStatus()[0]).toBe(liveRegion)
await update({ inputLockReason: 'waiting', composerText: 'run the tests again' })
expect(waitingStatus()).toHaveLength(1)
await update({ inputLockReason: 'disconnected', composerText: 'run the tests again' })
expect(waitingStatus()).toHaveLength(0)
expect(composer().props.placeholder).toBe('Reconnecting…')
expect(lockLiveRegion()).toBe(liveRegion)
expect(liveRegion.children).toEqual([])
} finally {
vi.useRealTimers()
}
})
it('keeps a visible lock through a subscribed-end lease blip', async () => {
vi.useFakeTimers()
try {
@@ -59,6 +106,7 @@ describe('MobileNativeChatView: composer lock and prompt cards', () => {
expect(composer().props.disabled).toBe(true)
expect(composer().props.placeholder).toBe('Waiting for terminal…')
expect(waitingStatus()).toHaveLength(1)
} finally {
vi.useRealTimers()
}
@@ -67,15 +115,17 @@ describe('MobileNativeChatView: composer lock and prompt cards', () => {
it('unlocks after the lease stays ready', async () => {
vi.useFakeTimers()
try {
await render({ inputLockReason: 'waiting' })
await render({ inputLockReason: 'waiting', composerText: 'retained draft' })
await act(async () => {
vi.advanceTimersByTime(600)
})
await update({ inputLockReason: null })
const liveRegion = lockLiveRegion()
await update({ inputLockReason: null, composerText: 'retained draft' })
await act(async () => {
vi.advanceTimersByTime(599)
})
expect(composer().props.disabled).toBe(true)
expect(waitingStatus()).toHaveLength(1)
await act(async () => {
vi.advanceTimersByTime(1)
@@ -83,6 +133,10 @@ describe('MobileNativeChatView: composer lock and prompt cards', () => {
expect(composer().props.disabled).toBe(false)
expect(composer().props.placeholder).toBe('Ask anything, @ to mention files, / for commands')
expect(composer().props.value).toBe('retained draft')
expect(waitingStatus()).toHaveLength(0)
expect(lockLiveRegion()).toBe(liveRegion)
expect(liveRegion.children).toEqual([])
} finally {
vi.useRealTimers()
}
@@ -17,6 +17,7 @@ export type Overrides = {
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
composerText?: string
onSend?: (text: string) => Promise<boolean>
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
structuredActivityUi?: boolean
+11 -1
View File
@@ -339,6 +339,10 @@ export function MobileNativeChatView({
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
const lockReason = useSettledMobileNativeChatInputLock(inputLockReason)
const composerPlaceholder = mobileNativeChatComposerPlaceholder(
lockReason,
turnIndicator?.afterStop
)
// Why only Send, terminal-backed only: that send types into the agent's prompt and can answer it,
// while drafting never does; the host queues a structured send behind it.
const expandedPromptOwnsSend =
@@ -398,6 +402,12 @@ export function MobileNativeChatView({
{...{ permission, onRespondPermission, question, onAnswerQuestion, onCollapsePrompt }}
collapsedPrompt={collapsedPrompt}
/>
<Text
style={lockReason === 'waiting' ? styles.inputLockStatus : undefined}
accessibilityLiveRegion="polite"
>
{lockReason === 'waiting' ? composerPlaceholder : null}
</Text>
{sendErrorMessage ? (
// This banner is the only channel for a send failure — announce it.
<View
@@ -430,7 +440,7 @@ export function MobileNativeChatView({
onMicPressOut={onMicPressOut}
disabled={lockReason !== null}
sendDisabled={expandedPromptOwnsSend}
placeholder={mobileNativeChatComposerPlaceholder(lockReason, turnIndicator?.afterStop)}
placeholder={composerPlaceholder}
filePaths={filePaths}
onNeedFiles={onNeedFiles}
stop={
@@ -8,6 +8,12 @@ export const styles = StyleSheet.create({
flex: 1,
backgroundColor: colors.bgBase
},
inputLockStatus: {
color: colors.textSecondary,
fontSize: typography.metaSize,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xs
},
sendError: {
alignItems: 'center',
paddingHorizontal: spacing.md,