diff --git a/mobile/src/session/native-chat/MobileNativeChatOverlay.test.tsx b/mobile/src/session/native-chat/MobileNativeChatOverlay.test.tsx new file mode 100644 index 00000000000..41b887d3d7d --- /dev/null +++ b/mobile/src/session/native-chat/MobileNativeChatOverlay.test.tsx @@ -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']) +}) diff --git a/mobile/src/session/native-chat/MobileNativeChatOverlay.tsx b/mobile/src/session/native-chat/MobileNativeChatOverlay.tsx index 566759d9ff9..0c29c6e048c 100644 --- a/mobile/src/session/native-chat/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/native-chat/MobileNativeChatOverlay.tsx @@ -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(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 ? : null), + [drawn] + ) + if (!content) { + return null + } + return {content} +} + +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 ( 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({h(Chat, { ...options, ...last })})) 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) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatPageList.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatPageList.tsx index 3d301d2431c..27e36d78ac3 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatPageList.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatPageList.tsx @@ -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) => {