mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(mobile-native-chat): reland glued pending retirement without the two revert causes Relands #14665 (reverted by #14819). #14665 retired mobile pending bubbles when two fast sends landed as one transcript row, but shipped two regressions; both are fixed here rather than re-applied and hoped for. 1. A rejected send restored a TRIMMED composer. #14665 reassigned `text` to `text.trimEnd()` at the top of `sendMessage` and then used that one value for both the bytes on the wire and the composer restore, so a rejection put back less than the user typed. The draft and the payload are now separate values: `draftText` is what the user typed and is what `clearDraftForSend` / `restoreRejectedDraft` see; only the transported `text` is trimmed. 2. Sends issued during hydration were stranded forever. #14665 persisted `glueBaselineTrusted: false` on any send captured while the transcript was still loading and never cleared it, so that send could never retire and stood as a permanent glue barrier for its neighbours. A hydration-time baseline is now a placeholder (`baselineResolved: false`) that the first authoritative read rebases onto real rows, ordinals included, instead of a permanent disqualification. That is STA-4492. The intended behavior is unchanged: one transcript user turn retires a run of 2+ adjacent text-only pending sends only when it exactly spells their normalized concatenation, every send is bounded by its OWN transcript tail, and exact landings, image echoes and unresolved tails stay barriers. No wire change: `baselineResolved` and the baseline tail are client-local React state in `pendingBySession` and are never exchanged with a host. The only client->host difference is trailing whitespace no longer being written onto the agent's input line, over the existing `terminal.send` params. Refs STA-4482, STA-4492. Original PR #14665, revert #14819. * fix(mobile-native-chat): let the untrimmed draft reach the send seam The composer sent `value.trimEnd()`, so the raw draft never reached `sendMessage` and a rejected send still handed back a trimmed composer — the split of `draftText` from the transported `text` had nothing to restore. Pass the draft through; the seam already owns the wire trim. Also pins the array-identity contract of `retireLandedMobileNativeChatPending`: the drafts effect early-outs on `next === current`, and nothing tested it. * docs(mobile-native-chat): name the hydration rebase's residual ambiguity * fix(mobile-native-chat): stop the hydration rebase stranding a send on its own echo Rebasing recounted the send's ordinal against the first authoritative read. That read can already carry the send's own echo — a re-subscribe after a tab switch or reconnect returns whatever exists now — so the ordinal landed one past anything the transcript could supply. The bubble never cleared, it stayed a live segment at the head of its run so no later pair could glue either, and `earlierOutstanding` carried the inflation onto the next send of the same text. Only the tail needs recovering; the ordinal was already counted against an empty transcript, which is right for "no history was known". A caption-less image echo keeps its captured tail, since it counts turns after it. `baselineResolved` also has to mean "captured against a settled read", not merely "not loading": a read that failed hands back an empty list that reads as an empty conversation, and the null tail then let any row the successful read finally brought glue-retire those sends. * test(mobile-native-chat): pin that a resolved hydration send leaves its run glue-capable A held send sits as a live segment at the head of its run, so the cursor can never reach a later pair — the stuck bubble takes the whole feature down with it. Goes red against the ordinal recount. * fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag A caption-less image echo keeps its captured tail because it counts image turns after it — but a send issued before any history was known captured null, which counts from the top of the transcript. An old image turn then claimed the send and bound the user's fresh photo to it, leaving the just-sent turn with no preview. A null tail is not a boundary worth preserving, so pin those too. `transcriptSettled` was optional and defaulted to the gate it replaced, so any caller that omitted it silently got the pre-fix behaviour. Required now, and threaded through every harness. * fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read The image-preview pass runs before the rebase, so a send captured with no boundary matched any image turn the settled read carried — binding the user's freshly attached photo to an old one and retiring the bubble through landedImagePendingIds, which short-circuits the retirement path entirely. Pinning the tail in the rebase could not help: the claim was already made. Such an entry now waits one tick and claims against a real tail. * fix(mobile-native-chat): never move a boundary the send already captured An unsettled read still shows this session's own retained history — a reconnect or a failed read keeps the conversation on screen rather than blanking it — so sends made across one already own a correct tail. The rebase overwrote it with the tail of the read that followed, which sits at or after their own glued row, so `turn.index <= segment.tail` rejected every turn and the pair stayed queued for the session, blocking every later pair in the run. Pin only a send that captured no tail at all. A captioned image echo is now left alone entirely: it binds its preview by an ordinal counted over the whole transcript, so supplying a tail without recounting left it matching nothing, forever. * fix(mobile-native-chat): supply a boundary only to a text-bearing send An image echo reconciles by counting turns AFTER its tail and has no other retirement path, so the tail supplied from a read that already carried its own echo excluded the very row it was waiting for: the "Queued" photo bubble stuck for the life of the session and the transcript row rendered as bare marker text with no photo. A regression against main, and against the earlier revision of this fix that pinned only captioned echoes. The glue matcher is the only consumer a supplied tail helps. Everything that reconciles relative to its own tail keeps whatever it captured. * fix(mobile-native-chat): stop one unmatchable send freezing glue for the session The match cursor only advanced on a hit, so a head that could never match — a pair whose glued row arrived with the read, or a send the count pass claimed against an older row — froze the run behind it and every later rapid pair became permanently unretirable. Two cases previously disclosed as bounded were not bounded at all. Slide past a non-matching head, keeping the cursor monotonic so a later turn can never take a send an earlier one claimed. The slide widens the search, so a span cap keeps the work linear in the run length instead of quadratic; the existing budget test now asserts that bound rather than the old one it silently broke. Re-fuzzed at 250k seeds: the boundary guarantee still holds. Also corrects a comment that claimed the preview-pass filter made a photo claim against a real tail. It does not — an image echo keeps whatever tail it captured, so a caption-less photo can still bind to an older photo turn, as on main. * fix(mobile-native-chat): stop the span cap stranding a long glued run Capping each match attempt at 8 segments did not truncate a longer glue, it rejected it outright: a row spelling 9+ sends exhausted the loop without reaching the end of the text and returned zero, so none of the nine retired — and each stuck send then inflated `earlierOutstanding` for the next send of the same text. Nothing bounds how many sends pile onto the agent's input line; accumulation ends when the agent accepts input again, not at any fixed count. One inspection budget now covers the whole slide instead. The first attempt spans the entire run and always fits, so a genuine glue is never truncated; only a run of identical prefix-matching sends can exhaust the budget, which is exactly the case that should be cheap. The in-flight attempt may overshoot the remainder — that is what makes the guarantee hold — so the budget test asserts the real ceiling. Re-fuzzed at 250k seeds with runs past the budget.
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import { createElement } from 'react'
|
||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||
import { radii, spacing } from '../theme/mobile-theme'
|
||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||
|
||
vi.mock('react-native', async () => {
|
||
const React = await import('react')
|
||
return {
|
||
ActivityIndicator: 'ActivityIndicator',
|
||
Image: 'Image',
|
||
Keyboard: { dismiss: vi.fn() },
|
||
Pressable: 'Pressable',
|
||
ScrollView: ({ children, ...props }: { children?: unknown }) =>
|
||
React.createElement('ScrollView', props, children),
|
||
StyleSheet: {
|
||
create: (styles: unknown) => styles,
|
||
hairlineWidth: 1
|
||
},
|
||
Text: 'Text',
|
||
TextInput: 'TextInput',
|
||
View: 'View'
|
||
}
|
||
})
|
||
|
||
vi.mock('lucide-react-native', () => ({
|
||
ArrowUp: 'ArrowUp',
|
||
Check: 'Check',
|
||
ChevronDown: 'ChevronDown',
|
||
ChevronLeft: 'ChevronLeft',
|
||
ChevronRight: 'ChevronRight',
|
||
ImagePlus: 'ImagePlus',
|
||
Mic: 'Mic',
|
||
Square: 'Square',
|
||
X: 'X'
|
||
}))
|
||
|
||
vi.mock('../components/BottomDrawer', async () => {
|
||
const React = await import('react')
|
||
return {
|
||
BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) =>
|
||
visible ? React.createElement('BottomDrawer', { visible }, children) : null
|
||
}
|
||
})
|
||
|
||
describe('MobileNativeChatComposer', () => {
|
||
let renderer: ReactTestRenderer | null = null
|
||
|
||
afterEach(() => {
|
||
act(() => renderer?.unmount())
|
||
renderer = null
|
||
})
|
||
|
||
async function render(
|
||
onSend: (text: string) => Promise<boolean>,
|
||
onChangeText: () => void,
|
||
isAttaching = false
|
||
) {
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: ' hello ',
|
||
onChangeText,
|
||
onSend,
|
||
isAttaching
|
||
})
|
||
)
|
||
})
|
||
}
|
||
|
||
function sendButton(): { props: { onPress: () => Promise<void> } } {
|
||
if (!renderer) {
|
||
throw new Error('Composer was not rendered')
|
||
}
|
||
return renderer.root.find(
|
||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message'
|
||
) as { props: { onPress: () => Promise<void> } }
|
||
}
|
||
|
||
it('reports an accepted send without owning route-scoped draft cleanup', async () => {
|
||
const onChangeText = vi.fn()
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await render(onSend, onChangeText)
|
||
|
||
await act(async () => sendButton().props.onPress())
|
||
|
||
// Verbatim: the send seam trims for the wire, so a rejected send can hand
|
||
// back the draft byte-for-byte (#14819).
|
||
expect(onSend).toHaveBeenCalledWith(' hello ')
|
||
expect(onChangeText).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('stacks the input above the composer action row', async () => {
|
||
await render(vi.fn().mockResolvedValue(true), vi.fn())
|
||
|
||
const composer = renderer!.root.findByProps({ testID: 'native-chat-composer' })
|
||
const inset = renderer!.root.findByProps({ testID: 'native-chat-composer-inset' })
|
||
const actions = renderer!.root.findByProps({ testID: 'native-chat-composer-actions' })
|
||
expect(composer.findAllByType('TextInput')).toHaveLength(1)
|
||
expect(composer.children[1]).toBe(actions)
|
||
expect(inset.props.style).toMatchObject({
|
||
paddingHorizontal: spacing.md,
|
||
paddingTop: spacing.sm,
|
||
paddingBottom: spacing.md
|
||
})
|
||
expect(composer.props.style).toMatchObject({
|
||
borderWidth: 1,
|
||
borderRadius: radii.card,
|
||
overflow: 'hidden'
|
||
})
|
||
})
|
||
|
||
it('preserves leading whitespace so prose is not turned into a slash command', async () => {
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: ' /clear is prose ',
|
||
onChangeText: vi.fn(),
|
||
onSend
|
||
})
|
||
)
|
||
})
|
||
await act(async () => sendButton().props.onPress())
|
||
expect(onSend).toHaveBeenCalledWith(' /clear is prose ')
|
||
})
|
||
|
||
it('locks the option pickers while a composer send is in flight', async () => {
|
||
// The reverse of the test below. The host spaces a send's body and its Enter
|
||
// ~500ms apart, so an apply tapped inside that window would be submitted as
|
||
// part of the user's prompt instead of running as its own command.
|
||
let releaseSend: ((accepted: boolean) => void) | undefined
|
||
const onSend = vi.fn(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
releaseSend = resolve
|
||
})
|
||
)
|
||
const controller = {
|
||
snapshot: [
|
||
{
|
||
id: 'model',
|
||
label: 'Model',
|
||
category: 'model' as const,
|
||
kind: {
|
||
type: 'select' as const,
|
||
choices: [
|
||
{ value: 'sonnet', label: 'Sonnet 5' },
|
||
{ value: 'opus', label: 'Opus 4.8' }
|
||
]
|
||
},
|
||
valueSource: 'unknown' as const,
|
||
settable: true
|
||
}
|
||
],
|
||
pendingId: null,
|
||
setOption: vi.fn(),
|
||
invokeAction: vi.fn(),
|
||
recordCommand: vi.fn()
|
||
}
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: 'run the tests',
|
||
onChangeText: vi.fn(),
|
||
onSend,
|
||
sessionOptions: { isWorking: false, controller }
|
||
})
|
||
)
|
||
})
|
||
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
|
||
renderer!.root.find(
|
||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
|
||
) as { props: { accessibilityState: { disabled: boolean } } }
|
||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||
// Start the send but don't await it — it stays in flight on purpose.
|
||
let pressed!: Promise<void>
|
||
await act(async () => {
|
||
pressed = sendButton().props.onPress()
|
||
await Promise.resolve()
|
||
})
|
||
expect(onSend).toHaveBeenCalled()
|
||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
|
||
await act(async () => {
|
||
releaseSend?.(true)
|
||
await pressed
|
||
})
|
||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||
})
|
||
|
||
it('blocks composer submission while a session-option command is pending', async () => {
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: 'hello',
|
||
onChangeText: vi.fn(),
|
||
onSend,
|
||
sessionOptions: {
|
||
isWorking: false,
|
||
controller: {
|
||
snapshot: [],
|
||
pendingId: 'model',
|
||
setOption: vi.fn(),
|
||
invokeAction: vi.fn(),
|
||
recordCommand: vi.fn()
|
||
}
|
||
}
|
||
})
|
||
)
|
||
})
|
||
expect(sendButton().props).toMatchObject({ disabled: true })
|
||
await act(async () => sendButton().props.onPress())
|
||
expect(onSend).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps the draft when the send is rejected', async () => {
|
||
const onChangeText = vi.fn()
|
||
const onSend = vi.fn().mockResolvedValue(false)
|
||
await render(onSend, onChangeText)
|
||
|
||
await act(async () => sendButton().props.onPress())
|
||
|
||
expect(onSend).toHaveBeenCalledWith(' hello ')
|
||
expect(onChangeText).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('disables send while an attachment path is still being injected', async () => {
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await render(onSend, vi.fn(), true)
|
||
|
||
expect(sendButton().props).toMatchObject({ disabled: true })
|
||
await act(async () => sendButton().props.onPress())
|
||
expect(onSend).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps the text input editable while the send is locked', async () => {
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: 'half-typed',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
disabled: 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(sendButton().props).toMatchObject({ disabled: true })
|
||
})
|
||
|
||
it('renders a removable thumbnail for each pending image attachment', async () => {
|
||
const onRemoveAttachment = vi.fn()
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
attachments: [
|
||
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
|
||
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
|
||
],
|
||
onRemoveAttachment
|
||
})
|
||
)
|
||
})
|
||
const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{
|
||
props: { source: { uri: string } }
|
||
}>
|
||
expect(thumbs.map((t) => t.props.source.uri)).toEqual(['file:///a.png', 'file:///b.png'])
|
||
|
||
const remove = renderer!.root.findAll(
|
||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Remove image'
|
||
) as Array<{ props: { onPress: () => void } }>
|
||
remove[1].props.onPress()
|
||
expect(onRemoveAttachment).toHaveBeenCalledWith('img-2')
|
||
})
|
||
|
||
it('enables send with an attached image even when the text is empty', async () => {
|
||
const onSend = vi.fn().mockResolvedValue(true)
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '',
|
||
onChangeText: vi.fn(),
|
||
onSend,
|
||
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
|
||
})
|
||
)
|
||
})
|
||
expect(sendButton().props).toMatchObject({ disabled: false })
|
||
await act(async () => sendButton().props.onPress())
|
||
expect(onSend).toHaveBeenCalledWith('')
|
||
})
|
||
|
||
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
|
||
const onChangeText = vi.fn()
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '/c',
|
||
onChangeText,
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
agent: 'claude'
|
||
})
|
||
)
|
||
})
|
||
const input = () =>
|
||
renderer!.root.find((node) => node.type === 'TextInput') as {
|
||
props: {
|
||
selection?: { start: number; end: number }
|
||
onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void
|
||
}
|
||
}
|
||
// Uncontrolled selection until a suggestion is applied.
|
||
expect(input().props.selection).toBeUndefined()
|
||
// Place the caret at the end so the slash trigger is active and suggestions render.
|
||
await act(async () =>
|
||
input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } })
|
||
)
|
||
const firstSuggestion = renderer!.root.findAll(
|
||
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
|
||
)[0] as { props: { onPress: () => void } }
|
||
await act(async () => firstSuggestion.props.onPress())
|
||
expect(onChangeText).toHaveBeenCalledWith('/clear ')
|
||
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
|
||
expect(input().props.selection).toEqual({ start: 7, end: 7 })
|
||
// The next native selection event releases control so manual placement still works.
|
||
await act(async () =>
|
||
input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } })
|
||
)
|
||
expect(input().props.selection).toBeUndefined()
|
||
})
|
||
|
||
it('serves the active agent’s shared command catalog with descriptions', async () => {
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '/',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
agent: 'codex'
|
||
})
|
||
)
|
||
})
|
||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||
props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void }
|
||
}
|
||
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 1 } } }))
|
||
const texts = renderer!.root
|
||
.findAll((node) => node.type === 'Text')
|
||
.map((node) => (node.props as { children?: unknown }).children)
|
||
// Codex-only commands from the shared catalog, with their description rows —
|
||
// and none of the old hardcoded provider-agnostic list's phantom entries.
|
||
expect(texts).toContain('/permissions')
|
||
expect(texts).toContain('Choose what Codex is allowed to do')
|
||
expect(texts).not.toContain('/cost')
|
||
})
|
||
|
||
it('wires the mic for hold vs toggle dictation like the terminal composer', async () => {
|
||
const onMicPress = vi.fn()
|
||
const onMicPressIn = vi.fn()
|
||
const onMicPressOut = vi.fn()
|
||
const mic = () =>
|
||
renderer!.root.find(
|
||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate'
|
||
) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } }
|
||
|
||
await act(async () => {
|
||
renderer = create(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
onMicPress,
|
||
dictationMode: 'hold',
|
||
onMicPressIn,
|
||
onMicPressOut
|
||
})
|
||
)
|
||
})
|
||
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
|
||
expect(mic().props.onPress).toBeUndefined()
|
||
expect(mic().props.onPressIn).toBe(onMicPressIn)
|
||
expect(mic().props.onPressOut).toBe(onMicPressOut)
|
||
|
||
await act(async () => {
|
||
renderer!.update(
|
||
createElement(MobileNativeChatComposer, {
|
||
value: '',
|
||
onChangeText: vi.fn(),
|
||
onSend: vi.fn().mockResolvedValue(true),
|
||
onMicPress,
|
||
dictationMode: 'toggle',
|
||
onMicPressIn,
|
||
onMicPressOut
|
||
})
|
||
)
|
||
})
|
||
// Toggle mode: tap drives dictation, press-in/out inert.
|
||
expect(mic().props.onPress).toBe(onMicPress)
|
||
expect(mic().props.onPressIn).toBeUndefined()
|
||
expect(mic().props.onPressOut).toBeUndefined()
|
||
})
|
||
})
|