mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
perf(mobile): stop chat redraws per keystroke and page-wide row scans per scroll frame (#34323)
* perf(mobile): stop redrawing the chat per keystroke and scanning every row per scroll frame Typing re-rendered the whole session screen because the draft text was subscribed in the drafts hook at the route; the composer now subscribes by draft key. The page list's reading place indexed every drawn row with a page-wide querySelectorAll on each scroll event; it now binary-searches the row box's children. The list also observes its viewport so composer resizes keep a parked reader's place without a list commit. * test(mobile): keep the keystroke redraw test in the draft-store suite under the line cap
This commit is contained in:
@@ -64,7 +64,7 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
|
||||
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
|
||||
chatPending: [],
|
||||
chatImagePreviewsByMessageId: {},
|
||||
chatComposerText: '',
|
||||
chatDraftKey: null,
|
||||
setChatComposerText: vi.fn(),
|
||||
nativeChatQueued: {
|
||||
cards: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
<div key={rowKeys.scope}>
|
||||
<div key={rowKeys.scope} {...mobileNativeChatRowBoxProps}>
|
||||
{rows.slice(first).map((item, at) => {
|
||||
const index = first + at
|
||||
return (
|
||||
|
||||
@@ -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<typeof createElement> {
|
||||
const DRAFT_KEY = 'draft-a'
|
||||
|
||||
function chatViewElement({
|
||||
composerText = '',
|
||||
...overrides
|
||||
}: Overrides): ReturnType<typeof createElement> {
|
||||
setMobileNativeChatDraftText(DRAFT_KEY, composerText)
|
||||
return createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
folded: [],
|
||||
@@ -63,7 +70,7 @@ function chatViewElement(overrides: Overrides): ReturnType<typeof createElement>
|
||||
getSendCompletionGeneration: () => 0,
|
||||
getComposerEditGeneration: () => 0,
|
||||
pending: [],
|
||||
composerText: '',
|
||||
draftKey: DRAFT_KEY,
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<boolean>
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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<string, string[]>
|
||||
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}
|
||||
<MobileNativeChatComposer
|
||||
<MobileNativeChatDraftComposer
|
||||
structured={structuredActivityUi ? structuredComposer : null}
|
||||
value={composerText}
|
||||
draftKey={draftKey}
|
||||
onChangeText={onComposerTextChange}
|
||||
onSend={handleSend}
|
||||
sendSurfaceId={sendSurfaceId}
|
||||
|
||||
@@ -75,22 +75,8 @@ vi.mock('./native-chat/input/MobileNativeChatQuestion', () => ({
|
||||
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<boolean>
|
||||
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<typeof createElement>
|
||||
getSendCompletionGeneration: () => 0,
|
||||
getComposerEditGeneration: () => 0,
|
||||
pending: [],
|
||||
composerText: '',
|
||||
draftKey: null,
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof useMobileNativeChatActiveResolution>[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)
|
||||
|
||||
@@ -29,7 +29,7 @@ export type MobileNativeChatController = {
|
||||
showNativeChat: boolean
|
||||
showNativeChatRef: MutableRefObject<boolean>
|
||||
nativeChatAgent: string | null
|
||||
chatComposerText: string
|
||||
chatDraftKey: string | null
|
||||
setChatComposerText: Dispatch<SetStateAction<string>>
|
||||
getChatComposerEditGeneration: () => number
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
|
||||
@@ -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<Phone> {
|
||||
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
|
||||
|
||||
@@ -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<unknown>>()
|
||||
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'])
|
||||
})
|
||||
|
||||
@@ -12,10 +12,13 @@ type Tagged = {
|
||||
getAttribute: (name: string) => string | null
|
||||
querySelectorAll: (selector: string) => ArrayLike<Tagged>
|
||||
}
|
||||
type Sibling = Tagged & { nextElementSibling: Sibling | null }
|
||||
type RowBox = Sibling & { children: ArrayLike<Sibling> }
|
||||
export type MobileNativeChatScrollNode = Tagged & {
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
querySelector: (selector: string) => RowBox | null
|
||||
}
|
||||
|
||||
type Keys = Pick<MobileNativeChatRowKeys, 'current' | 'placement'>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileNativeChatDraftComposer({
|
||||
draftKey,
|
||||
...props
|
||||
}: Omit<Props, 'value'> & { draftKey: string | null }): React.JSX.Element {
|
||||
return <MobileNativeChatComposer {...props} value={useMobileNativeChatDraft(draftKey)} />
|
||||
}
|
||||
|
||||
@@ -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<typeof useMobileNativeChatDrafts>
|
||||
type DraftState = ReturnType<typeof useMobileNativeChatDrafts> & { 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<typeof useMobileNativeChatDrafts> | null = null
|
||||
function Composer({ draftKey }: { draftKey: string | null }): null {
|
||||
shown = useMobileNativeChatDraft(draftKey)
|
||||
return null
|
||||
}
|
||||
const messages: NativeChatMessage[] = []
|
||||
function Screen(): ReturnType<typeof createElement> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
+7
-3
@@ -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<typeof useMobileNativeChatDrafts>
|
||||
type DraftState = ReturnType<typeof useMobileNativeChatDrafts> & { composerText: string }
|
||||
type TestRenderer = {
|
||||
unmount(): void
|
||||
update(element: ReturnType<typeof createElement>): 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
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -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<typeof useMobileNativeChatDrafts>
|
||||
type DraftState = ReturnType<typeof useMobileNativeChatDrafts> & { 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
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -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<typeof useMobileNativeChatDrafts> | null = null
|
||||
let state: (ReturnType<typeof useMobileNativeChatDrafts> & { 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof useMobileNativeChatDrafts>
|
||||
type DraftState = ReturnType<typeof useMobileNativeChatDrafts> & { 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SetStateAction<string>>
|
||||
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<string, MobileNativeChatPendingMessage[]>
|
||||
>({})
|
||||
@@ -249,7 +248,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
}, [messages, pending, pendingKey, transcriptSettled])
|
||||
|
||||
return {
|
||||
composerText,
|
||||
draftKey,
|
||||
setComposerText,
|
||||
appendComposerText,
|
||||
getComposerEditGeneration,
|
||||
|
||||
+7
-3
@@ -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<T>() {
|
||||
let resolve: ((value: T) => void) | undefined
|
||||
@@ -37,7 +40,7 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
type HookState = {
|
||||
drafts: ReturnType<typeof useMobileNativeChatDrafts>
|
||||
drafts: ReturnType<typeof useMobileNativeChatDrafts> & { composerText: string }
|
||||
sender: ReturnType<typeof useMobileNativeChatMessageSend>
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user