diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 94f90c5f186..d400ef2d4eb 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -64,7 +64,7 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], chatImagePreviewsByMessageId: {}, - chatComposerText: '', + chatDraftKey: null, setChatComposerText: vi.fn(), nativeChatQueued: { cards: [], diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 6b1e03beec4..4a2c342b884 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -185,7 +185,7 @@ export function MobileNativeChatOverlay({ getComposerEditGeneration={controller.getChatComposerEditGeneration} pending={controller.chatPending} imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} - composerText={controller.chatComposerText} + draftKey={controller.chatDraftKey} onComposerTextChange={controller.setChatComposerText} onAttachImage={ controller.nativeChatAcceptsImages diff --git a/mobile/src/session/MobileNativeChatPageList.tsx b/mobile/src/session/MobileNativeChatPageList.tsx index a64584fe189..b0b820f3d83 100644 --- a/mobile/src/session/MobileNativeChatPageList.tsx +++ b/mobile/src/session/MobileNativeChatPageList.tsx @@ -24,6 +24,7 @@ import { mobileNativeChatReadingDrift, mobileNativeChatReadingPlace, mobileNativeChatRowAbove, + mobileNativeChatRowBoxProps, type MobileNativeChatReadingUnit, type MobileNativeChatScrollNode } from './mobile-native-chat-reading-place' @@ -287,11 +288,12 @@ export function MobileNativeChatPageList({ } }) - // Image/inner-box resize bypasses React commits; restore nested scroll boxes first. + // Content and viewport resizes (e.g. the composer growing) bypass commits; restore nested scroll boxes first. useEffect(() => { const s = scroller() + const viewport: Element | null | undefined = scroll.current?.getScrollableNode() const content: Element | null | undefined = scroll.current?.getInnerViewNode() - if (!s || !content || typeof ResizeObserver === 'undefined') { + if (!s || !viewport || !content || typeof ResizeObserver === 'undefined') { return } const observer = new ResizeObserver(() => { @@ -299,6 +301,7 @@ export function MobileNativeChatPageList({ keepPlace(s) drawAndFetch(s) }) + observer.observe(viewport) observer.observe(content) roots?.observeWith(observer) return () => { @@ -374,7 +377,7 @@ export function MobileNativeChatPageList({ emptyStateView ) : ( // Key by chat to reset rows; persist disclosures above windowed rows. Block flow avoids relaying out every row for each streamed token. -
+
{rows.slice(first).map((item, at) => { const index = first + at return ( diff --git a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts index 6e262e589f8..77f20d44d0c 100644 --- a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts @@ -4,6 +4,7 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea import { afterEach, beforeEach, expect, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' +import { setMobileNativeChatDraftText } from './native-chat/composer/mobile-native-chat-draft-store' import { scrollToEnd, scrollToOffset, @@ -52,7 +53,13 @@ export function assistantTurn(id: string, text: string): NativeChatMessage { return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } } -function chatViewElement(overrides: Overrides): ReturnType { +const DRAFT_KEY = 'draft-a' + +function chatViewElement({ + composerText = '', + ...overrides +}: Overrides): ReturnType { + setMobileNativeChatDraftText(DRAFT_KEY, composerText) return createElement(MobileNativeChatView, { messages: [], folded: [], @@ -63,7 +70,7 @@ function chatViewElement(overrides: Overrides): ReturnType getSendCompletionGeneration: () => 0, getComposerEditGeneration: () => 0, pending: [], - composerText: '', + draftKey: DRAFT_KEY, onComposerTextChange: vi.fn(), ...overrides }) diff --git a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts index cfa8842afd7..19939d0371a 100644 --- a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts +++ b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts @@ -7,6 +7,7 @@ import { MobileNativeChatView } from './MobileNativeChatView' import { MobileNativeChatAsk } from './native-chat/input/MobileNativeChatAsk' import { useMobileNativeChatPromptPresentation } from './native-chat/input/use-mobile-native-chat-prompt-presentation' import type { MobileChatPermission } from './native-chat/input/mobile-native-chat-permission' +import { setMobileNativeChatDraftText } from './native-chat/composer/mobile-native-chat-draft-store' vi.mock('react-native', async () => { const React = await import('react') return { @@ -77,6 +78,7 @@ vi.mock('./native-chat/composer/MobileNativeChatComposerMenu', () => ({ vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) let tree: ReactTestRenderer | null = null +setMobileNativeChatDraftText('terminal-1', 'no, do X instead') const props = { messages: [], folded: [], @@ -87,7 +89,7 @@ const props = { sendSurfaceId: 'terminal-1', getSendCompletionGeneration: () => 0, getComposerEditGeneration: () => 0, - composerText: 'no, do X instead', + draftKey: 'terminal-1', onComposerTextChange: vi.fn() } const permission: MobileChatPermission = { diff --git a/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts b/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts index b41fa6bae1e..4bfb5b7b5ca 100644 --- a/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts @@ -11,6 +11,7 @@ const pageScroller = vi.hoisted(() => ({ getBoundingClientRect: () => ({ top: 0, bottom: 500 }), getAttribute: () => null, querySelectorAll: () => [], + querySelector: () => null, addEventListener: () => {}, removeEventListener: () => {} })) @@ -67,8 +68,14 @@ export async function composerStub() { const React = await import('react') const { MobileNativeChatStopButton } = await import('./native-chat/input/MobileNativeChatStopButton') + const { useMobileNativeChatDraft } = + await import('./native-chat/composer/mobile-native-chat-draft-store') return { - MobileNativeChatComposer: (props: { + MobileNativeChatDraftComposer: ({ + draftKey, + ...props + }: { + draftKey: string | null onSend: (text: string) => Promise disabled?: boolean placeholder?: string @@ -76,7 +83,12 @@ export async function composerStub() { }) => React.createElement( 'Composer', - { ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }, + { + ...props, + value: useMobileNativeChatDraft(draftKey), + accessibilityLabel: 'Send message', + onPress: () => props.onSend('hi') + }, props.stop ? React.createElement(MobileNativeChatStopButton, props.stop) : null ) } diff --git a/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts b/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts index 00420fcfb36..8c19d2dc4ba 100644 --- a/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts @@ -183,7 +183,7 @@ vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ MobileNativeChatMessageActionsSheet: 'ActionsSheet' })) vi.mock('./native-chat/composer/MobileNativeChatComposer', () => ({ - MobileNativeChatComposer: 'Composer' + MobileNativeChatDraftComposer: 'Composer' })) const stable = { @@ -191,7 +191,7 @@ const stable = { sendSurfaceId: 'perf-chat', getSendCompletionGeneration: () => 0, getComposerEditGeneration: () => 0, - composerText: '', + draftKey: null, onComposerTextChange: () => {}, pending: [ { @@ -231,7 +231,7 @@ function Harness({ nativeChatWorkingStartedAt: timing.workingStartedAt, chatPending: stable.pending, chatImagePreviewsByMessageId: stable.imagePreviewsByMessageId, - chatComposerText: '', + chatDraftKey: null, setChatComposerText: stable.onComposerTextChange, getChatComposerEditGeneration: stable.getComposerEditGeneration, nativeChatQueued: { cards: [], pause: null, sessionKey: 'session' } @@ -325,6 +325,7 @@ const scroller = { addEventListener: (type: string, callback: () => void) => listeners.set(type, callback), removeEventListener: (type: string) => listeners.delete(type), querySelectorAll: () => [], + querySelector: () => null, getBoundingClientRect: () => ({ top: 0, bottom: 500 }) } const nodeMock = (element: { type: unknown }) => diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f37b22c0ba1..f40085bc533 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -43,7 +43,7 @@ import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' import { MobileNativeChatFloatingControls } from './MobileNativeChatFloatingControls' import type { PendingNativeChatImage } from './native-chat/composer/mobile-native-chat-image-attachment' import { - MobileNativeChatComposer, + MobileNativeChatDraftComposer, type MobileNativeChatStructuredComposer } from './native-chat/composer/MobileNativeChatComposer' import { MobileNativeChatPromptCard } from './native-chat/input/MobileNativeChatPromptCard' @@ -94,7 +94,7 @@ type Props = ComposerTrayProps & { pending: MobileNativeChatPendingItem[] // Retain local preview URIs after authoritative image messages replace optimistic rows. imagePreviewsByMessageId?: Record - composerText: string + draftKey: string | null onComposerTextChange: (text: string) => void onAttachImage?: () => void attachments?: PendingNativeChatImage[] @@ -161,7 +161,7 @@ export function MobileNativeChatView({ getComposerEditGeneration, pending, imagePreviewsByMessageId, - composerText, + draftKey, onComposerTextChange, onAttachImage, attachments, @@ -373,9 +373,9 @@ export function MobileNativeChatView({ ) : null} {} {trayContent} - ({ vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('./native-chat/composer/MobileNativeChatComposer', async () => { - const React = await import('react') - const { MobileNativeChatStopButton } = - await import('./native-chat/input/MobileNativeChatStopButton') - return { - MobileNativeChatComposer: (props: { - onSend: (text: string) => Promise - disabled?: boolean - placeholder?: string - stop?: { onStop?: () => void; held: boolean } | null - }) => - React.createElement( - 'Composer', - { ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }, - props.stop ? React.createElement(MobileNativeChatStopButton, props.stop) : null - ) - } + const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return composerStub() }) type Overrides = { @@ -130,7 +116,7 @@ function chatViewElement(overrides: Overrides): ReturnType getSendCompletionGeneration: () => 0, getComposerEditGeneration: () => 0, pending: [], - composerText: '', + draftKey: null, onComposerTextChange: vi.fn(), ...overrides }) diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts index 256e2ea6fd4..74b139070c5 100644 --- a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -41,7 +41,7 @@ vi.mock('lucide-react-native', () => ({ vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./native-chat/composer/MobileNativeChatComposer', () => ({ - MobileNativeChatComposer: 'Composer' + MobileNativeChatDraftComposer: 'Composer' })) // The queue's action sheet pulls in the animation runtime, which this react-native mock can't host. vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) @@ -97,7 +97,7 @@ function renderView( getSendCompletionGeneration: () => 0, getComposerEditGeneration: () => 0, pending: [], - composerText: '', + draftKey: null, onComposerTextChange: vi.fn(), structuredActivityUi: true, agentWorking: true, diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts index 4d682980d2e..5faba289900 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -11,6 +11,7 @@ import { MobileNativeChatOverlay } from '../MobileNativeChatOverlay' import { MobileNativeChatAsk } from '../native-chat/input/MobileNativeChatAsk' import type { AskAnswerSelection } from '../../../../src/shared/native-chat-ask-answer' import { mobileNativeChatPromptDismissals } from '../native-chat/input/mobile-native-chat-prompt-dismissals' +import { setMobileNativeChatDraftText } from '../native-chat/composer/mobile-native-chat-draft-store' vi.mock('react-native', async () => { const React = await import('react') return { @@ -114,9 +115,10 @@ vi.mock('../use-mobile-session-view-mode', () => ({ vi.mock('../use-mobile-native-chat-session-lane', () => ({ useMobileNativeChatSessionLane: () => ({ session, structuredSession, sessionCommands }) })) +setMobileNativeChatDraftText('draft', 'ordinary message') vi.mock('../native-chat/composer/use-mobile-native-chat-drafts', () => ({ useMobileNativeChatDrafts: () => ({ - composerText: 'ordinary message', + draftKey: 'draft', setComposerText: vi.fn(), getComposerEditGeneration: () => 0, appendComposerText: () => true, diff --git a/mobile/src/session/mobile-dictation-agent-chat-start.test.tsx b/mobile/src/session/mobile-dictation-agent-chat-start.test.tsx index 1336e9e2346..62efa945c3f 100644 --- a/mobile/src/session/mobile-dictation-agent-chat-start.test.tsx +++ b/mobile/src/session/mobile-dictation-agent-chat-start.test.tsx @@ -1,4 +1,4 @@ -import { createElement, useRef, useState } from 'react' +import { createElement, useRef } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -13,6 +13,7 @@ import type { ConnectionState } from '../transport/types' import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' const device = vi.hoisted(() => ({ opens: 0, recording: false })) +const draft = vi.hoisted(() => ({ text: 'Draft' })) vi.mock('react-native', () => ({ AppState: { addEventListener: () => ({ remove: () => {} }) }, Platform: { OS: 'ios' } @@ -64,13 +65,13 @@ vi.mock('./use-mobile-native-chat-controller', async () => { args: Parameters[0] ) => { const resolution = useMobileNativeChatActiveResolution(args) - const [chatComposerText, setChatComposerText] = useState('Draft') return { ...resolution, nativeChatSession: { messages: [] }, nativeChatCommandRefusalCauses: null, - chatComposerText, - setChatComposerText + setChatComposerText: (update: string | ((current: string) => string)) => { + draft.text = typeof update === 'function' ? update(draft.text) : update + } } } } @@ -217,6 +218,7 @@ beforeEach(() => { current = null device.opens = 0 device.recording = false + draft.text = 'Draft' terminalText = '' }) afterEach(async () => { @@ -233,7 +235,7 @@ describe('dictation uses the active composer readiness', () => { void model().dictation.stop() }) await reply(client, 'speech.dictation.finish', { text: 'spoken words' }) - expect(model().nativeChatController.chatComposerText).toBe('Draft spoken words') + expect(draft.text).toBe('Draft spoken words') expect(terminalText).toBe('') expect(model().dictation.status).toBe('idle') expect(device.recording).toBe(false) @@ -269,7 +271,7 @@ describe('dictation uses the active composer readiness', () => { }) await reply(client, 'speech.dictation.finish', { text: 'terminal words' }) expect(terminalText).toBe('terminal words') - expect(model().nativeChatController.chatComposerText).toBe('Draft') + expect(draft.text).toBe('Draft') }) it('cancels an in-flight agent-chat start when the connection drops', async () => { @@ -291,7 +293,7 @@ describe('dictation uses the active composer readiness', () => { ) await reply(client, 'speech.dictation.cancel', {}) expect(device.recording).toBe(false) - expect(model().nativeChatController.chatComposerText).toBe('Draft') + expect(draft.text).toBe('Draft') await render(options) await start(client) }) @@ -316,7 +318,7 @@ describe('dictation uses the active composer readiness', () => { await act(async () => pendingFinish?.resolve({ id: 'desktop', ok: true, result: { text: 'late words' } }) ) - expect(model().nativeChatController.chatComposerText).toBe('Draft') + expect(draft.text).toBe('Draft') expect(device.recording).toBe(false) await render(options) await start(client) diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 533ffddca18..e0480eda21b 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -29,7 +29,7 @@ export type MobileNativeChatController = { showNativeChat: boolean showNativeChatRef: MutableRefObject nativeChatAgent: string | null - chatComposerText: string + chatDraftKey: string | null setChatComposerText: Dispatch> getChatComposerEditGeneration: () => number chatPending: MobileNativeChatPendingMessage[] diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index f9328b54938..e2b1a1f9e1f 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -65,7 +65,7 @@ vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ })) vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./native-chat/composer/MobileNativeChatComposer', () => ({ - MobileNativeChatComposer: 'Composer' + MobileNativeChatDraftComposer: 'Composer' })) vi.mock('./native-chat/input/MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' @@ -222,7 +222,7 @@ async function mountPhone(): Promise { nativeChatStreamScopeKey: 'host\0workspace\0tab', chatPending: drafts.pending, chatImagePreviewsByMessageId: drafts.imagePreviewsByMessageId, - chatComposerText: drafts.composerText, + chatDraftKey: drafts.draftKey, setChatComposerText: drafts.setComposerText, getChatComposerEditGeneration: drafts.getComposerEditGeneration, nativeChatQueued: session.queued diff --git a/mobile/src/session/mobile-native-chat-reading-place.test.tsx b/mobile/src/session/mobile-native-chat-reading-place.test.tsx index edfa598ca8b..dce2b5df7fe 100644 --- a/mobile/src/session/mobile-native-chat-reading-place.test.tsx +++ b/mobile/src/session/mobile-native-chat-reading-place.test.tsx @@ -13,7 +13,10 @@ import { useMobileNativeChatRowKeys, type MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' -import { mobileNativeChatRowId } from './mobile-native-chat-reading-place' +import { + mobileNativeChatReadingPlace, + mobileNativeChatRowId +} from './mobile-native-chat-reading-place' import { MobileNativeChatPageList } from './MobileNativeChatPageList' import { ToolRun } from './MobileNativeChatToolRun' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' @@ -71,16 +74,23 @@ vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() })) vi.mock('../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false })) // The browser's resize step: after layout, before paint. -const observers = new Set<() => void>() -const resized = () => act(() => observers.forEach((deliver) => deliver())) +const observers = new Map<() => void, Set>() +const resized = () => act(() => observers.forEach((_, deliver) => deliver())) +// Only observers watching `target` hear about it, as in the browser. +const resizedOnly = (target: unknown) => + act(() => observers.forEach((targets, deliver) => targets.has(target) && deliver())) vi.stubGlobal( 'ResizeObserver', class { constructor(private readonly deliver: () => void) { - observers.add(deliver) + observers.set(deliver, new Set()) + } + observe(target: unknown): void { + observers.get(this.deliver)?.add(target) + } + unobserve(target: unknown): void { + observers.get(this.deliver)?.delete(target) } - observe(): void {} - unobserve(): void {} disconnect(): void { observers.delete(this.deliver) } @@ -124,11 +134,13 @@ const chat = [say('u0', 'user', 'first'), say('x0', 'assistant', 'answer'), say( }) .concat(run(1).slice(1), ...Array.from({ length: 39 }, (_, i) => run(i + 2))) -type Box = { id: string; top: number; bottom: number; end: number } +type Box = { id: string; top: number; bottom: number; end: number; parent: number; rows: boolean } type Element = { getAttribute: () => string getBoundingClientRect: () => { top: number; bottom: number } querySelectorAll: (selector: string) => Element[] + children: Element[] + nextElementSibling: Element | null } const oldHostTail = (): NativeChatMessage[] => JSON.parse(JSON.stringify(chat)) @@ -139,33 +151,44 @@ function page(tree: () => Node | Node[] | null) { const boxes = (): Box[] => { const out: Box[] = [] let y = 0 - const walk = (node: Node | string): void => { + const walk = (node: Node | string, parent: number): void => { if (typeof node === 'string' || node.type === 'span') { y += typeof node === 'string' ? 0 : 20 return } - const at = out.push({ id: String(node.props.testID ?? ''), top: y, bottom: y, end: 0 }) - 1 - node.children?.forEach(walk) + const id = String(node.props.testID ?? '') + const rows = node.props['data-chat-rows'] !== undefined + const at = out.push({ id, top: y, bottom: y, end: 0, parent, rows }) - 1 + node.children?.forEach((child) => walk(child, at)) out[at] = { ...out[at]!, bottom: y, end: out.length } } - walk({ type: 'page', props: {}, children: [tree() ?? []].flat() }) + walk({ type: 'page', props: {}, children: [tree() ?? []].flat() }, -1) return out } + const element = (all: Box[], at: number): Element => { + const box = all[at]! + const siblings = (parent: number): Element[] => + all.flatMap((other, i) => (other.parent === parent ? [element(all, i)] : [])) + return { + getAttribute: () => box.id, + getBoundingClientRect: () => ({ + top: box.top - node.viewTop(), + bottom: box.bottom - node.viewTop() + }), + querySelectorAll: (inner: string) => select(all, at + 1, box.end, inner), + get children() { + return siblings(at) + }, + get nextElementSibling() { + const next = all.findIndex((other, i) => i > at && other.parent === box.parent) + return next === -1 ? null : element(all, next) + } + } + } const select = (all: Box[], from: number, to: number, selector: string): Element[] => { const prefix = /\^="([^"]+)"/.exec(selector)?.[1] ?? '?' return all.flatMap((box, at) => - at >= from && at < to && box.id.startsWith(prefix) - ? [ - { - getAttribute: () => box.id, - getBoundingClientRect: () => ({ - top: box.top - node.viewTop(), - bottom: box.bottom - node.viewTop() - }), - querySelectorAll: (inner: string) => select(all, at + 1, box.end, inner) - } - ] - : [] + at >= from && at < to && box.id.startsWith(prefix) ? [element(all, at)] : [] ) } let scrollTop = 0 @@ -188,7 +211,7 @@ function page(tree: () => Node | Node[] | null) { node.scrollTop = y - Math.max(0, node.scrollHeight - node.clientHeight) }, getAttribute: () => null, - getBoundingClientRect: () => ({ top: 0, bottom: 200 }), + getBoundingClientRect: () => ({ top: 0, bottom: node.clientHeight }), addEventListener: (type: string, listener: () => void): void => { listeners.set(type, listener) }, @@ -197,6 +220,11 @@ function page(tree: () => Node | Node[] | null) { }, touch: (type: 'touchstart' | 'touchend'): void => listeners.get(type)?.(), querySelectorAll: (selector: string): Element[] => select(boxes(), 0, Infinity, selector), + querySelector: (): Element | null => { + const all = boxes() + const at = all.findIndex((box) => box.rows) + return at === -1 ? null : element(all, at) + }, top: (id: string): number => (boxes().find((box) => box.id === id)?.top ?? Number.NaN) - node.viewTop() } @@ -390,6 +418,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('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) + chat0.read(() => chat0.scroller.showFrom(40)) + const line = chat0.scroller.top(mobileNativeChatRowId('m4')) + + chat0.scroller.clientHeight = 120 + resizedOnly(chat0.scroller) + + expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(line) +}) + it("holds a row's look with its messages: what the chat's state adds lands once the reader rests", () => { const rows = Array.from({ length: 12 }, (_, i) => say(`m${i}`, i % 2 ? 'assistant' : 'user', 'x')) const chat0 = mount(rows) @@ -577,3 +617,37 @@ it('a roster keeps its key when the fold moves it, and the rows the reader saw a expect(tops(['mid0', 'mid5'])).toEqual(after) expect(chat0.drawn(mobileNativeChatRowId('s2'))).toBe(1) }) + +it('keeps the nearest rows off screen as stand-ins when turn-status lines sit between them and the view', () => { + type Line = { testID: string; top: number; bottom: number } + const lines: Line[] = [ + { testID: mobileNativeChatRowId('above'), top: -300, bottom: -100 }, + { testID: '', top: -100, bottom: -80 }, + { testID: mobileNativeChatRowId('shown'), top: -80, bottom: 280 }, + { testID: '', top: 280, bottom: 300 }, + { testID: mobileNativeChatRowId('below'), top: 300, bottom: 500 } + ] + const children = lines.map((line) => ({ + getAttribute: () => line.testID || null, + getBoundingClientRect: () => line, + querySelectorAll: () => [], + nextElementSibling: null + })) + const box = { ...children[0]!, children } + const scroller = { + scrollTop: 0, + scrollHeight: 800, + clientHeight: 200, + getAttribute: () => null, + getBoundingClientRect: () => ({ top: 0, bottom: 200 }), + querySelectorAll: () => [], + querySelector: () => box + } + + const place = mobileNativeChatReadingPlace(scroller, { + current: () => undefined, + placement: () => 'p' + }) + + expect(place.map((unit) => unit.key)).toEqual(['shown', 'below', 'above']) +}) diff --git a/mobile/src/session/mobile-native-chat-reading-place.ts b/mobile/src/session/mobile-native-chat-reading-place.ts index 8c8964545d2..8819490b4e6 100644 --- a/mobile/src/session/mobile-native-chat-reading-place.ts +++ b/mobile/src/session/mobile-native-chat-reading-place.ts @@ -12,10 +12,13 @@ type Tagged = { getAttribute: (name: string) => string | null querySelectorAll: (selector: string) => ArrayLike } +type Sibling = Tagged & { nextElementSibling: Sibling | null } +type RowBox = Sibling & { children: ArrayLike } export type MobileNativeChatScrollNode = Tagged & { scrollTop: number scrollHeight: number clientHeight: number + querySelector: (selector: string) => RowBox | null } type Keys = Pick @@ -29,6 +32,10 @@ export type MobileNativeChatReadingUnit = { const ROW = 'chat-row:' const MEMBER = 'chat-member:' +const ROWS = 'data-chat-rows' + +// Lets the reading place index rows instead of searching the page. +export const mobileNativeChatRowBoxProps = { [ROWS]: '' } export function mobileNativeChatRowId(key: string): string { return `${ROW}${key}` @@ -80,13 +87,33 @@ function unitsOf( return lines.length > 0 ? lines : [shown[0] ?? self] } +function rowIdOf(node: Tagged): string | undefined { + const id = node.getAttribute('data-testid') + return id?.startsWith(ROW) ? id.slice(ROW.length) : undefined +} + +// The row box's children plus footer waiting rows after it; ids are read only where needed. +function drawnRows(scroller: MobileNativeChatScrollNode): Sibling[] { + const box = scroller.querySelector(`[${ROWS}]`) + if (!box) { + return [] + } + const rows = Array.from(box.children) + for (let next = box.nextElementSibling; next; next = next.nextElementSibling) { + if (rowIdOf(next) !== undefined) { + rows.push(next) + } + } + return rows +} + // Binary-search ordered row bottoms to avoid measuring thousands of offscreen rows. -function firstRowEndingBelow(rows: readonly { node: Tagged }[], y: number): number { +function firstRowEndingBelow(rows: readonly Tagged[], y: number): number { let low = 0 let high = rows.length while (low < high) { const middle = low + Math.floor((high - low) / 2) - if (rows[middle]!.node.getBoundingClientRect().bottom <= y) { + if (rows[middle]!.getBoundingClientRect().bottom <= y) { low = middle + 1 } else { high = middle @@ -95,13 +122,29 @@ function firstRowEndingBelow(rows: readonly { node: Tagged }[], y: number): numb return low } +// Turn-status lines sit between rows untagged, so step past them to the nearest tagged row. +function nearestRow( + rows: readonly Tagged[], + from: number, + step: 1 | -1 +): { id: string; node: Tagged } | undefined { + for (let at = from; at >= 0 && at < rows.length; at += step) { + const id = rowIdOf(rows[at]!) + if (id !== undefined) { + return { id, node: rows[at]! } + } + } + return undefined +} + // The nearest row wholly more than `distance` above the view; it ends at or above that line. export function mobileNativeChatRowAbove( scroller: MobileNativeChatScrollNode, distance: number ): string | undefined { - const rows = tagged(scroller, ROW) - return rows[firstRowEndingBelow(rows, scroller.getBoundingClientRect().top - distance) - 1]?.id + const rows = drawnRows(scroller) + const below = firstRowEndingBelow(rows, scroller.getBoundingClientRect().top - distance) + return nearestRow(rows, below - 1, -1)?.id } export function mobileNativeChatReadingPlace( @@ -109,18 +152,24 @@ export function mobileNativeChatReadingPlace( { placement }: Keys ): MobileNativeChatReadingUnit[] { const view = scroller.getBoundingClientRect() - const rows = tagged(scroller, ROW) + const rows = drawnRows(scroller) const low = firstRowEndingBelow(rows, view.top) let end = low - while (end < rows.length && rows[end]!.node.getBoundingClientRect().top < view.bottom) { + while (end < rows.length && rows[end]!.getBoundingClientRect().top < view.bottom) { end += 1 } - const self = ({ id, node }: { id: string; node: Tagged }): MobileNativeChatReadingUnit => { - return { key: id, top: node.getBoundingClientRect().top, placed: placement(id) } - } - const units = rows.slice(low, end).flatMap((row) => unitsOf(row.node, view, self(row))) + const unit = ({ id, node }: { id: string; node: Tagged }): MobileNativeChatReadingUnit => ({ + key: id, + top: node.getBoundingClientRect().top, + placed: placement(id) + }) + const units = rows.slice(low, end).flatMap((node) => { + const id = rowIdOf(node) + return id === undefined ? [] : unitsOf(node, view, unit({ id, node })) + }) // The nearest rows off screen stand in when the update takes away all that was on it. - return units.concat([rows[end], rows[low - 1]].flatMap((row) => (row ? [self(row)] : []))) + const nearest = [nearestRow(rows, end, 1), nearestRow(rows, low - 1, -1)] + return units.concat(nearest.flatMap((row) => (row ? [unit(row)] : []))) } // Follow paired results into their call’s row; runs/rows retain anchors only while their original placement identity survives. @@ -148,7 +197,12 @@ export function mobileNativeChatReadingDrift( keys: Keys, roots?: MobileNativeChatScrollRoots ): number { - const rows = new Map(tagged(scroller, ROW).map(({ id, node }) => [id, node])) + const rows = new Map( + drawnRows(scroller).flatMap((node) => { + const id = rowIdOf(node) + return id === undefined ? [] : [[id, node] as const] + }) + ) for (const unit of place) { const node = drawn(unit, rows, keys, roots) if (node) { diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index b35da28bf65..72ea58045e6 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -21,6 +21,7 @@ import { keepHeldPressThroughLongPress } from '../../held-press-long-press' import { mobileNativeChatComposerShowsStop } from './mobile-native-chat-composer-layout' import { MobileNativeChatStopButton } from '../input/MobileNativeChatStopButton' import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles' +import { useMobileNativeChatDraft } from './mobile-native-chat-draft-store' const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] @@ -327,3 +328,10 @@ export function MobileNativeChatComposer({ ) } + +export function MobileNativeChatDraftComposer({ + draftKey, + ...props +}: Omit & { draftKey: string | null }): React.JSX.Element { + return +} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts index 1f0c4648328..49b1048085c 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts @@ -1,10 +1,14 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import { resetMobileNativeChatDraftStoreForTests } from './mobile-native-chat-draft-store' +import { + resetMobileNativeChatDraftStoreForTests, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' -type DraftState = ReturnType +type DraftState = ReturnType & { composerText: string } describe('mobile native-chat draft store', () => { let renderer: ReactTestRenderer | null = null @@ -18,7 +22,7 @@ describe('mobile native-chat draft store', () => { }) function Harness({ tabId }: { tabId: string }): null { - state = useMobileNativeChatDrafts({ + const drafts = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', tabId, @@ -26,6 +30,7 @@ describe('mobile native-chat draft store', () => { messages: [], transcriptSettled: true }) + state = { ...drafts, composerText: useMobileNativeChatDraft(drafts.draftKey) } return null } @@ -105,4 +110,36 @@ describe('mobile native-chat draft store', () => { await mount('a') expect(currentState().composerText).toBe('') }) + + it('redraws only the draft subscriber per keystroke, not the screen that owns the drafts', async () => { + let screenRenders = 0 + let shown = '' + let owner: ReturnType | null = null + function Composer({ draftKey }: { draftKey: string | null }): null { + shown = useMobileNativeChatDraft(draftKey) + return null + } + const messages: NativeChatMessage[] = [] + function Screen(): ReturnType { + screenRenders += 1 + owner = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'a', + sessionId: 'session-a', + messages, + transcriptSettled: true + }) + return createElement(Composer, { draftKey: owner.draftKey }) + } + await act(async () => { + renderer = create(createElement(Screen)) + }) + const rendersBeforeTyping = screenRenders + + act(() => owner?.setComposerText('typed')) + + expect(shown).toBe('typed') + expect(screenRenders).toBe(rendersBeforeTyping) + }) }) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-glued-pending.test.ts index 55211d654da..5a9a376b3fc 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -4,9 +4,12 @@ import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' import { buildMobileNativeChatTransientData } from '../../mobile-native-chat-render-data' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import { resetMobileNativeChatDraftStoreForTests } from './mobile-native-chat-draft-store' +import { + resetMobileNativeChatDraftStoreForTests, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' -type DraftState = ReturnType +type DraftState = ReturnType & { composerText: string } type TestRenderer = { unmount(): void update(element: ReturnType): void @@ -71,7 +74,7 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { transcriptLoading?: boolean transcriptSettled?: boolean }): null { - state = useMobileNativeChatDrafts({ + const drafts = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', tabId: 'tab', @@ -80,6 +83,7 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { transcriptLoading, transcriptSettled }) + state = { ...drafts, composerText: useMobileNativeChatDraft(drafts.draftKey) } return null } diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts index 262ffa44855..f620d0361ea 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -4,9 +4,12 @@ import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' import { projectStructuredAgentSessionMessages } from '../../../../../src/shared/structured-agent-session-message-projection' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import { resetMobileNativeChatDraftStoreForTests } from './mobile-native-chat-draft-store' +import { + resetMobileNativeChatDraftStoreForTests, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' -type DraftState = ReturnType +type DraftState = ReturnType & { composerText: string } function userTextMessage(id: string, text: string): NativeChatMessage { return { @@ -48,7 +51,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { transcriptLoading?: boolean transcriptSettled?: boolean }): null { - state = useMobileNativeChatDrafts({ + const drafts = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', tabId, @@ -60,6 +63,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { transcriptLoading, transcriptSettled }) + state = { ...drafts, composerText: useMobileNativeChatDraft(drafts.draftKey) } return null } diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-queued-hold.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-queued-hold.test.ts index 029cfe807d3..69def691118 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-queued-hold.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-queued-hold.test.ts @@ -2,11 +2,14 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it } from 'vitest' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import { resetMobileNativeChatDraftStoreForTests } from './mobile-native-chat-draft-store' +import { + resetMobileNativeChatDraftStoreForTests, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' describe('useMobileNativeChatDrafts with queued cards', () => { let renderer: ReactTestRenderer | null = null - let state: ReturnType | null = null + let state: (ReturnType & { composerText: string }) | null = null afterEach(() => { act(() => renderer?.unmount()) @@ -16,7 +19,7 @@ describe('useMobileNativeChatDrafts with queued cards', () => { }) function Harness(): null { - state = useMobileNativeChatDrafts({ + const drafts = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', tabId: 'a', @@ -26,6 +29,7 @@ describe('useMobileNativeChatDrafts with queued cards', () => { transcriptLoading: false, transcriptSettled: true }) + state = { ...drafts, composerText: useMobileNativeChatDraft(drafts.draftKey) } return null } diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts index 7eb687ecdb1..7b731d6915b 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts @@ -3,9 +3,12 @@ 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 { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import { resetMobileNativeChatDraftStoreForTests } from './mobile-native-chat-draft-store' +import { + resetMobileNativeChatDraftStoreForTests, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' -type DraftState = ReturnType +type DraftState = ReturnType & { composerText: string } function userTextMessage(id: string, text: string): NativeChatMessage { return { @@ -55,7 +58,7 @@ describe('useMobileNativeChatDrafts', () => { transcriptLoading?: boolean transcriptSettled?: boolean }): null { - state = useMobileNativeChatDrafts({ + const drafts = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', tabId, @@ -66,6 +69,7 @@ describe('useMobileNativeChatDrafts', () => { transcriptLoading, transcriptSettled }) + state = { ...drafts, composerText: useMobileNativeChatDraft(drafts.draftKey) } return null } diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.ts index b81f513506c..2e4e359788b 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.ts @@ -26,8 +26,7 @@ import { clearMobileNativeChatDraftForSend, editMobileNativeChatDraft, readMobileNativeChatDraftEditGeneration, - setMobileNativeChatDraftText, - useMobileNativeChatDraft + setMobileNativeChatDraftText } from './mobile-native-chat-draft-store' const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] @@ -48,7 +47,8 @@ export function useMobileNativeChatDrafts(args: { // Only settled own-session history makes an empty list a real send boundary. transcriptSettled: boolean }): { - composerText: string + // Subscribe where the text is drawn; a subscriber here redraws the session screen per keystroke. + draftKey: string | null setComposerText: Dispatch> appendComposerText: (text: string) => boolean getComposerEditGeneration: () => number @@ -81,7 +81,6 @@ export function useMobileNativeChatDrafts(args: { } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null - const composerText = useMobileNativeChatDraft(draftKey) const [pendingBySession, setPendingBySession] = useState< Record >({}) @@ -249,7 +248,7 @@ export function useMobileNativeChatDrafts(args: { }, [messages, pending, pendingKey, transcriptSettled]) return { - composerText, + draftKey, setComposerText, appendComposerText, getComposerEditGeneration, diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-message-send-draft-lifetime.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-message-send-draft-lifetime.test.ts index eb115667c91..ec67955dad1 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-message-send-draft-lifetime.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-message-send-draft-lifetime.test.ts @@ -26,7 +26,10 @@ vi.mock('../mobile-native-chat-send', () => ({ import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatMessageSend } from '../../use-mobile-native-chat-message-send' -import { resetMobileNativeChatDraftStoreForTests } from './mobile-native-chat-draft-store' +import { + resetMobileNativeChatDraftStoreForTests, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' function deferred() { let resolve: ((value: T) => void) | undefined @@ -37,7 +40,7 @@ function deferred() { } type HookState = { - drafts: ReturnType + drafts: ReturnType & { composerText: string } sender: ReturnType } @@ -94,7 +97,8 @@ describe('mobile native-chat send across screen lifetimes', () => { holdUnconfirmedSend: drafts.holdUnconfirmedSend, onSendError: transport.error }) - latest = { drafts, sender } + const composerText = useMobileNativeChatDraft(drafts.draftKey) + latest = { drafts: { ...drafts, composerText }, sender } return null } await act(async () => { diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index c0f363e5577..19e8eefc13b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -110,7 +110,7 @@ vi.mock('./use-mobile-structured-agent-session', () => ({ vi.mock('./native-chat/composer/use-mobile-native-chat-drafts', () => ({ useMobileNativeChatDrafts: () => { return { - composerText: '', + draftKey: null, setComposerText: vi.fn(), pending: [], imagePreviewsByMessageId: {}, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index eb1e987fef0..77db2d87feb 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -89,7 +89,7 @@ export function useMobileNativeChatController(args: { }) const { structuredSession: structuredNativeChat, session: nativeChatSession } = nativeChatLane const { - composerText: chatComposerText, + draftKey: chatDraftKey, setComposerText: setChatComposerText, getComposerEditGeneration: getChatComposerEditGeneration, appendComposerText, @@ -261,7 +261,7 @@ export function useMobileNativeChatController(args: { showNativeChat, showNativeChatRef, nativeChatAgent: activeChatResolution?.agent ?? null, - chatComposerText, + chatDraftKey, setChatComposerText, getChatComposerEditGeneration, chatPending,