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) + } +}