mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +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>
177 lines
6.5 KiB
TypeScript
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()
|
|
})
|
|
})
|