@jcy2387/dsh-conversation-ui 0.1.0-rc.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +123 -0
  4. package/conversation-ui-off.yml +3 -0
  5. package/cordis.patch.yml +10 -0
  6. package/lib/client.js +3672 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/index.js +302 -0
  9. package/package.json +182 -0
  10. package/src/boot-config.ts +28 -0
  11. package/src/client/AnimatedDisclosure.tsx +89 -0
  12. package/src/client/ConversationCard.module.css +253 -0
  13. package/src/client/ConversationCard.tsx +122 -0
  14. package/src/client/DeliverablesCard.module.css +187 -0
  15. package/src/client/DeliverablesCard.tsx +142 -0
  16. package/src/client/FollowHost.tsx +25 -0
  17. package/src/client/ToolActivityGroup.tsx +341 -0
  18. package/src/client/TurnPreludeUserNodeView.tsx +258 -0
  19. package/src/client/TurnProcessMember.tsx +92 -0
  20. package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
  21. package/src/client/TypewriterAssistantNodeView.tsx +390 -0
  22. package/src/client/TypewriterToolNodeView.tsx +916 -0
  23. package/src/client/conversation-ui-card-controller.ts +162 -0
  24. package/src/client/conversation-ui-settings-api.ts +65 -0
  25. package/src/client/deliverables.ts +263 -0
  26. package/src/client/index.ts +242 -0
  27. package/src/client/locales.ts +74 -0
  28. package/src/client/teleprompterGlide.ts +590 -0
  29. package/src/client/turnElapsed.ts +27 -0
  30. package/src/client/turnProcessFold.ts +224 -0
  31. package/src/client/useConversationContent.ts +471 -0
  32. package/src/client/useFpsGuard.ts +84 -0
  33. package/src/config.ts +44 -0
  34. package/src/css-modules.d.ts +9 -0
  35. package/src/index.ts +2 -0
  36. package/src/package-meta.ts +39 -0
  37. package/src/plugin.ts +155 -0
  38. package/src/profile-installation.ts +121 -0
  39. package/src/settings-api.ts +33 -0
  40. package/src/settings.ts +30 -0
