From 80b9dca13e2fe5f8d3299bbb1ebc3550c0264f8a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:37:59 -0700 Subject: [PATCH] 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> --- .../session/MobileNativeChatComposer.test.ts | 54 ++++++++++++----- ...MobileNativeChatView.composer-lock.test.ts | 58 ++++++++++++++++++- ...bileNativeChatView.harness.test-fixture.ts | 1 + mobile/src/session/MobileNativeChatView.tsx | 12 +++- .../session/mobile-native-chat-view-styles.ts | 6 ++ 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index a2b92a701c8..94acbb4ef83 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -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 () => { diff --git a/mobile/src/session/MobileNativeChatView.composer-lock.test.ts b/mobile/src/session/MobileNativeChatView.composer-lock.test.ts index 5ce3dd445c8..fbfa4dcf2ec 100644 --- a/mobile/src/session/MobileNativeChatView.composer-lock.test.ts +++ b/mobile/src/session/MobileNativeChatView.composer-lock.test.ts @@ -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() } diff --git a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts index 92bd95df52c..0489b358d1a 100644 --- a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts @@ -17,6 +17,7 @@ export type Overrides = { sendErrorMessage?: string | null onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null + composerText?: string onSend?: (text: string) => Promise pending?: Parameters[0]['pending'] structuredActivityUi?: boolean diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 1e7d9cf5154..9415db4e42a 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -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} /> + + {lockReason === 'waiting' ? composerPlaceholder : null} + {sendErrorMessage ? ( // This banner is the only channel for a send failure — announce it.