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:
Kelvin Amoaba
2026-11-06 18:17:08 +00:00
committed by GitHub
parent a0da1caafb
commit e28bcfea65
4 changed files with 171 additions and 11 deletions
@@ -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) => {