@@ -0,0 +1,224 @@
1
+ import { useCallback, useLayoutEffect, useSyncExternalStore } from 'react'
2
+
3
+ export interface TurnFoldAddress {
4
+ readonly sessionId: string
5
+ readonly turn: number
6
+ }
7
+
8
+ interface TurnFoldEntry {
9
+ collapsed: boolean
10
+ completed: boolean
11
+ label: string | undefined
12
+ readonly boundaries: Map<string, number>
13
+ readonly members: Map<string, number>
14
+ readonly listeners: Set<() => void>
15
+ }
16
+
17
+ interface TurnProcessFoldState {
18
+ readonly collapsed: boolean
19
+ readonly completed: boolean
20
+ readonly boundary: boolean
21
+ readonly controls: string
22
+ readonly hasMembers: boolean
23
+ readonly label: string | undefined
24
+ readonly toggle: () => void
25
+ }
26
+
27
+ const entries = new Map<string, TurnFoldEntry>()
28
+
29
+ function addressKey(address: TurnFoldAddress): string {
30
+ return `${address.sessionId}\u0000${address.turn}`
31
+ }
32
+
33
+ function entryFor(key: string): TurnFoldEntry {
34
+ const current = entries.get(key)
35
+ if (current !== undefined) return current
36
+ const created: TurnFoldEntry = {
37
+ collapsed: false,
38
+ completed: false,
39
+ label: undefined,
40
+ boundaries: new Map(),
41
+ members: new Map(),
42
+ listeners: new Set(),
43
+ }
44
+ entries.set(key, created)
45
+ return created
46
+ }
47
+
48
+ function notify(entry: TurnFoldEntry): void {
49
+ for (const listener of entry.listeners) listener()
50
+ }
51
+
52
+ function discardUnused(key: string, entry: TurnFoldEntry): void {
53
+ if (entry.listeners.size === 0 && entry.boundaries.size === 0 && entry.members.size === 0) entries.delete(key)
54
+ }
55
+
56
+ function subscribe(key: string, listener: () => void): () => void {
57
+ const entry = entryFor(key)
58
+ entry.listeners.add(listener)
59
+ return () => {
60
+ entry.listeners.delete(listener)
61
+ discardUnused(key, entry)
62
+ }
63
+ }
64
+
65
+ function orderedMembers(entry: TurnFoldEntry): readonly string[] {
66
+ return [...entry.members]
67
+ .sort(([leftId, leftOrder], [rightId, rightOrder]) => leftOrder - rightOrder || leftId.localeCompare(rightId))
68
+ .map(([id]) => id)
69
+ }
70
+
71
+ function orderedBoundaries(entry: TurnFoldEntry): readonly string[] {
72
+ return [...entry.boundaries]
73
+ .sort(([leftId, leftOrder], [rightId, rightOrder]) => leftOrder - rightOrder || leftId.localeCompare(rightId))
74
+ .map(([id]) => id)
75
+ }
76
+
77
+ function complete(key: string, label: string): void {
78
+ const entry = entryFor(key)
79
+ if (entry.completed) {
80
+ if (entry.label === label) return
81
+ entry.label = label
82
+ notify(entry)
83
+ return
84
+ }
85
+ entry.completed = true
86
+ entry.label = label
87
+ entry.collapsed = true
88
+ notify(entry)
89
+ }
90
+
91
+ function registerMember(key: string, memberId: string, memberOrder: number): () => void {
92
+ const entry = entryFor(key)
93
+ entry.members.set(memberId, memberOrder)
94
+ notify(entry)
95
+ return () => {
96
+ entry.members.delete(memberId)
97
+ notify(entry)
98
+ discardUnused(key, entry)
99
+ }
100
+ }
101
+
102
+ function registerBoundary(key: string, boundaryId: string, boundaryOrder: number): () => void {
103
+ const entry = entryFor(key)
104
+ entry.boundaries.set(boundaryId, boundaryOrder)
105
+ notify(entry)
106
+ return () => {
107
+ entry.boundaries.delete(boundaryId)
108
+ notify(entry)
109
+ discardUnused(key, entry)
110
+ }
111
+ }
112
+
113
+ function hash(value: string): string {
114
+ let result = 2_166_136_261
115
+ for (let index = 0; index < value.length; index += 1) {
116
+ result ^= value.charCodeAt(index)
117
+ result = Math.imul(result, 16_777_619)
118
+ }
119
+ return (result >>> 0).toString(36)
120
+ }
121
+
122
+ /** Stable DOM id for one flat Chat node controlled by a Turn disclosure. */
123
+ export function turnProcessMemberId(address: TurnFoldAddress, nodeKey: string): string {
124
+ return `dsh-turn-process-${address.turn}-${hash(`${address.sessionId}\u0000${nodeKey}`)}`
125
+ }
126
+
127
+ /** Stable DOM/store identity for the user-anchored Turn disclosure. */
128
+ export function turnProcessBoundaryId(sessionId: string, nodeKey: string): string {
129
+ return `dsh-turn-boundary-${hash(`${sessionId}\u0000${nodeKey}`)}`
130
+ }
131
+
132
+ /**
133
+ * Share one collapse state across flat Chat siblings belonging to the same
134
+ * Session Turn. A successful final node marks the Turn complete; process
135
+ * members contribute their DOM ids to the disclosure's `aria-controls` list.
136
+ */
137
+ export function useTurnProcessFold(
138
+ address: TurnFoldAddress | undefined,
139
+ options: {
140
+ readonly boundaryId?: string | undefined
141
+ readonly boundaryOrder?: number | undefined
142
+ readonly completedLabel?: string | undefined
143
+ readonly memberId?: string | undefined
144
+ readonly memberOrder?: number | undefined
145
+ } = {},
146
+ ): TurnProcessFoldState {
147
+ const key = address === undefined ? undefined : addressKey(address)
148
+ const subscribeToKey = useCallback((listener: () => void) => (
149
+ key === undefined ? () => {} : subscribe(key, listener)
150
+ ), [key])
151
+ const collapsed = useSyncExternalStore(
152
+ subscribeToKey,
153
+ () => key === undefined ? false : entries.get(key)?.collapsed ?? false,
154
+ () => false,
155
+ )
156
+ const completed = useSyncExternalStore(
157
+ subscribeToKey,
158
+ () => key === undefined ? false : entries.get(key)?.completed ?? false,
159
+ () => false,
160
+ )
161
+ const controls = useSyncExternalStore(
162
+ subscribeToKey,
163
+ () => {
164
+ if (key === undefined) return ''
165
+ const entry = entries.get(key)
166
+ return entry === undefined ? '' : orderedMembers(entry).join(' ')
167
+ },
168
+ () => '',
169
+ )
170
+ const boundaryId = useSyncExternalStore(
171
+ subscribeToKey,
172
+ () => {
173
+ if (key === undefined) return undefined
174
+ const entry = entries.get(key)
175
+ if (entry === undefined) return undefined
176
+ return orderedBoundaries(entry)[0] ?? orderedMembers(entry)[0]
177
+ },
178
+ () => undefined,
179
+ )
180
+ const hasMembers = useSyncExternalStore(
181
+ subscribeToKey,
182
+ () => key !== undefined && (entries.get(key)?.members.size ?? 0) > 0,
183
+ () => false,
184
+ )
185
+ const label = useSyncExternalStore(
186
+ subscribeToKey,
187
+ () => key === undefined ? undefined : entries.get(key)?.label,
188
+ () => undefined,
189
+ )
190
+
191
+ useLayoutEffect(() => {
192
+ if (key === undefined || options.boundaryId === undefined || options.boundaryOrder === undefined) return
193
+ return registerBoundary(key, options.boundaryId, options.boundaryOrder)
194
+ }, [key, options.boundaryId, options.boundaryOrder])
195
+
196
+ useLayoutEffect(() => {
197
+ if (key === undefined || options.memberId === undefined || options.memberOrder === undefined) return
198
+ return registerMember(key, options.memberId, options.memberOrder)
199
+ }, [key, options.memberId, options.memberOrder])
200
+
201
+ useLayoutEffect(() => {
202
+ if (key === undefined || options.completedLabel === undefined) return
203
+ complete(key, options.completedLabel)
204
+ }, [key, options.completedLabel])
205
+
206
+ const toggle = useCallback(() => {
207
+ if (key === undefined) return
208
+ const entry = entryFor(key)
209
+ if (!entry.completed || entry.members.size === 0) return
210
+ entry.collapsed = !entry.collapsed
211
+ notify(entry)
212
+ }, [key])
213
+
214
+ return {
215
+ collapsed,
216
+ completed,
217
+ boundary: (options.boundaryId !== undefined && boundaryId === options.boundaryId)
218
+ || (options.memberId !== undefined && boundaryId === options.memberId),
219
+ controls,
220
+ hasMembers,
221
+ label,
222
+ toggle,
223
+ }
224
+ }
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Stream-smoothing reveal hook.
3
+ *
4
+ * Buffers the model's chunked text and reveals it at a cadence that tracks
5
+ * the observed arrival rate, so a long reply never dumps whole paragraphs at
6
+ * once and a fast stream never stutters. Port of lobe-ui's smoother: EMA
7
+ * arrival cps + chunk size, backlog pressure, commit-interval widening with
8
+ * tail length, and a flush-speed settle drain once the input idles. The
9
+ * reveal decision is the pure {@link computeRevealStep} for unit tests.
10
+ *
11
+ * `shouldHoldBack` is the performance guard's veto: while it returns true the
12
+ * loop keeps measuring but skips the DOM commit, so an offscreen reply never
13
+ * competes with visible frames when the frame rate is degraded.
14
+ */
15
+
16
+ import { useCallback, useEffect, useRef, useState } from 'react'
17
+
18
+ export type ConversationSmoothingPreset = 'realtime' | 'balanced' | 'silky'
19
+
20
+ export interface ConversationSmoothingPresetConfig {
21
+ readonly activeInputWindowMs: number
22
+ readonly defaultCps: number
23
+ readonly emaAlpha: number
24
+ readonly flushCps: number
25
+ readonly largeAppendChars: number
26
+ readonly maxActiveCps: number
27
+ readonly maxCps: number
28
+ readonly maxFlushCps: number
29
+ readonly minCommitIntervalMs: number
30
+ readonly minCps: number
31
+ readonly settleAfterMs: number
32
+ readonly settleDrainMaxMs: number
33
+ readonly settleDrainMinMs: number
34
+ readonly targetBufferMs: number
35
+ }
36
+
37
+ export const PRESET_CONFIG: Record<ConversationSmoothingPreset, ConversationSmoothingPresetConfig> = {
38
+ balanced: {
39
+ activeInputWindowMs: 220,
40
+ defaultCps: 80,
41
+ emaAlpha: 0.35,
42
+ flushCps: 180,
43
+ largeAppendChars: 120,
44
+ maxActiveCps: 360,
45
+ maxCps: 240,
46
+ maxFlushCps: 480,
47
+ minCommitIntervalMs: 16,
48
+ minCps: 24,
49
+ settleAfterMs: 280,
50
+ settleDrainMaxMs: 420,
51
+ settleDrainMinMs: 120,
52
+ targetBufferMs: 40,
53
+ },
54
+ realtime: {
55
+ activeInputWindowMs: 140,
56
+ defaultCps: 120,
57
+ emaAlpha: 0.45,
58
+ flushCps: 240,
59
+ largeAppendChars: 180,
60
+ maxActiveCps: 480,
61
+ maxCps: 320,
62
+ maxFlushCps: 640,
63
+ minCommitIntervalMs: 16,
64
+ minCps: 32,
65
+ settleAfterMs: 200,
66
+ settleDrainMaxMs: 280,
67
+ settleDrainMinMs: 100,
68
+ targetBufferMs: 24,
69
+ },
70
+ silky: {
71
+ activeInputWindowMs: 280,
72
+ defaultCps: 64,
73
+ emaAlpha: 0.28,
74
+ flushCps: 140,
75
+ largeAppendChars: 100,
76
+ maxActiveCps: 280,
77
+ maxCps: 180,
78
+ maxFlushCps: 400,
79
+ minCommitIntervalMs: 16,
80
+ minCps: 20,
81
+ settleAfterMs: 360,
82
+ settleDrainMaxMs: 520,
83
+ settleDrainMinMs: 160,
84
+ targetBufferMs: 56,
85
+ },
86
+ }
87
+
88
+ const MAX_COMMIT_INTERVAL_MS = 16
89
+ /** Demo queue divisor: each frame reveals about `backlog / 8` characters. */
90
+ export const QUEUE_REVEAL_DIVISOR = 8
91
+ const QUEUE_FRAME_MS = 16.67
92
+ /** Hard cap on how far display may trail a live stream, in characters. */
93
+ export const LIVE_LAG_CHAR_CEILING = 32
94
+ const CATCHUP_SECONDS = 0.15
95
+
96
+ export const clamp = (value: number, min: number, max: number): number => {
97
+ return Math.min(max, Math.max(min, value))
98
+ }
99
+
100
+ /**
101
+ * Demo per-frame reveal: drain `backlog / 8` characters, at least 1, scaled
102
+ * by frame time. A small queue types one glyph per frame; a burst raises the
103
+ * step so the display does not fall behind.
104
+ * @param backlog - Unrevealed characters.
105
+ * @param dtMs - Frame delta in ms.
106
+ * @returns Characters to reveal this frame.
107
+ */
108
+ export function computeQueueReveal(backlog: number, dtMs: number): number {
109
+ if (backlog <= 0 || dtMs <= 0) return 0
110
+ return Math.min(backlog, Math.max(1, Math.ceil((backlog / QUEUE_REVEAL_DIVISOR) * (dtMs / QUEUE_FRAME_MS))))
111
+ }
112
+
113
+ const graphemeSegmenter = typeof Intl.Segmenter === 'function'
114
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
115
+ : undefined
116
+
117
+ /** Split text into user-perceived characters without tearing emoji or combining marks. */
118
+ export function splitGraphemes(text: string): string[] {
119
+ if (graphemeSegmenter === undefined) return [...text]
120
+ return [...graphemeSegmenter.segment(text)].map(item => item.segment)
121
+ }
122
+
123
+ /** Counts user-perceived characters, not UTF-16 units or Unicode code points. */
124
+ export const countChars = (text: string): number => splitGraphemes(text).length
125
+
126
+ /**
127
+ * Drain rate for backlog beyond the tail-lag ceiling: the displayed text may
128
+ * trail the stream by at most `backlogCharCeiling` characters for at most
129
+ * `backlogSecondCeiling` seconds. The constants are a smoothness invariant
130
+ * (bounded lag is the reason the reveal never stalls mid-reply), so they are
131
+ * not config fields; the preset multiplier is how a deployment tunes them.
132
+ */
133
+ export const BACKLOG_CHAR_CEILING = 300
134
+ export const BACKLOG_SECOND_CEILING = 2
135
+
136
+ export interface SettleDrainInput {
137
+ readonly backlog: number
138
+ readonly inputActive: boolean
139
+ readonly settling: boolean
140
+ }
141
+
142
+ /** Pure settle-drain decision shared by the frame loop and its tests. */
143
+ export function computeSettleDrain(config: ConversationSmoothingPresetConfig, input: SettleDrainInput): number {
144
+ if (input.inputActive || !input.settling) return 0
145
+ // A settled stream must drain fast enough to finish the reply promptly:
146
+ // lag beyond the ceiling drains within the second ceiling.
147
+ const overflow = Math.max(0, input.backlog - BACKLOG_CHAR_CEILING)
148
+ const overflowCps = (overflow * 1000) / BACKLOG_SECOND_CEILING
149
+ const drainTargetMs = clamp(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs)
150
+ const settleCps = (input.backlog * 1000) / drainTargetMs
151
+ return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps)
152
+ }
153
+
154
+ /**
155
+ * Drain rate multiplier once the input ends: leftover backlog reveals at
156
+ * this multiple of the steady rate, so the end never drags.
157
+ */
158
+ export const SETTLE_DRAIN_MULTIPLIER = 1.8
159
+
160
+ export interface RevealStepInput {
161
+ readonly backlog: number
162
+ readonly chunkSizeEma: number
163
+ readonly arrivalCpsEma: number
164
+ readonly emaCps: number
165
+ readonly inputActive: boolean
166
+ readonly settling: boolean
167
+ /** Fixed reveal rate; overrides the arrival-tracking cadence entirely. */
168
+ readonly steadyCps?: number | undefined
169
+ }
170
+
171
+ export interface RevealStepResult {
172
+ readonly revealChars: number
173
+ readonly targetLagChars: number
174
+ }
175
+
176
+ /** Pure per-frame reveal decision shared by the loop and its tests. */
177
+ export function computeRevealStep(config: ConversationSmoothingPresetConfig, input: RevealStepInput, dtSeconds: number): RevealStepResult {
178
+ // Track the faster of the two EMAs and do not clamp to the old low maxCps
179
+ // ceiling — that cap is why a fast model left hundreds of chars unrevealed.
180
+ const trackedCps = Math.max(input.emaCps, input.arrivalCpsEma)
181
+ const baseCps = clamp(trackedCps, config.minCps, config.maxFlushCps)
182
+ const targetLagChars = input.inputActive
183
+ ? Math.max(2, Math.round((baseCps * config.targetBufferMs) / 1000))
184
+ : 0
185
+
186
+ let currentCps: number
187
+ if (input.steadyCps !== undefined) {
188
+ currentCps = input.inputActive || input.settling
189
+ ? clamp(
190
+ input.steadyCps * (input.inputActive ? 1 : SETTLE_DRAIN_MULTIPLIER),
191
+ config.minCps,
192
+ config.maxFlushCps,
193
+ )
194
+ : 0
195
+ } else if (input.inputActive) {
196
+ const overflow = Math.max(0, input.backlog - LIVE_LAG_CHAR_CEILING)
197
+ const catchup = overflow > 0 ? overflow / CATCHUP_SECONDS : 0
198
+ currentCps = clamp(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps)
199
+ } else if (input.settling) {
200
+ currentCps = computeSettleDrain(config, input)
201
+ } else {
202
+ const idleFlushCps = Math.max(config.flushCps, baseCps * 1.8, input.arrivalCpsEma * 0.8)
203
+ currentCps = clamp(idleFlushCps, config.flushCps, config.maxFlushCps)
204
+ }
205
+
206
+ const minRevealChars = input.inputActive ? 1 : 2
207
+ return { revealChars: Math.max(minRevealChars, Math.round(currentCps * dtSeconds)), targetLagChars }
208
+ }
209
+
210
+ export interface UseConversationContentOptions {
211
+ enabled?: boolean
212
+ preset?: ConversationSmoothingPreset
213
+ /** Performance guard veto: while true, reveal commits are held back. */
214
+ shouldHoldBack?: (() => boolean) | undefined
215
+ /**
216
+ * Fixed reveal rate in chars/s. When set, the reveal runs at this steady
217
+ * pace while the input streams (instead of tracking the arrival rate) and
218
+ * drains the leftover backlog at {@link SETTLE_DRAIN_MULTIPLIER} once the
219
+ * input ends.
220
+ */
221
+ steadyCps?: number | undefined
222
+ /**
223
+ * Seed for the arrival-rate EMA. When omitted, the preset's `defaultCps`
224
+ * is used. The live rate then tracks observed arrival; this is not a cap.
225
+ */
226
+ defaultCps?: number | undefined
227
+ /** Written each commit with the live arrival-rate EMA for the follow lerp. */
228
+ speedCpsRef?: { current: number } | undefined
229
+ }
230
+
231
+ /**
232
+ * Smooth a chunked content stream into a reveal-paced display string.
233
+ *
234
+ * @param content - The full accumulated input so far.
235
+ * @param options - Preset, guard, and steady-rate wiring.
236
+ * @returns The displayed content, revealed at the smoothed cadence.
237
+ */
238
+ export function useConversationContent(
239
+ content: string,
240
+ { enabled = true, preset = 'balanced', shouldHoldBack, steadyCps, defaultCps, speedCpsRef }: UseConversationContentOptions = {},
241
+ ): string {
242
+ const config = PRESET_CONFIG[preset]
243
+ const seedCps = defaultCps ?? config.defaultCps
244
+ const [displayedContent, setDisplayedContent] = useState(content)
245
+
246
+ const displayedContentRef = useRef(content)
247
+ const displayedCountRef = useRef(countChars(content))
248
+ const targetContentRef = useRef(content)
249
+ const targetCharsRef = useRef(splitGraphemes(content))
250
+ const targetCountRef = useRef(countChars(content))
251
+
252
+ const emaCpsRef = useRef(seedCps)
253
+ const lastInputTsRef = useRef(0)
254
+ const lastInputCountRef = useRef(countChars(content))
255
+ const chunkSizeEmaRef = useRef(1)
256
+ const arrivalCpsEmaRef = useRef(seedCps)
257
+
258
+ const rafRef = useRef<number | null>(null)
259
+ const lastFrameTsRef = useRef<number | null>(null)
260
+ const wakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
261
+ const holdBackRef = useRef(shouldHoldBack)
262
+ const speedOutRef = useRef(speedCpsRef)
263
+ speedOutRef.current = speedCpsRef
264
+
265
+ useEffect(() => {
266
+ holdBackRef.current = shouldHoldBack
267
+ }, [shouldHoldBack])
268
+
269
+ const clearWakeTimer = useCallback(() => {
270
+ if (wakeTimerRef.current !== null) {
271
+ clearTimeout(wakeTimerRef.current)
272
+ wakeTimerRef.current = null
273
+ }
274
+ }, [])
275
+
276
+ const stopFrameLoop = useCallback(() => {
277
+ if (rafRef.current !== null) {
278
+ cancelAnimationFrame(rafRef.current)
279
+ rafRef.current = null
280
+ }
281
+ lastFrameTsRef.current = null
282
+ }, [])
283
+
284
+ const stopScheduling = useCallback(() => {
285
+ stopFrameLoop()
286
+ clearWakeTimer()
287
+ }, [clearWakeTimer, stopFrameLoop])
288
+
289
+ const startFrameLoopRef = useRef<() => void>(() => {})
290
+
291
+ const scheduleFrameWake = useCallback(
292
+ (delayMs: number) => {
293
+ clearWakeTimer()
294
+ wakeTimerRef.current = setTimeout(
295
+ () => {
296
+ wakeTimerRef.current = null
297
+ startFrameLoopRef.current()
298
+ },
299
+ Math.max(1, Math.ceil(delayMs)),
300
+ )
301
+ },
302
+ [clearWakeTimer],
303
+ )
304
+
305
+ const syncImmediate = useCallback(
306
+ (nextContent: string) => {
307
+ stopScheduling()
308
+ const chars = splitGraphemes(nextContent)
309
+ const now = performance.now()
310
+ targetContentRef.current = nextContent
311
+ targetCharsRef.current = chars
312
+ targetCountRef.current = chars.length
313
+ displayedContentRef.current = nextContent
314
+ displayedCountRef.current = chars.length
315
+ setDisplayedContent(nextContent)
316
+ emaCpsRef.current = seedCps
317
+ chunkSizeEmaRef.current = 1
318
+ arrivalCpsEmaRef.current = seedCps
319
+ lastInputTsRef.current = now
320
+ lastInputCountRef.current = chars.length
321
+ },
322
+ [seedCps, stopScheduling],
323
+ )
324
+
325
+ const startFrameLoop = useCallback(() => {
326
+ clearWakeTimer()
327
+ if (rafRef.current !== null) return
328
+
329
+ const tick = () => {
330
+ const targetCount = targetCountRef.current
331
+ const displayedCount = displayedCountRef.current
332
+ const backlog = targetCount - displayedCount
333
+
334
+ if (backlog <= 0) {
335
+ stopFrameLoop()
336
+ return
337
+ }
338
+
339
+ const now = performance.now()
340
+ if (lastFrameTsRef.current === null) {
341
+ lastFrameTsRef.current = now
342
+ rafRef.current = requestAnimationFrame(tick)
343
+ return
344
+ }
345
+
346
+ const commitIntervalMs = Math.min(MAX_COMMIT_INTERVAL_MS, config.minCommitIntervalMs)
347
+ const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current)
348
+ if (frameIntervalMs < commitIntervalMs) {
349
+ rafRef.current = requestAnimationFrame(tick)
350
+ return
351
+ }
352
+
353
+ const dtSeconds = Math.max(0.001, Math.min(frameIntervalMs / 1000, 0.12))
354
+ lastFrameTsRef.current = now
355
+
356
+ const idleMs = now - lastInputTsRef.current
357
+ const inputActive = idleMs <= config.activeInputWindowMs
358
+ const settling = !inputActive && idleMs >= config.settleAfterMs
359
+
360
+ let revealChars: number
361
+ if (steadyCps !== undefined) {
362
+ const step = computeRevealStep(
363
+ config,
364
+ {
365
+ backlog,
366
+ chunkSizeEma: chunkSizeEmaRef.current,
367
+ arrivalCpsEma: arrivalCpsEmaRef.current,
368
+ emaCps: emaCpsRef.current,
369
+ inputActive,
370
+ settling,
371
+ steadyCps,
372
+ },
373
+ dtSeconds,
374
+ )
375
+ revealChars = Math.min(step.revealChars, backlog)
376
+ } else {
377
+ // Same rAF queue drain as the silky markdown demo.
378
+ revealChars = computeQueueReveal(backlog, frameIntervalMs)
379
+ }
380
+
381
+ // Performance guard: while degraded and the reply is offscreen, skip
382
+ // the DOM commit — the backlog keeps accumulating and flushes when the
383
+ // guard clears or the reply scrolls into view.
384
+ if (holdBackRef.current?.() === true) {
385
+ rafRef.current = requestAnimationFrame(tick)
386
+ return
387
+ }
388
+
389
+ const speedOut = speedOutRef.current
390
+ if (speedOut !== undefined && frameIntervalMs > 0) {
391
+ const instantCps = (revealChars * 1000) / frameIntervalMs
392
+ speedOut.current = speedOut.current * 0.92 + instantCps * 0.08
393
+ }
394
+
395
+ const nextCount = displayedCount + revealChars
396
+ const segment = targetCharsRef.current.slice(displayedCount, nextCount).join('')
397
+ if (segment) {
398
+ const nextDisplayed = displayedContentRef.current + segment
399
+ displayedContentRef.current = nextDisplayed
400
+ displayedCountRef.current = nextCount
401
+ setDisplayedContent(nextDisplayed)
402
+ } else {
403
+ displayedContentRef.current = targetContentRef.current
404
+ displayedCountRef.current = targetCount
405
+ setDisplayedContent(targetContentRef.current)
406
+ }
407
+
408
+ rafRef.current = requestAnimationFrame(tick)
409
+ }
410
+
411
+ rafRef.current = requestAnimationFrame(tick)
412
+ }, [config, scheduleFrameWake, stopFrameLoop, clearWakeTimer, steadyCps])
413
+
414
+ useEffect(() => {
415
+ startFrameLoopRef.current = startFrameLoop
416
+ }, [startFrameLoop])
417
+
418
+ useEffect(() => {
419
+ if (!enabled) {
420
+ syncImmediate(content)
421
+ return
422
+ }
423
+
424
+ const prevTargetContent = targetContentRef.current
425
+ if (content === prevTargetContent) return
426
+
427
+ const now = performance.now()
428
+ const appendOnly = content.startsWith(prevTargetContent)
429
+
430
+ if (!appendOnly) {
431
+ syncImmediate(content)
432
+ return
433
+ }
434
+
435
+ const appended = content.slice(prevTargetContent.length)
436
+ const appendedChars = splitGraphemes(appended)
437
+ const appendedCount = appendedChars.length
438
+
439
+ targetContentRef.current = content
440
+ targetCharsRef.current.push(...appendedChars)
441
+ targetCountRef.current += appendedCount
442
+
443
+ const hadSample = lastInputTsRef.current > 0
444
+ const deltaChars = targetCountRef.current - lastInputCountRef.current
445
+ const deltaMs = Math.max(1, now - lastInputTsRef.current)
446
+
447
+ // Skip the first sample: lastInputTs starts at 0, so the interval would
448
+ // be "time since page load" (or 1ms under fake timers) and poison the EMA.
449
+ if (hadSample && deltaChars > 0) {
450
+ const instantCps = (deltaChars * 1000) / deltaMs
451
+ const normalizedInstantCps = clamp(instantCps, config.minCps, config.maxFlushCps * 3)
452
+ const chunkEmaAlpha = 0.45
453
+ chunkSizeEmaRef.current = chunkSizeEmaRef.current * (1 - chunkEmaAlpha) + appendedCount * chunkEmaAlpha
454
+ arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * (1 - chunkEmaAlpha) + normalizedInstantCps * chunkEmaAlpha
455
+ emaCpsRef.current = emaCpsRef.current * (1 - config.emaAlpha) + normalizedInstantCps * config.emaAlpha
456
+ }
457
+
458
+ lastInputTsRef.current = now
459
+ lastInputCountRef.current = targetCountRef.current
460
+
461
+ startFrameLoop()
462
+ }, [content, enabled, config, startFrameLoop, syncImmediate])
463
+
464
+ useEffect(() => {
465
+ return () => {
466
+ stopScheduling()
467
+ }
468
+ }, [stopScheduling])
469
+
470
+ return displayedContent
471
+ }