mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
perf(mobile): keep the chat mounted behind its terminal (#39003)
* perf(mobile): keep the chat mounted behind its terminal * test(mobile): assert only what the hidden chat shows * refactor(mobile): skip the hidden chat by element identity * test(mobile): find the chat view without a cast * test(mobile): typecheck the overlay toggle test * fix(mobile): release a chat's scroll hold when it shows again * test(mobile): typecheck the hidden-list reading-place case
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { createElement, useState, type ReactNode } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types'
|
||||
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
|
||||
import { findByHostType } from '../../test-support/react-test-renderer-host-nodes'
|
||||
|
||||
vi.mock('react-native', async () => ({
|
||||
...(await import('../../test-support/react-native-motion-stub')).reactNativeMotionStub(),
|
||||
Platform: { OS: 'web' },
|
||||
StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} },
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('react-native-gesture-handler', () => ({
|
||||
Gesture: {},
|
||||
GestureDetector: 'GestureDetector'
|
||||
}))
|
||||
vi.mock('./composer/use-mobile-native-chat-composer-tray', () => ({
|
||||
useMobileNativeChatComposerTray: () => null
|
||||
}))
|
||||
vi.mock('./transcript/MobileNativeChatRowOverlaysHost', () => ({
|
||||
MobileNativeChatRowOverlaysHost: ({ children }: { children: ReactNode }) => children
|
||||
}))
|
||||
// Stands in for the chat list: its state lives exactly as long as the mounted view.
|
||||
vi.mock('./MobileNativeChatView', () => ({
|
||||
MobileNativeChatView: ({ messages }: { messages: NativeChatMessage[] }) => {
|
||||
const [mountId] = useState(() => Symbol('chat view'))
|
||||
return createElement('ChatView', { mountId, ids: messages.map((message) => message.id) })
|
||||
}
|
||||
}))
|
||||
|
||||
const message = (id: string): NativeChatMessage => ({
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: `reply ${id}` }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
})
|
||||
|
||||
function overlay(showNativeChat: boolean, messages: NativeChatMessage[]) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members here.
|
||||
const controller = {
|
||||
showNativeChat,
|
||||
nativeChatSession: { messages, status: 'ready' },
|
||||
nativeChatAgent: 'claude',
|
||||
nativeChatStructured: false,
|
||||
nativeChatTurnActive: false,
|
||||
nativeChatItemSource: null,
|
||||
nativeChatVisualSource: null,
|
||||
chatPending: [],
|
||||
nativeChatQueued: { cards: [], pause: null, sessionKey: 'session' }
|
||||
} as unknown as MobileNativeChatController
|
||||
return createElement(MobileNativeChatOverlay, {
|
||||
controller,
|
||||
worktreePath: null,
|
||||
connState: 'connected',
|
||||
onOpenFile: () => {},
|
||||
images: {
|
||||
attachments: [],
|
||||
isAttaching: false,
|
||||
attachImage: async () => {},
|
||||
removeAttachment: () => {},
|
||||
sendNativeChat: async () => true
|
||||
},
|
||||
onMicPress: undefined,
|
||||
micActive: false,
|
||||
micStarting: false,
|
||||
micProcessing: false,
|
||||
dictationMode: undefined,
|
||||
onMicPressIn: () => {},
|
||||
onMicPressOut: () => {},
|
||||
inputLockReason: null,
|
||||
sendErrorMessage: null,
|
||||
onClearSendError: () => {},
|
||||
sendSurfaceId: 'host\0worktree\0tab',
|
||||
sessionId: 'session',
|
||||
getSendCompletionGeneration: () => 0,
|
||||
keyboardInset: 0,
|
||||
hostName: null,
|
||||
onRunInTerminal: () => {}
|
||||
})
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const chatView = () => findByHostType(renderer!.root, 'ChatView').props
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('keeps the chat view mounted and hidden while its terminal shows', () => {
|
||||
const rows = [message('a'), message('b')]
|
||||
act(() => {
|
||||
renderer = create(overlay(true, rows))
|
||||
})
|
||||
const shown = chatView()
|
||||
expect(shown.ids).toEqual(['a', 'b'])
|
||||
|
||||
// The hidden lane reads as an empty chat; nothing is drawn over the terminal.
|
||||
act(() => renderer!.update(overlay(false, [])))
|
||||
expect(renderer!.toJSON()).toBeNull()
|
||||
|
||||
act(() => renderer!.update(overlay(true, [...rows, message('c')])))
|
||||
const back = chatView()
|
||||
expect(back.mountId).toBe(shown.mountId)
|
||||
expect(back.ids).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MobileQueuedMessageComposerContext } from '../mobile-queued-message-composer-context'
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { Activity, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
|
||||
import { useMobileNativeChatProjection } from './transcript/use-mobile-native-chat-projection'
|
||||
@@ -63,8 +63,27 @@ type Props = {
|
||||
hostOutage?: MobileNativeChatHostOutage | null
|
||||
}
|
||||
|
||||
// Keep the terminal subscription and streaming gate above view toggles; the chat list unmounts below this boundary.
|
||||
export function MobileNativeChatOverlay({
|
||||
// Hide the last chat behind its terminal instead of unmounting it, so returning keeps its rows and parsed markdown.
|
||||
export function MobileNativeChatOverlay(props: Props): React.JSX.Element | null {
|
||||
const shown = props.controller.showNativeChat
|
||||
// While hidden, the live lane is starved to an empty chat; draw the last shown props so the hidden tree does no work.
|
||||
const [lastShown, setLastShown] = useState<Props | null>(null)
|
||||
if (shown && lastShown !== props) {
|
||||
setLastShown(props)
|
||||
}
|
||||
const drawn = shown ? props : lastShown
|
||||
// The same element while hidden lets React skip the hidden tree.
|
||||
const content = useMemo(
|
||||
() => (drawn ? <MobileNativeChatOverlayContent {...drawn} /> : null),
|
||||
[drawn]
|
||||
)
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
return <Activity mode={shown ? 'visible' : 'hidden'}>{content}</Activity>
|
||||
}
|
||||
|
||||
function MobileNativeChatOverlayContent({
|
||||
controller,
|
||||
imageSource,
|
||||
worktreePath,
|
||||
@@ -92,7 +111,7 @@ export function MobileNativeChatOverlay({
|
||||
onRunInTerminal,
|
||||
hostOutage = null,
|
||||
onOpenAccounts
|
||||
}: Props): React.JSX.Element | null {
|
||||
}: Props): React.JSX.Element {
|
||||
const session = controller.nativeChatSession
|
||||
const agent = controller.nativeChatAgent ?? ''
|
||||
const fullContent = useMobileNativeChatMessageDetail(
|
||||
@@ -237,9 +256,6 @@ export function MobileNativeChatOverlay({
|
||||
isWorking: controller.nativeChatTurnActive
|
||||
})
|
||||
const continueFailed = sendErrorMessage === null && continuation?.failed === true
|
||||
if (!controller.showNativeChat) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<MobileNativeChatRowOverlaysHost
|
||||
conversation={{ surfaceId: sendSurfaceId, sessionId }}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createElement as h, Fragment, useMemo, type ReactElement, type ReactNode } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
createElement as h,
|
||||
Fragment,
|
||||
useMemo,
|
||||
type ReactElement,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import {
|
||||
act,
|
||||
create,
|
||||
@@ -326,11 +333,19 @@ function mount(
|
||||
})
|
||||
const scroller = page(() => renderer.toJSON())
|
||||
scrollable.node = scroller
|
||||
let mode: 'visible' | 'hidden' = 'visible'
|
||||
let last: { messages: NativeChatMessage[]; joined: number } = { messages, joined: 0 }
|
||||
const show = (next: NativeChatMessage[], joined = 0): number => {
|
||||
const before = scroller.writes
|
||||
act(() => renderer.update(h(Chat, { ...options, messages: next, joined })))
|
||||
last = { messages: next, joined }
|
||||
act(() => renderer.update(<Activity mode={mode}>{h(Chat, { ...options, ...last })}</Activity>))
|
||||
return scroller.writes - before
|
||||
}
|
||||
// Hide or show the list the way the chat overlay does behind its terminal.
|
||||
const display = (next: 'visible' | 'hidden') => {
|
||||
mode = next
|
||||
show(last.messages, last.joined)
|
||||
}
|
||||
vi.useFakeTimers({ now: 10_000, toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
const scrolled = () =>
|
||||
act(() =>
|
||||
@@ -354,7 +369,7 @@ function mount(
|
||||
renderer.root
|
||||
.findAll((n) => n.type === 'div' && String(n.props.testID).startsWith('chat-row:'))
|
||||
.map((n) => n.props.testID)
|
||||
return { scroller, show, press, drawn, lines, find, rows, scrolled, rest, read }
|
||||
return { scroller, show, display, press, drawn, lines, find, rows, scrolled, rest, read }
|
||||
}
|
||||
|
||||
it('keeps the line a reader was on, its run open, when older messages join the run from an old host', () => {
|
||||
@@ -418,6 +433,18 @@ it('holds back a change below a scrolling reader until they rest, then keeps the
|
||||
expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(line)
|
||||
})
|
||||
|
||||
it('releases a held change once a chat hidden mid-scroll is shown again and the reader rests', () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => say(`m${i}`, i % 2 ? 'assistant' : 'user', 'x'))
|
||||
const chat0 = mount(rows)
|
||||
chat0.scroller.showFrom(40)
|
||||
chat0.scrolled()
|
||||
chat0.show([...rows, say('n1', 'assistant', 'reply')])
|
||||
chat0.display('hidden')
|
||||
chat0.display('visible')
|
||||
chat0.rest()
|
||||
expect(chat0.drawn(mobileNativeChatRowId('n1'))).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a parked reader on their line when the list viewport changes height, as when the composer grows', () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => say(`m${i}`, i % 2 ? 'assistant' : 'user', 'x'))
|
||||
const chat0 = mount(rows)
|
||||
|
||||
@@ -237,7 +237,13 @@ export function MobileNativeChatPageList({
|
||||
}
|
||||
}
|
||||
})
|
||||
useEffect(() => () => clearTimeout(settleTimer.current), [])
|
||||
// A hidden chat keeps its hold but loses the timer; settle it again when the chat shows.
|
||||
useEffect(() => {
|
||||
if (heldRef.current) {
|
||||
settle.current()
|
||||
}
|
||||
return () => clearTimeout(settleTimer.current)
|
||||
}, [])
|
||||
|
||||
const drawAndFetch = useCallback(
|
||||
(s: Scroller) => {
|
||||
|
||||
Reference in New Issue
Block a user