mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user