Files
orca/mobile/src/session/MobileNativeChatOverlay.test.ts
T
3d341c132f fix(mobile): dismiss the keyboard after sending to an agent (#17059)
* 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>
2026-08-30 16:59:55 -07:00

177 lines
6.5 KiB
TypeScript

import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} },
View: 'View'
}))
vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' }))
function assistantTurn(id: string, text: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
}
/** One render of the route: chat visible or not, the transcript it currently
* holds, and the agent-status stream behind it. */
type Tick = {
show?: boolean
messages?: NativeChatMessage[]
streamingText?: string
streamLive?: boolean
identity?: string
}
function overlayElement(tick: Tick): ReturnType<typeof createElement> {
const controller = {
showNativeChat: tick.show ?? true,
nativeChatSession: { messages: tick.messages ?? [], status: 'ready' },
nativeChatAgent: 'claude',
nativeChatAgentWorking: tick.streamLive ?? false,
nativeChatStreamingText: tick.streamingText,
nativeChatStreamLive: tick.streamLive ?? false,
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
chatPending: [],
chatImagePreviewsByMessageId: {},
chatComposerText: '',
setChatComposerText: vi.fn()
} as unknown as MobileNativeChatController
return createElement(MobileNativeChatOverlay, {
controller,
images: {} as never,
onMicPress: vi.fn(),
micActive: false,
dictationMode: 'toggle',
onMicPressIn: vi.fn(),
onMicPressOut: vi.fn(),
inputLockReason: null,
sendErrorMessage: null,
onClearSendError: vi.fn(),
sendSurfaceId: tick.identity ?? 'tab-a',
getSendCompletionGeneration: () => 0,
keyboardInset: 0
})
}
describe('MobileNativeChatOverlay streaming gate', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(tick: Tick): Promise<void> {
await act(async () => {
renderer = create(overlayElement(tick))
})
}
async function update(tick: Tick): Promise<void> {
await act(async () => {
renderer?.update(overlayElement(tick))
})
}
/** The bubble text handed to the chat list, or `'hidden'` when chat is off. */
function streaming(): string | null | 'hidden' {
const views = renderer!.root.findAll((node) => node.type === 'ChatView')
return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null)
}
it('keeps streaming a reply that repeats the previous turn as a prefix', async () => {
const prior = [assistantTurn('a1', 'The tests pass.')]
await render({ messages: prior })
expect(streaming()).toBeNull()
await update({ messages: prior, streamingText: 'The tests', streamLive: true })
expect(streaming()).toBe('The tests')
})
it('drops the streaming bubble once the reply lands as its own turn', async () => {
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
await update({
messages: [...prior, assistantTurn('a2', 'Done.')],
streamingText: 'Done.',
streamLive: true
})
expect(streaming()).toBeNull()
})
it('keeps the bubble across a peek at the terminal view', async () => {
// Toggling to the terminal unmounts the chat list and unsubscribes its
// transcript. The gate lives above that boundary, so the baseline survives
// and the repeated-prefix reply keeps streaming on the way back.
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
await update({ show: false, messages: [], streamLive: true })
expect(streaming()).toBe('hidden')
// Back on chat the session withholds its transcript until a fresh read
// settles, so the throttled stream text returns a round trip ahead of it.
await update({ messages: [], streamLive: true })
await update({ messages: [], streamingText: 'Done.', streamLive: true })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
})
it('keeps the bubble across a peek at the terminal taken between turns', async () => {
// Same toggle, but taken while idle: the transcript empties before the next
// turn starts, so the gate has to reject that empty tail as a baseline.
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ show: false, messages: [] })
await update({ show: false, messages: [], streamLive: true })
await update({ messages: [], streamLive: true })
await update({ messages: [], streamingText: 'Done.', streamLive: true })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
})
it('hides a repeated part whose own turn landed during a mid-turn gap', async () => {
// Between parts the status frame carries no assistant text (a tool call), so
// the stream goes textless while the turn is still live and the part that
// just finished lands in the transcript. Re-anchoring on that tick would
// adopt it as history and render it a second time.
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
const landed = [...prior, assistantTurn('a2', 'Done.')]
await update({ messages: landed, streamLive: true })
await update({ messages: landed, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBeNull()
})
it("does not carry one chat's baseline into another stream identity", async () => {
const prior = [assistantTurn('a1', 'Shared answer text')]
await render({ messages: prior, identity: 'tab-a' })
await update({
messages: prior,
streamingText: 'Shared answer',
streamLive: true,
identity: 'tab-b'
})
expect(streaming()).toBeNull()
})
})