fix(mobile): a streaming reply reveals word by word, like the desktop (#29148)

* fix(mobile): a streaming reply reveals word by word, like the desktop

The phone drew each streamed chunk as it landed, so a reply arrived in a few
jumps. Move the desktop's paced reveal into src/shared and use it on both
surfaces: the phone paces the growing block of the newest reply, remembers
progress per chat list so a recycled row resumes, and draws everything at once
with reduced motion. The terminal-backed chat's live row now counts as growing.

* fix(mobile): hold the reply reveals in the chat overlay

The list's turn resolver must not scan terminal-backed transcripts, and the
overlay outlives the list across chat and terminal toggles, so it owns the
reveal memory and the reduced-motion read. Also fix the moved hook's import
in the onboarding route, and mock the OS read where tests render the overlay.

* test(mobile): mock the message row's other children in the reveal test

Their real modules reach React Native's Flow sources, which Bun cannot load;
the other message row tests mock them the same way.

* style: format the desktop paced-text hook

* Keep phone reply reveal progress owned by its message

* Add braces around phone reveal test assertion

* Start phone prose streaming only at a text block

* Find the mocked phone tool row with a node predicate

* refactor(mobile): move chat prose rendering into its own file

Keeps MobileNativeChatMessage.tsx under the 400-line limit after the main merge.
This commit is contained in:
Brennan Benson
2026-10-16 18:51:18 -07:00
committed by GitHub
parent 634ca45fbb
commit b61b9da778
23 changed files with 868 additions and 412 deletions
@@ -0,0 +1,179 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
createNativeChatReplyRevealTracker,
trackNativeChatReplyRows
} from '../../../src/shared/native-chat-reply-reveals'
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS } from '../../../src/shared/native-chat-text-reveal'
import {
MobileNativeChatReplyRevealsContext,
type MobileNativeChatReplyReveals
} from './mobile-native-chat-reply-reveals'
vi.mock('react-native', () => ({
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
Text: 'Text',
View: 'View',
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }
}))
vi.mock('lucide-react-native', () => ({ Copy: 'Copy', Check: 'Check' }))
vi.mock('../components/MobileSelectableText', () => ({ MobileSelectableText: 'Text' }))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText: vi.fn() }) }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
vi.mock('./MobileNativeChatReasoningRow', () => ({ MobileNativeChatReasoningRow: 'Reasoning' }))
vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnStatus: 'TurnStatus' }))
vi.mock('./MobileNativeChatToolRun', () => ({ ToolRun: 'ToolRun' }))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
const DELAY = NATIVE_CHAT_TEXT_REVEAL_DELAY_MS
const first = 'word '.repeat(40)
const second = first + 'more '.repeat(40)
describe('a growing reply on the phone', () => {
let renderer: ReactTestRenderer | undefined
let chat: MobileNativeChatReplyReveals
function render(text: string | string[], working = true, runningTool = false): void {
const message: NativeChatMessage = {
id: 'a1',
role: 'assistant',
source: 'transcript',
timestamp: null,
blocks: (typeof text === 'string' ? [text] : text).map((part) => ({
type: 'text',
text: part
}))
}
if (runningTool) {
message.blocks.push({ type: 'tool-call', name: 'Read', input: {}, state: 'running' })
}
const row = createElement(MobileNativeChatMessage, {
message,
mayStillGrow: working,
activeTurnIsWorking: true
})
const tree = createElement(MobileNativeChatReplyRevealsContext.Provider, { value: chat }, row)
act(() => {
if (renderer) {
renderer.update(tree)
} else {
renderer = create(tree)
}
})
}
function contents(): string[] {
return renderer!.root
.findAll((node) => String(node.type) === 'MobileMarkdown')
.map((node) => String(node.props.content))
}
function drawn(): number {
return contents().join('').length
}
function advance(ms: number): void {
act(() => {
vi.advanceTimersByTime(ms)
})
}
function remount(text: string | string[]): void {
act(() => renderer?.unmount())
renderer = undefined
render(text)
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
vi.spyOn(performance, 'now').mockImplementation(() => Date.now())
vi.stubGlobal('requestAnimationFrame', (step: () => void) => setTimeout(step, 16))
vi.stubGlobal('cancelAnimationFrame', (frame: number) => clearTimeout(frame))
// A reply that began while the chat was open.
const tracker = createNativeChatReplyRevealTracker()
trackNativeChatReplyRows(tracker, ['u1'], new Set(['u1']))
const reveals = trackNativeChatReplyRows(tracker, ['u1', 'a1'], new Set(['u1', 'a1']))
chat = { ...reveals, reducedMotion: false }
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = undefined
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('reveals each chunk over time, and a remounted row resumes instead of replaying', () => {
render(first)
expect(drawn()).toBe('word'.length)
advance(DELAY / 2)
expect(drawn()).toBeGreaterThan('word'.length)
expect(drawn()).toBeLessThan(first.length)
advance(DELAY)
expect(drawn()).toBe(first.length)
render(second)
advance(DELAY / 2)
const before = drawn()
expect(before).toBeGreaterThan(first.length)
expect(before).toBeLessThan(second.length)
// The list recycles the row while it is still revealing.
remount(second)
expect(drawn()).toBeGreaterThanOrEqual(before)
expect(drawn()).toBeLessThan(second.length)
advance(DELAY)
expect(drawn()).toBe(second.length)
})
it.each([false, true])('paces a second prose block after the first caught up: %s', (caughtUp) => {
render(first)
advance(caughtUp ? DELAY : DELAY / 2)
render([first, second])
if (!caughtUp) {
expect(contents()[0].length).toBeLessThan(first.length)
}
expect(contents()[1].length).toBeLessThan(second.length)
advance(DELAY / 2)
const before = drawn()
expect(before).toBeGreaterThan(first.length)
expect(before).toBeLessThan(first.length + second.length)
remount([first, second])
expect(drawn()).toBeGreaterThanOrEqual(before)
expect(drawn()).toBeLessThan(first.length + second.length)
advance(DELAY)
expect(contents()).toEqual([first, second])
})
it('does not replay read text when a question ends while the row is unmounted', () => {
render(first)
advance(DELAY)
render(first, false)
expect(chat.drawn.has('a1')).toBe(false)
act(() => renderer?.unmount())
renderer = undefined
// The question clears while the row remains outside the virtualized window.
render(first)
expect(drawn()).toBe(first.length)
render(second)
expect(drawn()).toBeLessThan(second.length)
advance(DELAY)
expect(drawn()).toBe(second.length)
})
it('draws completed prose in full on first mount beside a running tool', () => {
render(first, true, true)
expect(contents()).toEqual([first])
const tool = renderer!.root.find((node) => String(node.type) === 'ToolRun')
expect(tool.props.blocks[0].state).toBe('running')
})
it('draws the text as it arrives with reduced motion', () => {
chat = { ...chat, reducedMotion: true }
render(second)
expect(drawn()).toBe(second.length)
})
})
+37 -145
View File
@@ -1,6 +1,6 @@
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { memo, useCallback, useContext, useState } from 'react'
import { Image, Pressable, Text as NativeText, View } from 'react-native'
import { Text as NativeText, View } from 'react-native'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
import { MobileNativeChatLongPressContent as Content } from './MobileNativeChatLongPressContent'
@@ -15,23 +15,13 @@ import {
isRenderableSubagentGroup,
withoutSubagentGroupTwins
} from '../../../src/shared/native-chat-subagent-summary'
import type {
NativeChatBlock,
NativeChatImageRefBlock,
NativeChatMessage
} from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileSourceCopyButton } from '../components/MobileSourceCopyButton'
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content'
import { MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow'
import {
AGENT_SESSION_HOST_STATUS_COPY,
isAgentSessionHostStatusPresentation
} from '../../../src/shared/agent-session-host-status-rows'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import { MobileNativeChatNoticeRow } from './MobileNativeChatNoticeRow'
import { MobileNativeChatImageViewerContext } from './mobile-native-chat-image-viewer-context'
import { MobileNativeChatUserMessageFold } from './MobileNativeChatUserMessageFold'
import { MobileNativeChatAwaitingInputRow } from './MobileNativeChatAwaitingInputRow'
import { MobileNativeChatResolutionReceipt } from './MobileNativeChatResolutionReceipt'
@@ -43,118 +33,11 @@ import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-r
import { MobileNativeChatSubagentGroup } from './MobileNativeChatSubagentGroup'
import { ToolRun } from './MobileNativeChatToolRun'
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import {
CHAT_PROSE_SCALE,
scaledType,
styles,
TEXT_SIZE
} from './mobile-native-chat-message-styles'
import { styles } from './mobile-native-chat-message-styles'
import { agentMessageAttribution } from './mobile-agent-message-attribution'
import { withoutPendingNativeChatVisualDirectiveTail } from '../../../src/shared/native-chat-visual-directive'
import {
MobileNativeChatVisualContext,
type MobileNativeChatVisualRender
} from './mobile-native-chat-visual-context'
function Prose({
block,
invert,
fontScale,
onOpenFile,
onLongPress,
renderVisual,
holdPendingVisual = false
}: {
block: NativeChatBlock
invert?: boolean
fontScale: number
/** Assistant prose of a structured chat only. */
renderVisual?: MobileNativeChatVisualRender
/** The reply may still be growing: a directive still being typed at its tail is held back. */
holdPendingVisual?: boolean
onOpenFile?: (relativePath: string) => void
/** Android only: routes a long press on a link span to the row's actions sheet. */
onLongPress?: () => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
// A host line standing in for a message too large to send, on the person's own row.
if (isAgentSessionHostStatusPresentation(block.presentation)) {
return (
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.hostNotice, scaledType(TEXT_SIZE, fontScale)]}
>
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
</Text>
)
}
if (invert) {
return (
<MobileMarkdown
content={block.text}
tone="onBright"
rangeSelectable
textScale={CHAT_PROSE_SCALE * fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
)
}
return (
<MobileMarkdown
content={
holdPendingVisual && renderVisual
? withoutPendingNativeChatVisualDirectiveTail(block.text)
: block.text
}
renderVisual={renderVisual}
rangeSelectable
textScale={CHAT_PROSE_SCALE * fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
)
}
if (isImageRefBlock(block)) {
return <ImageAttachment block={block} fontScale={fontScale} onLongPress={onLongPress} />
}
return null
}
/** A local preview (composer echo) or real URL draws as a thumbnail that opens full size; a bare
* host path (not loadable on the device) falls back to a text placeholder. */
function ImageAttachment({
block,
fontScale,
onLongPress
}: {
block: NativeChatImageRefBlock
fontScale: number
onLongPress?: () => void
}): React.JSX.Element {
const openImage = useContext(MobileNativeChatImageViewerContext)
const uri = block.url ?? block.path
const label = block.alt ?? 'Attached image'
if (!isRenderableImageUri(uri)) {
return (
<NativeText style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
</NativeText>
)
}
return (
<Pressable
onPress={() => openImage({ uri, label })}
onLongPress={onLongPress}
accessibilityRole="imagebutton"
accessibilityLabel={label}
accessibilityHint="Opens the image full size"
>
<Image source={{ uri }} style={styles.imageThumb} resizeMode="contain" />
</Pressable>
)
}
import { MobileNativeChatVisualContext } from './mobile-native-chat-visual-context'
import { MobileNativeChatPacedProse } from './MobileNativeChatPacedProse'
import { Prose } from './MobileNativeChatProse'
function MobileNativeChatMessageImpl({
message,
@@ -328,28 +211,30 @@ function MobileNativeChatMessageImpl({
message.role === 'user' || message.role === 'assistant'
? nativeChatMessagePlainText(message)
: ''
const proseRows = (blocks: NativeChatBlock[]) =>
blocks.map((block, index) =>
isSubagentGroupBlock(block) && isRenderableSubagentGroup(block) ? (
<MobileNativeChatSubagentGroup
key={`subagent-group:${block.groupId}`}
block={block}
open={subagentGroupsOpen?.has(block.groupId) === true}
onToggle={onToggleSubagentGroup}
/>
) : isSystem ? null : (
<Prose
key={index}
block={block}
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
renderVisual={message.role === 'assistant' ? renderVisual : undefined}
holdPendingVisual={block === growingBlock}
/>
)
const proseRow = (block: NativeChatBlock, index: number, text: string, revealing = false) =>
isSubagentGroupBlock(block) && isRenderableSubagentGroup(block) ? (
<MobileNativeChatSubagentGroup
key={`subagent-group:${block.groupId}`}
block={block}
open={subagentGroupsOpen?.has(block.groupId) === true}
onToggle={onToggleSubagentGroup}
/>
) : isSystem ? null : (
<Prose
key={index}
block={block}
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
renderVisual={message.role === 'assistant' ? renderVisual : undefined}
text={text}
growing={block === growingBlock}
revealing={revealing}
/>
)
const proseRows = (blocks: NativeChatBlock[]) =>
blocks.map((block, index) => proseRow(block, index, isTextBlock(block) ? block.text : ''))
return (
<>
{/* A turn with no user bubble carries its bar above its first row. */}
@@ -391,7 +276,14 @@ function MobileNativeChatMessageImpl({
</MobileNativeChatUserMessageFold>
</>
) : (
proseRows(drawnProse)
<MobileNativeChatPacedProse
rowKey={message.id}
blocks={drawnProse}
growingBlock={growingBlock}
paced={message.role === 'assistant'}
>
{(block, index, shown) => proseRow(block, index, shown.text, shown.revealing)}
</MobileNativeChatPacedProse>
)}
{askRow}
{showToolRun ? (
@@ -11,6 +11,7 @@ vi.mock('react-native', () => ({
}))
vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' }))
vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => null }))
vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' }))
vi.mock('./MobileNativeChatBackgroundTasks', () => ({
MobileNativeChatBackgroundTasks: 'BackgroundTasks'
+73 -66
View File
@@ -8,6 +8,10 @@ import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-str
import { useMobileNativeChatComposerTray } from './use-mobile-native-chat-composer-tray'
import { useMobileNativeChatVisualRenderer } from './MobileNativeChatVisual'
import { MobileNativeChatVisualContext } from './mobile-native-chat-visual-context'
import {
MobileNativeChatReplyRevealsContext,
useMobileNativeChatReplyReveals
} from './mobile-native-chat-reply-reveals'
import { MobileNativeChatImageViewerHost } from './MobileNativeChatImageViewer'
type Props = {
@@ -89,6 +93,7 @@ export function MobileNativeChatOverlay({
backgroundTasks: controller.nativeChatBackgroundTasks
})
const visuals = useMobileNativeChatVisualRenderer(controller.nativeChatVisualSource)
const replyReveals = useMobileNativeChatReplyReveals(folded, streaming !== null, sendSurfaceId)
if (!controller.showNativeChat) {
return null
}
@@ -96,72 +101,74 @@ export function MobileNativeChatOverlay({
<MobileNativeChatImageViewerHost conversation={{ surfaceId: sendSurfaceId, sessionId }}>
<View style={styles.overlay}>
<MobileNativeChatVisualContext.Provider value={visuals}>
<MobileNativeChatView
messages={session.messages}
folded={folded}
status={session.status}
error={session.error}
readFailedFinally={session.readFailedFinally === true}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
structuredActivityUi={controller.nativeChatStructured}
turnIndicator={controller.nativeChatTurnIndicator}
workingStartedAt={controller.nativeChatWorkingStartedAt}
settledTurns={controller.nativeChatSettledTurns}
turnJournal={controller.nativeChatTurnJournal}
receipts={controller.nativeChatReceipts}
actionablePromptIds={controller.nativeChatActionablePromptIds}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
askKey={controller.nativeChatAskKey}
promptKey={controller.nativeChatPromptKey}
onDismissAsk={controller.dismissNativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
onCancelPrompt={controller.handleNativeChatCancelPrompt}
onCollapseAsk={controller.collapseNativeChatAsk}
onCollapsePrompt={controller.collapseNativeChatPrompt}
collapsedPrompt={controller.nativeChatCollapsedPrompt}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
composerTray={composerTray}
onOpenFile={onOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
sendSurfaceId={sendSurfaceId}
getSendCompletionGeneration={getSendCompletionGeneration}
getComposerEditGeneration={controller.getChatComposerEditGeneration}
pending={controller.chatPending}
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={
controller.nativeChatAcceptsImages
? () => void images.attachImage('library')
: undefined
}
attachments={images.attachments}
onRemoveAttachment={images.removeAttachment}
isAttaching={images.isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
inputLockReason={inputLockReason}
sendErrorMessage={sendErrorMessage}
onClearSendError={onClearSendError}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
sessionOptions={controller.nativeChatSessionOptions}
keyboardInset={keyboardInset}
/>
<MobileNativeChatReplyRevealsContext.Provider value={replyReveals}>
<MobileNativeChatView
messages={session.messages}
folded={folded}
status={session.status}
error={session.error}
readFailedFinally={session.readFailedFinally === true}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
structuredActivityUi={controller.nativeChatStructured}
turnIndicator={controller.nativeChatTurnIndicator}
workingStartedAt={controller.nativeChatWorkingStartedAt}
settledTurns={controller.nativeChatSettledTurns}
turnJournal={controller.nativeChatTurnJournal}
receipts={controller.nativeChatReceipts}
actionablePromptIds={controller.nativeChatActionablePromptIds}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
askKey={controller.nativeChatAskKey}
promptKey={controller.nativeChatPromptKey}
onDismissAsk={controller.dismissNativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
onCancelPrompt={controller.handleNativeChatCancelPrompt}
onCollapseAsk={controller.collapseNativeChatAsk}
onCollapsePrompt={controller.collapseNativeChatPrompt}
collapsedPrompt={controller.nativeChatCollapsedPrompt}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
composerTray={composerTray}
onOpenFile={onOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
sendSurfaceId={sendSurfaceId}
getSendCompletionGeneration={getSendCompletionGeneration}
getComposerEditGeneration={controller.getChatComposerEditGeneration}
pending={controller.chatPending}
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={
controller.nativeChatAcceptsImages
? () => void images.attachImage('library')
: undefined
}
attachments={images.attachments}
onRemoveAttachment={images.removeAttachment}
isAttaching={images.isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
inputLockReason={inputLockReason}
sendErrorMessage={sendErrorMessage}
onClearSendError={onClearSendError}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
sessionOptions={controller.nativeChatSessionOptions}
keyboardInset={keyboardInset}
/>
</MobileNativeChatReplyRevealsContext.Provider>
</MobileNativeChatVisualContext.Provider>
</View>
</MobileNativeChatImageViewerHost>
@@ -0,0 +1,41 @@
import { useMemo, type ReactNode } from 'react'
import { nativeChatProseToMarkdown } from '../../../src/shared/native-chat-prose'
import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types'
import {
useMobileNativeChatPacedText,
type MobileNativeChatPacedText
} from './use-mobile-native-chat-paced-text'
/** One message owns the reveal; frames redraw only its prose subtree. */
export function MobileNativeChatPacedProse({
rowKey,
blocks,
growingBlock,
paced,
children
}: {
rowKey: string
blocks: NativeChatBlock[]
growingBlock: NativeChatBlock | undefined
paced: boolean
children: (block: NativeChatBlock, index: number, shown: MobileNativeChatPacedText) => ReactNode
}): React.JSX.Element {
const source = useMemo(() => nativeChatProseToMarkdown(blocks), [blocks])
const shown = useMobileNativeChatPacedText(rowKey, source, growingBlock?.type === 'text', paced)
let end = 0
return (
<>
{blocks.map((block, index) => {
const text = isTextBlock(block) ? block.text : ''
// Match the canonical join while preserving independently parsed blocks and attachment order.
const start = end + (end > 0 && text.length > 0 ? 2 : 0)
end = start + text.length
const content = shown.text.slice(start, end)
return children(block, index, {
text: content,
revealing: shown.revealing && (block === growingBlock || content.length < text.length)
})
})}
</>
)
}
@@ -0,0 +1,128 @@
import { useContext } from 'react'
import { Image, Pressable, Text as NativeText } from 'react-native'
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
import type {
NativeChatBlock,
NativeChatImageRefBlock
} from '../../../src/shared/native-chat-types'
import {
AGENT_SESSION_HOST_STATUS_COPY,
isAgentSessionHostStatusPresentation
} from '../../../src/shared/agent-session-host-status-rows'
import { withoutPendingNativeChatVisualDirectiveTail } from '../../../src/shared/native-chat-visual-directive'
import { MobileNativeChatImageViewerContext } from './mobile-native-chat-image-viewer-context'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import {
CHAT_PROSE_SCALE,
scaledType,
styles,
TEXT_SIZE
} from './mobile-native-chat-message-styles'
import type { MobileNativeChatVisualRender } from './mobile-native-chat-visual-context'
export function Prose({
block,
text,
invert,
fontScale,
onOpenFile,
onLongPress,
renderVisual,
growing = false,
revealing = false
}: {
block: NativeChatBlock
text: string
invert?: boolean
fontScale: number
/** Assistant prose of a structured chat only. */
renderVisual?: MobileNativeChatVisualRender
/** The reply may still be growing: a directive still being typed at its tail is held back. */
growing?: boolean
revealing?: boolean
onOpenFile?: (relativePath: string) => void
/** Android only: routes a long press on a link span to the row's actions sheet. */
onLongPress?: () => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
// A host line standing in for a message too large to send, on the person's own row.
if (isAgentSessionHostStatusPresentation(block.presentation)) {
return (
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.hostNotice, scaledType(TEXT_SIZE, fontScale)]}
>
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
</Text>
)
}
if (invert) {
return (
<MobileMarkdown
content={text}
tone="onBright"
rangeSelectable
textScale={CHAT_PROSE_SCALE * fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
)
}
return (
<MobileMarkdown
content={
renderVisual && (growing || revealing)
? withoutPendingNativeChatVisualDirectiveTail(text)
: text
}
growing={revealing}
renderVisual={renderVisual}
rangeSelectable
textScale={CHAT_PROSE_SCALE * fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
)
}
if (isImageRefBlock(block)) {
return <ImageAttachment block={block} fontScale={fontScale} onLongPress={onLongPress} />
}
return null
}
/** A local preview (composer echo) or real URL draws as a thumbnail that opens full size; a bare
* host path (not loadable on the device) falls back to a text placeholder. */
function ImageAttachment({
block,
fontScale,
onLongPress
}: {
block: NativeChatImageRefBlock
fontScale: number
onLongPress?: () => void
}): React.JSX.Element {
const openImage = useContext(MobileNativeChatImageViewerContext)
const uri = block.url ?? block.path
const label = block.alt ?? 'Attached image'
if (!isRenderableImageUri(uri)) {
return (
<NativeText style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
</NativeText>
)
}
return (
<Pressable
onPress={() => openImage({ uri, label })}
onLongPress={onLongPress}
accessibilityRole="imagebutton"
accessibilityLabel={label}
accessibilityHint="Opens the image full size"
>
<Image source={{ uri }} style={styles.imageThumb} resizeMode="contain" />
</Pressable>
)
}
@@ -67,6 +67,7 @@ vi.mock('lucide-react-native', () =>
)
vi.mock('../MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' }))
vi.mock('../MobileNativeChatVisual', () => ({ useMobileNativeChatVisualRenderer: () => null }))
vi.mock('../../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => null }))
vi.mock('../MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' }))
vi.mock('../MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('../MobileAgentWorkingIndicator', () => ({
@@ -44,6 +44,7 @@ vi.mock('react-native', async () => {
View: 'View'
}
})
vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => null }))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
@@ -5,6 +5,7 @@ import {
} from '../../../src/shared/native-chat-empty-state'
import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journal-producer'
import { stripNoiseMessages } from '../../../src/shared/native-chat-noise'
import { nativeChatStreamingMessage } from '../../../src/shared/native-chat-streaming'
import { isWordlessProviderFrameMessage } from '../../../src/shared/native-chat-provider-frame-summary'
import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold'
import { foldSubagentItems } from '../../../src/shared/native-chat-subagent-items'
@@ -203,13 +204,7 @@ export function buildMobileNativeChatTransientData({
}
}
if (streaming) {
data.push({
id: 'streaming',
role: 'assistant',
blocks: [{ type: 'text', text: streaming }],
timestamp: null,
source: 'hook'
})
data.push(nativeChatStreamingMessage(streaming))
}
data.push(...trailingPending)
return { folded: renderedFolded, streaming, data }
@@ -0,0 +1,40 @@
import { createContext, useMemo, useState } from 'react'
import { NATIVE_CHAT_STREAMING_ID } from '../../../src/shared/native-chat-streaming'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
createNativeChatReplyRevealTracker,
trackNativeChatReplyRows,
type NativeChatReplyReveals
} from '../../../src/shared/native-chat-reply-reveals'
import { useReducedMotionEnabled } from '../platform/use-reduced-motion'
/** One chat's reveals, held above its list so a row the list remounts, or a chat reopened from
* the terminal, resumes instead of replaying. */
export type MobileNativeChatReplyReveals = NativeChatReplyReveals & { reducedMotion: boolean }
/** Absent outside a chat: text draws as it arrives. */
export const MobileNativeChatReplyRevealsContext =
createContext<MobileNativeChatReplyReveals | null>(null)
export function useMobileNativeChatReplyReveals(
folded: readonly NativeChatMessage[],
streaming: boolean,
scopeKey: string
): MobileNativeChatReplyReveals {
const [held] = useState(() => ({ scopeKey, tracker: createNativeChatReplyRevealTracker() }))
// Row keys are only unique within one chat, so another chat starts its own.
if (held.scopeKey !== scopeKey) {
held.scopeKey = scopeKey
held.tracker = createNativeChatReplyRevealTracker()
}
// Once per transcript change, not per streamed chunk.
const rowKeys = useMemo(() => {
const keys = folded.map((message) => message.id)
return streaming ? [...keys, NATIVE_CHAT_STREAMING_ID] : keys
}, [folded, streaming])
const loaded = useMemo(() => new Set(rowKeys), [rowKeys])
const reveals = trackNativeChatReplyRows(held.tracker, rowKeys, loaded)
// Until the OS answers, text draws as it arrives.
const reducedMotion = useReducedMotionEnabled() !== false
return useMemo(() => ({ ...reveals, reducedMotion }), [reveals, reducedMotion])
}
@@ -0,0 +1,69 @@
import { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
playNativeChatTextReveal,
receiveNativeChatText,
startNativeChatTextReveal,
type NativeChatTextRevealFrames
} from '../../../src/shared/native-chat-text-reveal'
import { MobileNativeChatReplyRevealsContext } from './mobile-native-chat-reply-reveals'
const FRAMES: NativeChatTextRevealFrames = {
now: () => performance.now(),
request: (step) => requestAnimationFrame(step),
cancel: (frame) => cancelAnimationFrame(frame)
}
export type MobileNativeChatPacedText = { text: string; revealing: boolean }
/** How much of a reply to draw now: all of it unless `paced` in a chat with motion allowed. */
export function useMobileNativeChatPacedText(
rowKey: string,
text: string,
streaming: boolean,
paced: boolean
): MobileNativeChatPacedText {
const chat = useContext(MobileNativeChatReplyRevealsContext)
const list = paced ? (chat ?? undefined) : undefined
const immediate = list === undefined || list.reducedMotion
const [initial] = useState(() =>
startNativeChatTextReveal(
text,
streaming,
list?.drawn.get(rowKey),
list?.begun.has(rowKey) === true,
FRAMES.now()
)
)
const pace = useRef(initial)
const [shown, setShown] = useState(initial.shown)
useLayoutEffect(() => {
pace.current = receiveNativeChatText(pace.current, text, streaming, immediate, FRAMES.now())
setShown(pace.current.shown)
list?.drawn.set(rowKey, pace.current)
list?.begun.delete(rowKey)
}, [text, streaming, immediate, list, rowKey])
const behind = !immediate && shown < text.length
useEffect(() => {
if (!behind) {
if (!streaming) {
list?.drawn.delete(rowKey)
}
return
}
return playNativeChatTextReveal(
() => pace.current,
(next) => {
pace.current = next
list?.drawn.set(rowKey, next)
setShown(next.shown)
},
FRAMES
)
}, [behind, text, list, rowKey, streaming])
return immediate
? { text, revealing: false }
: { text: text.slice(0, Math.min(shown, text.length)), revealing: streaming || behind }
}
@@ -46,4 +46,15 @@ describe('useMobileNativeChatTurnDisclosure growing row', () => {
messages.map((message, index) => disclosure.resolveRow(index, message).mayStillGrow)
).toEqual([false, false, true, false])
})
it('puts only the terminal-backed streaming row in the live turn', () => {
const messages = [userMessage('u1'), assistantMessage('a1'), assistantMessage('streaming')]
act(() => {
renderer = create(createElement(Harness, { messages, enabled: false }))
})
const { disclosure } = renderer!.root.findByType(Result).props
expect(
messages.map((message, index) => disclosure.resolveRow(index, message).activeTurnIsWorking)
).toEqual([false, false, true])
})
})
@@ -12,6 +12,7 @@ import {
type NativeChatTurnJournal
} from '../../../src/shared/native-chat-turn-membership'
import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../src/shared/native-chat-messages-waiting-behind-live-turn'
import { NATIVE_CHAT_STREAMING_ID } from '../../../src/shared/native-chat-streaming'
import {
nativeChatRowsInDrawOrder,
nativeChatTurnBarRows
@@ -233,8 +234,11 @@ export function useMobileNativeChatTurnDisclosure({
// transcript, defeating the row's memo; caching one per turn would mean
// writing a ref during render, which react-freeze can discard.
turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined,
// With no user boundary at all, the session's working state stays authoritative.
activeTurnIsWorking: inLiveWorkingTurn(index),
// With no user boundary at all, the session's working state stays authoritative. The bridge
// lane has no turns: only its synthetic streaming row is known to be in the live one.
activeTurnIsWorking: enabled
? inLiveWorkingTurn(index)
: isWorking && message.id === NATIVE_CHAT_STREAMING_ID,
mayStillGrow: !lineYields && message.id === latestAssistantId,
reasoningIsLive: message.id === liveReasoningId,
reasoningExpanded:
@@ -266,6 +270,7 @@ export function useMobileNativeChatTurnDisclosure({
toggleReasoning,
openSubagentGroups,
toggleSubagentGroup,
isWorking,
openUserMessages,
toggleUserMessage
]
@@ -11,7 +11,7 @@ import {
NativeChatReplyRevealsContext,
type NativeChatReplyReveals
} from './native-chat-reply-reveals'
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS } from './native-chat-text-reveal'
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS } from '../../../../shared/native-chat-text-reveal'
vi.mock('@/hooks/usePrefersReducedMotion', () => ({ usePrefersReducedMotion: () => false }))
vi.mock('./native-chat-visual-markdown-extension', () => ({
@@ -6,7 +6,7 @@ import {
NativeChatReplyRevealsContext,
type NativeChatReplyReveals
} from './native-chat-reply-reveals'
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS } from './native-chat-text-reveal'
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS } from '../../../../shared/native-chat-text-reveal'
const reveals: NativeChatReplyReveals = { begun: new Set(['reply']), drawn: new Map() }
@@ -1,17 +1,11 @@
// Which replies an open transcript is still drawing, and how far each has got.
import { createContext, useState } from 'react'
import type { NativeChatTextReveal } from './native-chat-text-reveal'
import {
createNativeChatReplyRevealTracker,
trackNativeChatReplyRows,
type NativeChatReplyReveals
} from '../../../../shared/native-chat-reply-reveals'
/** What one open transcript knows about its replies being drawn. Per transcript, because row
* keys are only unique within one: a legacy preview's live row has the same key in every chat. */
export type NativeChatReplyReveals = {
/** Rows that first appeared at the end of the transcript while it was open: replies that
* began while the reader was watching, not ones already written when the pane opened. */
begun: Set<string>
/** How much of a row was drawn when it left the window, so returning does not replay it. */
drawn: Map<string, NativeChatTextReveal>
}
export type { NativeChatReplyReveals }
/** Absent outside a transcript: such a row neither begins nor is remembered. */
export const NativeChatReplyRevealsContext = createContext<NativeChatReplyReveals | null>(null)
@@ -21,46 +15,6 @@ export function useNativeChatReplyReveals(
rowKeys: readonly string[],
loadedMessageIds: ReadonlySet<string>
): NativeChatReplyReveals {
const [state] = useState<{
reveals: NativeChatReplyReveals
rowKeys: readonly string[]
loadedMessageIds: ReadonlySet<string> | null
known: Set<string>
}>(() => ({
reveals: { begun: new Set(), drawn: new Map() },
rowKeys: [],
loadedMessageIds: null,
known: new Set()
}))
// In render, so a row's first draw already knows. Once per change of rows or loaded messages,
// so it is idempotent.
if (state.rowKeys !== rowKeys || state.loadedMessageIds !== loadedMessageIds) {
const present = new Set(rowKeys)
const lastKey = rowKeys.at(-1)
const { begun, drawn } = state.reveals
// Progress belongs to the row that made it: a later row carrying the key starts its own.
for (const key of [...begun, ...drawn.keys()]) {
if (!present.has(key)) {
begun.delete(key)
drawn.delete(key)
}
}
// Known while loaded, so a row that leaves and returns (a section closed and reopened) is not
// taken for a reply beginning.
if (lastKey !== undefined && !state.known.has(lastKey) && state.known.size > 0) {
begun.add(lastKey)
}
// Only message rows reveal, and only loaded ones can return, so nothing else is kept.
for (const key of state.known) {
if (!loadedMessageIds.has(key)) {
state.known.delete(key)
}
}
for (const key of rowKeys) {
state.known.add(key)
}
state.rowKeys = rowKeys
state.loadedMessageIds = loadedMessageIds
}
return state.reveals
const [state] = useState(createNativeChatReplyRevealTracker)
return trackNativeChatReplyRows(state, rowKeys, loadedMessageIds)
}
@@ -1,76 +0,0 @@
import { isSpacelessScriptChar } from '@/components/sidebar/comment-markdown-word-fade'
export const NATIVE_CHAT_TEXT_REVEAL_DELAY_MS = 350
type TextArrival = { from: number; to: number; at: number }
export type NativeChatTextReveal = {
source: string
shown: number
arrivals: TextArrival[]
}
function endsWord(char: string): boolean {
return /\s/.test(char) || isSpacelessScriptChar(char)
}
export function pacedTextEnd(text: string, position: number, holdPartialWord: boolean): number {
const from = Math.floor(position)
if (from >= text.length) {
return text.length
}
let end = from
while (end < text.length && !endsWord(text[end])) {
end += 1
}
if (end < text.length || !holdPartialWord) {
return end
}
let start = from
while (start > 0 && !endsWord(text[start - 1])) {
start -= 1
}
return start
}
export function arriveNativeChatText(
previous: NativeChatTextReveal,
source: string,
at: number
): NativeChatTextReveal {
if (source === previous.source) {
return previous
}
if (!source.startsWith(previous.source)) {
return { source, shown: source.length, arrivals: [] }
}
return {
...previous,
source,
arrivals: [...previous.arrivals, { from: previous.source.length, to: source.length, at }]
}
}
/** Each arrival has its own deadline; later arrivals can only bring earlier text forward. */
export function advanceNativeChatText(
previous: NativeChatTextReveal,
now: number
): NativeChatTextReveal {
let position = previous.arrivals[0]?.from ?? previous.shown
let due = previous.shown
for (const arrival of previous.arrivals) {
const fraction = Math.min(1, Math.max(0, (now - arrival.at) / NATIVE_CHAT_TEXT_REVEAL_DELAY_MS))
position += (arrival.to - arrival.from) * fraction
if (fraction === 1) {
due = Math.max(due, arrival.to)
}
}
const shown = Math.max(due, pacedTextEnd(previous.source, position, due < previous.source.length))
return {
...previous,
shown: Math.max(previous.shown, shown),
arrivals: previous.arrivals.filter(
(arrival) => arrival.at + NATIVE_CHAT_TEXT_REVEAL_DELAY_MS > now
)
}
}
@@ -6,7 +6,7 @@ import {
NativeChatReplyRevealsContext,
type NativeChatReplyReveals
} from './native-chat-reply-reveals'
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS as D } from './native-chat-text-reveal'
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS as D } from '../../../../shared/native-chat-text-reveal'
import { useNativeChatPacedText } from './use-native-chat-paced-text'
const motion = vi.hoisted(() => ({ reduced: false }))
@@ -2,14 +2,22 @@ import { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'
import { NativeChatReplyRevealsContext } from './native-chat-reply-reveals'
import {
advanceNativeChatText,
arriveNativeChatText,
NATIVE_CHAT_TEXT_REVEAL_DELAY_MS,
type NativeChatTextReveal
} from './native-chat-text-reveal'
nativeChatTextAppended,
playNativeChatTextReveal,
receiveNativeChatText,
startNativeChatTextReveal,
type NativeChatTextReveal,
type NativeChatTextRevealFrames
} from '../../../../shared/native-chat-text-reveal'
const WORD_FADE_MS = 300
const FRAMES: NativeChatTextRevealFrames = {
now: () => performance.now(),
request: (step) => requestAnimationFrame(step),
cancel: (frame) => cancelAnimationFrame(frame)
}
export type NativeChatPacedText = { text: string; revealing: boolean; fading: boolean }
export function useNativeChatPacedText(
@@ -19,37 +27,31 @@ export function useNativeChatPacedText(
): NativeChatPacedText {
const reducedMotion = usePrefersReducedMotion()
const reveals = useContext(NativeChatReplyRevealsContext)
const [initial] = useState<NativeChatTextReveal>(() => {
const remembered = reveals?.drawn.get(rowKey)
// Text received while unmounted has no arrival timestamp: show it without adding latency.
if (remembered?.source === text && streaming) {
return advanceNativeChatText(remembered, performance.now())
}
const shown = streaming && !remembered && reveals?.begun.has(rowKey) ? 0 : text.length
return arriveNativeChatText(
{ source: text.slice(0, shown), shown, arrivals: [] },
const [initial] = useState<NativeChatTextReveal>(() =>
startNativeChatTextReveal(
text,
streaming,
reveals?.drawn.get(rowKey),
reveals?.begun.has(rowKey) === true,
performance.now()
)
})
)
const pace = useRef(initial)
const [shown, setShown] = useState(initial.shown)
const [grew, setGrew] = useState(initial.shown < text.length)
useLayoutEffect(() => {
const previous = pace.current
const appended = text.startsWith(previous.source) && text.length > previous.source.length
if (streaming && appended) {
if (streaming && nativeChatTextAppended(previous, text)) {
setGrew(true)
}
const immediate =
reducedMotion || (!streaming && !appended && previous.shown === previous.source.length)
pace.current = immediate
? { source: text, shown: text.length, arrivals: [] }
: advanceNativeChatText(
arriveNativeChatText(previous, text, performance.now()),
performance.now()
)
pace.current = receiveNativeChatText(
previous,
text,
streaming,
reducedMotion,
performance.now()
)
setShown(pace.current.shown)
reveals?.drawn.set(rowKey, pace.current)
}, [text, streaming, reducedMotion, reveals, rowKey])
@@ -62,30 +64,15 @@ export function useNativeChatPacedText(
}
return
}
let frame = 0
let deadline = 0
const tick = (): void => {
cancelAnimationFrame(frame)
window.clearTimeout(deadline)
const now = performance.now()
const next = advanceNativeChatText(pace.current, now)
pace.current = next
reveals?.drawn.set(rowKey, next)
setShown(next.shown)
if (next.shown >= next.source.length) {
return
}
frame = requestAnimationFrame(tick)
const earliest = Math.min(
...next.arrivals.map((arrival) => arrival.at + NATIVE_CHAT_TEXT_REVEAL_DELAY_MS)
)
deadline = window.setTimeout(tick, Math.max(0, earliest - now))
}
tick()
return () => {
cancelAnimationFrame(frame)
window.clearTimeout(deadline)
}
return playNativeChatTextReveal(
() => pace.current,
(next) => {
pace.current = next
reveals?.drawn.set(rowKey, next)
setShown(next.shown)
},
FRAMES
)
}, [behind, text, reveals, rowKey, streaming])
const revealing = !reducedMotion && ((streaming && grew) || behind)
@@ -3,20 +3,12 @@
// text mounts only the new ones.
import type { Element, Root } from 'hast'
import { SPACELESS_SCRIPT_RANGES } from '../../../../shared/native-chat-text-reveal'
/** Elements whose renderers read their text, or that are faded in as a whole. */
const UNSPLIT_TAGS = new Set(['a', 'code', 'pre', 'kbd', 'svg', 'math'])
/** Scripts written without spaces, where each character is its own word. */
const SPACELESS_SCRIPT_RANGES = String.raw`\u2e80-\ud7ff\uf900-\uffef`
const SPACELESS_SCRIPT_CHAR = new RegExp(`[${SPACELESS_SCRIPT_RANGES}]`, 'u')
/** Shared with whatever paces the text these words are drawn from, so both end words alike. */
export function isSpacelessScriptChar(char: string): boolean {
return SPACELESS_SCRIPT_CHAR.test(char)
}
/** Whitespace, one character of such a script, or a run of anything else. */
/** Whitespace, one character of a script written without spaces, or a run of anything else. */
const WORD_PATTERN = new RegExp(
`\\s+|[${SPACELESS_SCRIPT_RANGES}]|[^\\s${SPACELESS_SCRIPT_RANGES}]+`,
'gu'
+67
View File
@@ -0,0 +1,67 @@
// Which replies an open transcript is still drawing, and how far each has got.
import type { NativeChatTextReveal } from './native-chat-text-reveal'
/** What one open transcript knows about its replies being drawn. Per transcript, because row
* keys are only unique within one: a legacy preview's live row has the same key in every chat. */
export type NativeChatReplyReveals = {
/** Rows that first appeared at the end of the transcript while it was open: replies that
* began while the reader was watching, not ones already written when the pane opened. */
begun: Set<string>
/** How much of a row was drawn when it left the window, so returning does not replay it. */
drawn: Map<string, NativeChatTextReveal>
}
export type NativeChatReplyRevealTracker = {
reveals: NativeChatReplyReveals
rowKeys: readonly string[]
loadedMessageIds: ReadonlySet<string> | null
known: Set<string>
}
export function createNativeChatReplyRevealTracker(): NativeChatReplyRevealTracker {
return {
reveals: { begun: new Set(), drawn: new Map() },
rowKeys: [],
loadedMessageIds: null,
known: new Set()
}
}
/** Brings the reveals up to the transcript's rows. Runs in render, so a row's first draw already
* knows; once per change of rows or loaded messages, so it is idempotent. */
export function trackNativeChatReplyRows(
state: NativeChatReplyRevealTracker,
rowKeys: readonly string[],
loadedMessageIds: ReadonlySet<string>
): NativeChatReplyReveals {
if (state.rowKeys !== rowKeys || state.loadedMessageIds !== loadedMessageIds) {
const present = new Set(rowKeys)
const lastKey = rowKeys.at(-1)
const { begun, drawn } = state.reveals
// Progress belongs to the row that made it: a later row carrying the key starts its own.
for (const key of [...begun, ...drawn.keys()]) {
if (!present.has(key)) {
begun.delete(key)
drawn.delete(key)
}
}
// Known while loaded, so a row that leaves and returns (a section closed and reopened) is not
// taken for a reply beginning.
if (lastKey !== undefined && !state.known.has(lastKey) && state.known.size > 0) {
begun.add(lastKey)
}
// Only message rows reveal, and only loaded ones can return, so nothing else is kept.
for (const key of state.known) {
if (!loadedMessageIds.has(key)) {
state.known.delete(key)
}
}
for (const key of rowKeys) {
state.known.add(key)
}
state.rowKeys = rowKeys
state.loadedMessageIds = loadedMessageIds
}
return state.reveals
}
+162
View File
@@ -0,0 +1,162 @@
// A reply being written is drawn at a reading pace: every surface that draws one steps it here.
/** Scripts written without spaces, where each character is its own word. */
export const SPACELESS_SCRIPT_RANGES = String.raw`\u2e80-\ud7ff\uf900-\uffef`
const SPACELESS_SCRIPT_CHAR = new RegExp(`[${SPACELESS_SCRIPT_RANGES}]`, 'u')
/** Shared with whatever splits the drawn text into words, so both end words alike. */
export function isSpacelessScriptChar(char: string): boolean {
return SPACELESS_SCRIPT_CHAR.test(char)
}
export const NATIVE_CHAT_TEXT_REVEAL_DELAY_MS = 350
type TextArrival = { from: number; to: number; at: number }
export type NativeChatTextReveal = {
source: string
shown: number
arrivals: TextArrival[]
}
/** The surface's frame clock; the reveal steps once a frame. */
export type NativeChatTextRevealFrames = {
now: () => number
request: (step: () => void) => number
cancel: (frame: number) => void
}
function endsWord(char: string): boolean {
return /\s/.test(char) || isSpacelessScriptChar(char)
}
export function pacedTextEnd(text: string, position: number, holdPartialWord: boolean): number {
const from = Math.floor(position)
if (from >= text.length) {
return text.length
}
let end = from
while (end < text.length && !endsWord(text[end])) {
end += 1
}
if (end < text.length || !holdPartialWord) {
return end
}
let start = from
while (start > 0 && !endsWord(text[start - 1])) {
start -= 1
}
return start
}
export function arriveNativeChatText(
previous: NativeChatTextReveal,
source: string,
at: number
): NativeChatTextReveal {
if (source === previous.source) {
return previous
}
if (!source.startsWith(previous.source)) {
return { source, shown: source.length, arrivals: [] }
}
return {
...previous,
source,
arrivals: [...previous.arrivals, { from: previous.source.length, to: source.length, at }]
}
}
/** Each arrival has its own deadline; later arrivals can only bring earlier text forward. */
export function advanceNativeChatText(
previous: NativeChatTextReveal,
now: number
): NativeChatTextReveal {
let position = previous.arrivals[0]?.from ?? previous.shown
let due = previous.shown
for (const arrival of previous.arrivals) {
const fraction = Math.min(1, Math.max(0, (now - arrival.at) / NATIVE_CHAT_TEXT_REVEAL_DELAY_MS))
position += (arrival.to - arrival.from) * fraction
if (fraction === 1) {
due = Math.max(due, arrival.to)
}
}
const shown = Math.max(due, pacedTextEnd(previous.source, position, due < previous.source.length))
return {
...previous,
shown: Math.max(previous.shown, shown),
arrivals: previous.arrivals.filter(
(arrival) => arrival.at + NATIVE_CHAT_TEXT_REVEAL_DELAY_MS > now
)
}
}
/** Where a row's reveal starts when it mounts. `remembered` is how far it had got when it last
* left view; `begun` says the reply began while the reader was watching. */
export function startNativeChatTextReveal(
text: string,
streaming: boolean,
remembered: NativeChatTextReveal | undefined,
begun: boolean,
now: number
): NativeChatTextReveal {
// Text received while unmounted has no arrival timestamp: show it without adding latency.
if (remembered?.source === text && streaming) {
return advanceNativeChatText(remembered, now)
}
const shown = streaming && !remembered && begun ? 0 : text.length
return arriveNativeChatText({ source: text.slice(0, shown), shown, arrivals: [] }, text, now)
}
export function nativeChatTextAppended(previous: NativeChatTextReveal, text: string): boolean {
return text.startsWith(previous.source) && text.length > previous.source.length
}
/** The reveal once the row's text or streaming state changes. */
export function receiveNativeChatText(
previous: NativeChatTextReveal,
text: string,
streaming: boolean,
reducedMotion: boolean,
now: number
): NativeChatTextReveal {
const immediate =
reducedMotion ||
(!streaming &&
!nativeChatTextAppended(previous, text) &&
previous.shown === previous.source.length)
return immediate
? { source: text, shown: text.length, arrivals: [] }
: advanceNativeChatText(arriveNativeChatText(previous, text, now), now)
}
/** Steps the reveal every frame until all its text shows, and returns the cancel. A timer at the
* earliest deadline keeps it on time when frames stop, as they do in a hidden window. */
export function playNativeChatTextReveal(
current: () => NativeChatTextReveal,
step: (next: NativeChatTextReveal) => void,
frames: NativeChatTextRevealFrames
): () => void {
let frame = 0
let deadline: ReturnType<typeof setTimeout> | undefined
const tick = (): void => {
frames.cancel(frame)
clearTimeout(deadline)
const now = frames.now()
const next = advanceNativeChatText(current(), now)
step(next)
if (next.shown >= next.source.length) {
return
}
frame = frames.request(tick)
const earliest = Math.min(
...next.arrivals.map((arrival) => arrival.at + NATIVE_CHAT_TEXT_REVEAL_DELAY_MS)
)
deadline = setTimeout(tick, Math.max(0, earliest - now))
}
tick()
return () => {
frames.cancel(frame)
clearTimeout(deadline)
}
}