From b61b9da778f908076d0fa07adc386f84ff5fbd39 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 16 Oct 2026 18:51:18 -0700
Subject: [PATCH] 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.
---
...bileNativeChatMessage.paced-reveal.test.ts | 179 +++++++++++++++++
.../src/session/MobileNativeChatMessage.tsx | 182 ++++--------------
.../session/MobileNativeChatOverlay.test.ts | 1 +
.../src/session/MobileNativeChatOverlay.tsx | 139 ++++++-------
.../session/MobileNativeChatPacedProse.tsx | 41 ++++
mobile/src/session/MobileNativeChatProse.tsx | 128 ++++++++++++
.../__mocks__/mobile-prompt-controller.ts | 1 +
...chat-merged-snapshot-parity-hooks.test.tsx | 1 +
.../session/mobile-native-chat-render-data.ts | 9 +-
.../mobile-native-chat-reply-reveals.ts | 40 ++++
.../use-mobile-native-chat-paced-text.ts | 69 +++++++
...-chat-turn-disclosure-growing-row.test.tsx | 11 ++
.../use-mobile-native-chat-turn-disclosure.ts | 9 +-
...ativeChatPacedMarkdown.file-links.test.tsx | 2 +-
.../NativeChatPacedMarkdown.mermaid.test.tsx | 2 +-
.../native-chat/native-chat-reply-reveals.ts | 62 +-----
.../native-chat/native-chat-text-reveal.ts | 76 --------
...se-native-chat-paced-text.remount.test.tsx | 2 +-
.../native-chat/use-native-chat-paced-text.ts | 85 ++++----
.../sidebar/comment-markdown-word-fade.ts | 12 +-
src/shared/native-chat-reply-reveals.ts | 67 +++++++
.../native-chat-text-reveal.test.ts} | 0
src/shared/native-chat-text-reveal.ts | 162 ++++++++++++++++
23 files changed, 868 insertions(+), 412 deletions(-)
create mode 100644 mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts
create mode 100644 mobile/src/session/MobileNativeChatPacedProse.tsx
create mode 100644 mobile/src/session/MobileNativeChatProse.tsx
create mode 100644 mobile/src/session/mobile-native-chat-reply-reveals.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-paced-text.ts
delete mode 100644 src/renderer/src/components/native-chat/native-chat-text-reveal.ts
create mode 100644 src/shared/native-chat-reply-reveals.ts
rename src/{renderer/src/components/native-chat/use-native-chat-paced-text.test.ts => shared/native-chat-text-reveal.test.ts} (100%)
create mode 100644 src/shared/native-chat-text-reveal.ts
diff --git a/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts b/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts
new file mode 100644
index 00000000000..8f10f492335
--- /dev/null
+++ b/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts
@@ -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)
+ })
+})
diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx
index 2d7a3650231..8903271149d 100644
--- a/mobile/src/session/MobileNativeChatMessage.tsx
+++ b/mobile/src/session/MobileNativeChatMessage.tsx
@@ -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 (
-
- {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
-
- )
- }
- if (invert) {
- return (
-
- )
- }
- return (
-
- )
- }
- if (isImageRefBlock(block)) {
- return
- }
- 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 (
-
- 🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
-
- )
- }
- return (
- openImage({ uri, label })}
- onLongPress={onLongPress}
- accessibilityRole="imagebutton"
- accessibilityLabel={label}
- accessibilityHint="Opens the image full size"
- >
-
-
- )
-}
+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) ? (
-
- ) : isSystem ? null : (
-
- )
+ const proseRow = (block: NativeChatBlock, index: number, text: string, revealing = false) =>
+ isSubagentGroupBlock(block) && isRenderableSubagentGroup(block) ? (
+
+ ) : isSystem ? null : (
+
)
+ 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({
>
) : (
- proseRows(drawnProse)
+
+ {(block, index, shown) => proseRow(block, index, shown.text, shown.revealing)}
+
)}
{askRow}
{showToolRun ? (
diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts
index c9fe374286a..11dc676e878 100644
--- a/mobile/src/session/MobileNativeChatOverlay.test.ts
+++ b/mobile/src/session/MobileNativeChatOverlay.test.ts
@@ -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'
diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx
index 414e907b1dc..909d975f4f0 100644
--- a/mobile/src/session/MobileNativeChatOverlay.tsx
+++ b/mobile/src/session/MobileNativeChatOverlay.tsx
@@ -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({
- 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}
- />
+
+ 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}
+ />
+
diff --git a/mobile/src/session/MobileNativeChatPacedProse.tsx b/mobile/src/session/MobileNativeChatPacedProse.tsx
new file mode 100644
index 00000000000..4b04bf3249d
--- /dev/null
+++ b/mobile/src/session/MobileNativeChatPacedProse.tsx
@@ -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)
+ })
+ })}
+ >
+ )
+}
diff --git a/mobile/src/session/MobileNativeChatProse.tsx b/mobile/src/session/MobileNativeChatProse.tsx
new file mode 100644
index 00000000000..8b29a1db48c
--- /dev/null
+++ b/mobile/src/session/MobileNativeChatProse.tsx
@@ -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 (
+
+ {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
+
+ )
+ }
+ if (invert) {
+ return (
+
+ )
+ }
+ return (
+
+ )
+ }
+ if (isImageRefBlock(block)) {
+ return
+ }
+ 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 (
+
+ 🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
+
+ )
+ }
+ return (
+ openImage({ uri, label })}
+ onLongPress={onLongPress}
+ accessibilityRole="imagebutton"
+ accessibilityLabel={label}
+ accessibilityHint="Opens the image full size"
+ >
+
+
+ )
+}
diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts
index 4012118a6fd..beda8d07ff2 100644
--- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts
+++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts
@@ -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', () => ({
diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx
index 3440d3866c3..5c613ecfaa6 100644
--- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx
+++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx
@@ -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 })
}))
diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts
index 2ff9d993b97..0cabb0d00fe 100644
--- a/mobile/src/session/mobile-native-chat-render-data.ts
+++ b/mobile/src/session/mobile-native-chat-render-data.ts
@@ -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 }
diff --git a/mobile/src/session/mobile-native-chat-reply-reveals.ts b/mobile/src/session/mobile-native-chat-reply-reveals.ts
new file mode 100644
index 00000000000..330c026a194
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-reply-reveals.ts
@@ -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(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])
+}
diff --git a/mobile/src/session/use-mobile-native-chat-paced-text.ts b/mobile/src/session/use-mobile-native-chat-paced-text.ts
new file mode 100644
index 00000000000..261b4c0f8d2
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-paced-text.ts
@@ -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 }
+}
diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure-growing-row.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure-growing-row.test.tsx
index 90fda385bcc..74039f32f35 100644
--- a/mobile/src/session/use-mobile-native-chat-turn-disclosure-growing-row.test.tsx
+++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure-growing-row.test.tsx
@@ -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])
+ })
})
diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
index afe17f40a23..495a223b9b4 100644
--- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
+++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
@@ -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
]
diff --git a/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.file-links.test.tsx b/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.file-links.test.tsx
index 8b48277aa20..c320d19ea52 100644
--- a/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.file-links.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.file-links.test.tsx
@@ -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', () => ({
diff --git a/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx b/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx
index 8ebfcb70f00..87ec8efebad 100644
--- a/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx
@@ -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() }
diff --git a/src/renderer/src/components/native-chat/native-chat-reply-reveals.ts b/src/renderer/src/components/native-chat/native-chat-reply-reveals.ts
index 3aaeb9dbe46..48766c105d2 100644
--- a/src/renderer/src/components/native-chat/native-chat-reply-reveals.ts
+++ b/src/renderer/src/components/native-chat/native-chat-reply-reveals.ts
@@ -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
- /** How much of a row was drawn when it left the window, so returning does not replay it. */
- drawn: Map
-}
+export type { NativeChatReplyReveals }
/** Absent outside a transcript: such a row neither begins nor is remembered. */
export const NativeChatReplyRevealsContext = createContext(null)
@@ -21,46 +15,6 @@ export function useNativeChatReplyReveals(
rowKeys: readonly string[],
loadedMessageIds: ReadonlySet
): NativeChatReplyReveals {
- const [state] = useState<{
- reveals: NativeChatReplyReveals
- rowKeys: readonly string[]
- loadedMessageIds: ReadonlySet | null
- known: Set
- }>(() => ({
- 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)
}
diff --git a/src/renderer/src/components/native-chat/native-chat-text-reveal.ts b/src/renderer/src/components/native-chat/native-chat-text-reveal.ts
deleted file mode 100644
index 76fd8b7f894..00000000000
--- a/src/renderer/src/components/native-chat/native-chat-text-reveal.ts
+++ /dev/null
@@ -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
- )
- }
-}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-paced-text.remount.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-paced-text.remount.test.tsx
index c1df2f3f5c6..88b239e09b4 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-paced-text.remount.test.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-paced-text.remount.test.tsx
@@ -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 }))
diff --git a/src/renderer/src/components/native-chat/use-native-chat-paced-text.ts b/src/renderer/src/components/native-chat/use-native-chat-paced-text.ts
index 9ba76bf7697..ca2925c996b 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-paced-text.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-paced-text.ts
@@ -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(() => {
- 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(() =>
+ 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)
diff --git a/src/renderer/src/components/sidebar/comment-markdown-word-fade.ts b/src/renderer/src/components/sidebar/comment-markdown-word-fade.ts
index 420de8e6bfc..834ef904b14 100644
--- a/src/renderer/src/components/sidebar/comment-markdown-word-fade.ts
+++ b/src/renderer/src/components/sidebar/comment-markdown-word-fade.ts
@@ -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'
diff --git a/src/shared/native-chat-reply-reveals.ts b/src/shared/native-chat-reply-reveals.ts
new file mode 100644
index 00000000000..d61c955d612
--- /dev/null
+++ b/src/shared/native-chat-reply-reveals.ts
@@ -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
+ /** How much of a row was drawn when it left the window, so returning does not replay it. */
+ drawn: Map
+}
+
+export type NativeChatReplyRevealTracker = {
+ reveals: NativeChatReplyReveals
+ rowKeys: readonly string[]
+ loadedMessageIds: ReadonlySet | null
+ known: Set
+}
+
+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
+): 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
+}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-paced-text.test.ts b/src/shared/native-chat-text-reveal.test.ts
similarity index 100%
rename from src/renderer/src/components/native-chat/use-native-chat-paced-text.test.ts
rename to src/shared/native-chat-text-reveal.test.ts
diff --git a/src/shared/native-chat-text-reveal.ts b/src/shared/native-chat-text-reveal.ts
new file mode 100644
index 00000000000..92d2960f1b7
--- /dev/null
+++ b/src/shared/native-chat-text-reveal.ts
@@ -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 | 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)
+ }
+}