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