mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
249 lines
9.1 KiB
TypeScript
249 lines
9.1 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' }))
|
|
vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => null }))
|
|
vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' }))
|
|
vi.mock('./MobileNativeChatBackgroundTasks', () => ({
|
|
MobileNativeChatBackgroundTasks: 'BackgroundTasks'
|
|
}))
|
|
vi.mock('./MobileNativeChatVisual', () => ({ useMobileNativeChatVisualRenderer: () => null }))
|
|
vi.mock('./MobileNativeChatRowOverlaysHost', () => ({
|
|
MobileNativeChatRowOverlaysHost: 'View'
|
|
}))
|
|
|
|
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
|
|
/** The host says a person's Stop is ending the turn. */
|
|
stopping?: boolean
|
|
acceptsImages?: boolean
|
|
}
|
|
|
|
function overlayElement(tick: Tick): ReturnType<typeof createElement> {
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members; the rest of the controller is unreachable from it.
|
|
const controller = {
|
|
showNativeChat: tick.show ?? true,
|
|
nativeChatSession: { messages: tick.messages ?? [], status: 'ready' },
|
|
nativeChatAgent: 'claude',
|
|
nativeChatAcceptsImages: tick.acceptsImages ?? true,
|
|
nativeChatAgentWorking: tick.streamLive ?? false,
|
|
nativeChatTurnIndicator: {
|
|
thinking: false,
|
|
activityText: null,
|
|
stopping: tick.stopping ?? false
|
|
},
|
|
nativeChatStreamingText: tick.streamingText,
|
|
nativeChatStreamLive: tick.streamLive ?? false,
|
|
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
|
|
chatPending: [],
|
|
chatImagePreviewsByMessageId: {},
|
|
chatComposerText: '',
|
|
setChatComposerText: vi.fn(),
|
|
nativeChatQueued: {
|
|
cards: [],
|
|
send: vi.fn(),
|
|
delete: vi.fn(),
|
|
edit: vi.fn(),
|
|
pause: null,
|
|
resume: vi.fn(),
|
|
sessionKey: 'session-a'
|
|
}
|
|
} 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',
|
|
sessionId: null,
|
|
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()
|
|
})
|
|
})
|
|
|
|
describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
async function steerHeld(tick: Tick): Promise<unknown> {
|
|
await act(async () => {
|
|
renderer = create(overlayElement(tick))
|
|
})
|
|
const view = renderer!.root.find((node) => node.type === 'ChatView')
|
|
// The tray's second child is the queued box; the first is the child-work strip.
|
|
return view.props.composerTray?.content?.props?.children?.[1]?.props?.steerHeld
|
|
}
|
|
|
|
it("holds the queued cards' Steer while the agent works and the host says Stopping", async () => {
|
|
expect(await steerHeld({ streamLive: true, stopping: true })).toBe(true)
|
|
})
|
|
|
|
it('leaves Steer to the cards otherwise', async () => {
|
|
expect(await steerHeld({ streamLive: true, stopping: false })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('MobileNativeChatOverlay attach button', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
it.each([true, false])('offers attach only when the agent takes images (%s)', async (accepts) => {
|
|
await act(async () => {
|
|
renderer = create(overlayElement({ acceptsImages: accepts }))
|
|
})
|
|
const view = renderer!.root.find((node) => node.type === 'ChatView')
|
|
expect(typeof view.props.onAttachImage === 'function').toBe(accepts)
|
|
})
|
|
})
|