mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* fix(mobile): wall hosts that cannot serve the page instead of opening native screens * refactor(mobile): drop the native-route state and the shell fallback prop; unknown routes say unavailable * test(mobile): read the nullable page route refusal in its policy test * refactor(mobile): every build is the OTA shell; drop the native/OTA build switch The owner retired the native-screen fallback with no emergency native build, so EXPO_PUBLIC_MOBILE_SHELL, mobileShellBuildKind, shellSwitchDecision, the Metro cache-key suffix and both release workflows' shell input go. Every host route mounts the shell; a route naming no screen the shell could open gets the existing refusal screen instead of a native screen. Claude-Session: https://claude.ai/code/session_6cea92f6 * refactor(mobile): delete native screens the OTA shell no longer reaches With every build the OTA shell, these native siblings run nowhere: the native-executed closure (index.js plus every non-web route) does not reach them, and the page resolves their .web siblings. Each .web sibling takes the plain name, its web-overrides entry goes, and the native-only rich Markdown editor and Mermaid WebView generators and their outputs go with them. Kept: native siblings the node test suite still renders shared screens through (terminal WebView, browser frame path, clipboard, dictation, media picker, live input, snapshot budget, input styles). Claude-Session: https://claude.ai/code/session_6cea92f6 * test(mobile): drop the phone-preview comparison the deleted native sibling held Claude-Session: https://claude.ai/code/session_6cea92f6 * refactor(mobile): drop the dead web route, flag-era comments and the one-value shell kind The host route files no longer switch, so their comments say what they do now: a route naming no screen shows the unavailable screen. app/h/[hostId]/web.tsx duplicated index.tsx and nothing linked to it, so it and its stack entry go (the bundle sweep is re-measured: 77 scripts at 15 routes). Diagnostics print the bridge protocol on its own instead of a shell kind that has one value. Claude-Session: https://claude.ai/code/session_6cea92f6 * refactor(mobile): one host for the rich editor, plain Text, and drop two unused native deps MobileSelectableText was react-native's Text, so callers use Text. The rich Markdown editor's host-facing seams (messages, URL prompt, keyboard inset) are required now that the page is its only host; the WebView defaults go. Comments that described deleted native components are rewritten, and expo-image-manipulator and react-native-uitextview leave package.json and the lockfile. Claude-Session: https://claude.ai/code/session_6cea92f6
179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
import { createElement } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
|
import {
|
|
createNativeChatReplyRevealTracker,
|
|
trackNativeChatReplyRows
|
|
} from '../../../src/shared/native-chat-reply-reveals'
|
|
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS } from '../../../src/shared/native-chat-text-reveal'
|
|
import {
|
|
MobileNativeChatReplyRevealsContext,
|
|
type MobileNativeChatReplyReveals
|
|
} from './mobile-native-chat-reply-reveals'
|
|
|
|
vi.mock('react-native', () => ({
|
|
Image: 'Image',
|
|
Platform: { OS: 'ios' },
|
|
Pressable: 'Pressable',
|
|
Text: 'Text',
|
|
View: 'View',
|
|
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }
|
|
}))
|
|
vi.mock('lucide-react-native', () => ({ Copy: 'Copy', Check: 'Check' }))
|
|
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
|
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText: vi.fn() }) }))
|
|
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
|
|
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
|
|
}))
|
|
vi.mock('./MobileNativeChatReasoningRow', () => ({ MobileNativeChatReasoningRow: 'Reasoning' }))
|
|
vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnStatus: 'TurnStatus' }))
|
|
vi.mock('./MobileNativeChatToolRun', () => ({ ToolRun: 'ToolRun' }))
|
|
|
|
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
|
|
|
const DELAY = NATIVE_CHAT_TEXT_REVEAL_DELAY_MS
|
|
const first = 'word '.repeat(40)
|
|
const second = first + 'more '.repeat(40)
|
|
|
|
describe('a growing reply on the phone', () => {
|
|
let renderer: ReactTestRenderer | undefined
|
|
let chat: MobileNativeChatReplyReveals
|
|
|
|
function render(text: string | string[], working = true, runningTool = false): void {
|
|
const message: NativeChatMessage = {
|
|
id: 'a1',
|
|
role: 'assistant',
|
|
source: 'transcript',
|
|
timestamp: null,
|
|
blocks: (typeof text === 'string' ? [text] : text).map((part) => ({
|
|
type: 'text',
|
|
text: part
|
|
}))
|
|
}
|
|
if (runningTool) {
|
|
message.blocks.push({ type: 'tool-call', name: 'Read', input: {}, state: 'running' })
|
|
}
|
|
const row = createElement(MobileNativeChatMessage, {
|
|
message,
|
|
mayStillGrow: working,
|
|
activeTurnIsWorking: true
|
|
})
|
|
const tree = createElement(MobileNativeChatReplyRevealsContext.Provider, { value: chat }, row)
|
|
act(() => {
|
|
if (renderer) {
|
|
renderer.update(tree)
|
|
} else {
|
|
renderer = create(tree)
|
|
}
|
|
})
|
|
}
|
|
function contents(): string[] {
|
|
return renderer!.root
|
|
.findAll((node) => String(node.type) === 'MobileMarkdown')
|
|
.map((node) => String(node.props.content))
|
|
}
|
|
function drawn(): number {
|
|
return contents().join('').length
|
|
}
|
|
function advance(ms: number): void {
|
|
act(() => {
|
|
vi.advanceTimersByTime(ms)
|
|
})
|
|
}
|
|
function remount(text: string | string[]): void {
|
|
act(() => renderer?.unmount())
|
|
renderer = undefined
|
|
render(text)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
|
vi.spyOn(performance, 'now').mockImplementation(() => Date.now())
|
|
vi.stubGlobal('requestAnimationFrame', (step: () => void) => setTimeout(step, 16))
|
|
vi.stubGlobal('cancelAnimationFrame', (frame: number) => clearTimeout(frame))
|
|
// A reply that began while the chat was open.
|
|
const tracker = createNativeChatReplyRevealTracker()
|
|
trackNativeChatReplyRows(tracker, ['u1'], new Set(['u1']))
|
|
const reveals = trackNativeChatReplyRows(tracker, ['u1', 'a1'], new Set(['u1', 'a1']))
|
|
chat = { ...reveals, reducedMotion: false }
|
|
})
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = undefined
|
|
vi.unstubAllGlobals()
|
|
vi.restoreAllMocks()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('reveals each chunk over time, and a remounted row resumes instead of replaying', () => {
|
|
render(first)
|
|
expect(drawn()).toBe('word'.length)
|
|
advance(DELAY / 2)
|
|
expect(drawn()).toBeGreaterThan('word'.length)
|
|
expect(drawn()).toBeLessThan(first.length)
|
|
advance(DELAY)
|
|
expect(drawn()).toBe(first.length)
|
|
|
|
render(second)
|
|
advance(DELAY / 2)
|
|
const before = drawn()
|
|
expect(before).toBeGreaterThan(first.length)
|
|
expect(before).toBeLessThan(second.length)
|
|
// The list recycles the row while it is still revealing.
|
|
remount(second)
|
|
expect(drawn()).toBeGreaterThanOrEqual(before)
|
|
expect(drawn()).toBeLessThan(second.length)
|
|
advance(DELAY)
|
|
expect(drawn()).toBe(second.length)
|
|
})
|
|
|
|
it.each([false, true])('paces a second prose block after the first caught up: %s', (caughtUp) => {
|
|
render(first)
|
|
advance(caughtUp ? DELAY : DELAY / 2)
|
|
render([first, second])
|
|
if (!caughtUp) {
|
|
expect(contents()[0].length).toBeLessThan(first.length)
|
|
}
|
|
expect(contents()[1].length).toBeLessThan(second.length)
|
|
advance(DELAY / 2)
|
|
const before = drawn()
|
|
expect(before).toBeGreaterThan(first.length)
|
|
expect(before).toBeLessThan(first.length + second.length)
|
|
remount([first, second])
|
|
expect(drawn()).toBeGreaterThanOrEqual(before)
|
|
expect(drawn()).toBeLessThan(first.length + second.length)
|
|
advance(DELAY)
|
|
expect(contents()).toEqual([first, second])
|
|
})
|
|
|
|
it('does not replay read text when a question ends while the row is unmounted', () => {
|
|
render(first)
|
|
advance(DELAY)
|
|
render(first, false)
|
|
expect(chat.drawn.has('a1')).toBe(false)
|
|
act(() => renderer?.unmount())
|
|
renderer = undefined
|
|
// The question clears while the row remains outside the virtualized window.
|
|
render(first)
|
|
expect(drawn()).toBe(first.length)
|
|
render(second)
|
|
expect(drawn()).toBeLessThan(second.length)
|
|
advance(DELAY)
|
|
expect(drawn()).toBe(second.length)
|
|
})
|
|
|
|
it('draws completed prose in full on first mount beside a running tool', () => {
|
|
render(first, true, true)
|
|
expect(contents()).toEqual([first])
|
|
const tool = renderer!.root.find((node) => String(node.type) === 'ToolRun')
|
|
expect(tool.props.blocks[0].state).toBe('running')
|
|
})
|
|
|
|
it('draws the text as it arrives with reduced motion', () => {
|
|
chat = { ...chat, reducedMotion: true }
|
|
render(second)
|
|
expect(drawn()).toBe(second.length)
|
|
})
|
|
})
|