dsh-smooth-stream 0.6.1 → 0.7.0

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.
@@ -1,12 +1,14 @@
1
1
  import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from 'react'
2
- import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
2
+ import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
3
3
  import { ImageGallery, type ImageLoader, type MessageImageLabels } from '@deepseek-ai/dsh-client-ui-attachment'
4
4
  import type { ChatNodeViewProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
5
5
  import { AnimatedDisclosure } from './AnimatedDisclosure.tsx'
6
+ import { IconThink } from './harnessIcons.ts'
6
7
  import { notifyFollowCommit } from './teleprompterGlide.ts'
7
8
  import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothStreamContent.ts'
8
9
  import { useFpsGuard } from './useFpsGuard.ts'
9
10
  import { useLogarithmicFade } from './useLogarithmicFade.ts'
11
+ import { useDecoupledMarkdown } from './useDecoupledMarkdown.ts'
10
12
  import { FollowHost } from './FollowHost.tsx'
11
13
  import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
12
14
  import { DEFAULT_STREAM_SETTINGS, type StreamMotionPreference } from '../settings.ts'
@@ -49,15 +51,14 @@ interface AnimatedMarkdownTextProps extends MarkdownProps {
49
51
  logarithmicFade: boolean
50
52
  /** Whether the resolved reduced-motion gate keeps the reveal engine off. */
51
53
  motionReduced: boolean
52
- /** True on the last text block: that block owns conversation follow. */
53
- ownFollow: boolean
54
54
  followSpeedCpsRef?: { current: number } | undefined
55
55
  followRevealedCharsRef?: { current: number } | undefined
56
56
  followRevealScaleRef?: { current: number } | undefined
57
57
  onPredictiveChange?: ((predictive: boolean) => void) | undefined
58
+ /** Reports whether the final text arm still has visible reveal work. */
59
+ onRevealActivityChange?: ((active: boolean) => void) | undefined
58
60
  preset: StreamSmoothingPreset
59
61
  shouldHoldBack: () => boolean
60
- controlScroll?: boolean
61
62
  }
62
63
 
63
64
  /** Conservative fallback before the streaming Markdown tail has geometry. */
@@ -82,50 +83,6 @@ function approximateInlineWidth(text: string, emPx: number): number {
82
83
  return width
83
84
  }
84
85
 
85
- function measurePendingTextGeometry(root: HTMLElement, visibleText: string): PendingTextGeometry {
86
- if (
87
- typeof document.createTreeWalker !== 'function'
88
- || typeof NodeFilter === 'undefined'
89
- ) {
90
- return { root, visibleText, fontSize: 14, wrapThresholdWidth: null }
91
- }
92
- const rootRect = root.getBoundingClientRect()
93
- const rootWidth = Math.max(0, rootRect.width, rootRect.right - rootRect.left, root.clientWidth)
94
- if (rootWidth <= 0) return { root, visibleText, fontSize: 14, wrapThresholdWidth: null }
95
-
96
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
97
- let tail: Text | null = null
98
- for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
99
- if ((node.textContent ?? '').length > 0) tail = node as Text
100
- }
101
- const parent = tail?.parentElement ?? root
102
- const fontSize = Number.parseFloat(getComputedStyle(parent).fontSize) || 14
103
- if (tail === null || typeof document.createRange !== 'function') {
104
- return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
105
- }
106
-
107
- try {
108
- const length = tail.textContent?.length ?? 0
109
- if (length <= 0) return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
110
- const range = document.createRange()
111
- range.setStart(tail, Math.max(0, length - 1))
112
- range.setEnd(tail, length)
113
- const tailRect = range.getBoundingClientRect()
114
- const contentRight = rootRect.right
115
- if (!Number.isFinite(tailRect.right) || tailRect.right <= rootRect.left || contentRight <= rootRect.left) {
116
- return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
117
- }
118
- const remainingWidth = Math.max(0, contentRight - tailRect.right)
119
- return {
120
- root,
121
- visibleText,
122
- fontSize,
123
- wrapThresholdWidth: remainingWidth + fontSize * 0.35,
124
- }
125
- } catch {
126
- return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
127
- }
128
- }
129
86
 
130
87
  /** Whether buffered source can reach a new visual line before it drains. */
131
88
  function pendingTextCanGrow(
@@ -137,17 +94,16 @@ function pendingTextCanGrow(
137
94
  if (pending === '') return false
138
95
  if (/[\r\n]/u.test(pending)) return true
139
96
  const pendingChars = [...pending]
140
- if (root === null) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
97
+ if (pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS) return true
98
+ if (root === null) return false
141
99
 
142
100
  let geometry = geometryRef.current
143
- if (geometry?.root !== root || geometry.visibleText !== visibleText) {
144
- geometry = measurePendingTextGeometry(root, visibleText)
101
+ if (geometry?.root !== root) {
102
+ const rootWidth = root.clientWidth || 600
103
+ geometry = { root, visibleText, fontSize: 14, wrapThresholdWidth: Math.max(120, rootWidth * 0.4) }
145
104
  geometryRef.current = geometry
146
105
  }
147
- if (geometry.wrapThresholdWidth === null) {
148
- return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
149
- }
150
- return approximateInlineWidth(pending, geometry.fontSize) >= geometry.wrapThresholdWidth
106
+ return approximateInlineWidth(pending, geometry.fontSize) >= (geometry.wrapThresholdWidth ?? (geometry.fontSize * PREDICTIVE_WRAP_FALLBACK_CHARS))
151
107
  }
152
108
 
153
109
  function announcementChunkEnd(source: string, start: number): number {
@@ -299,9 +255,9 @@ const StreamAnnouncement = memo(function StreamAnnouncement({
299
255
  * and rendered by the Harness `MarkdownText`
300
256
  * streaming arm (incremental parse, frozen non-tail blocks), so there is no
301
257
  * raw-text tail and no text-to-markdown swap: the tree stays markdown
302
- * throughout. The last text block owns conversation-port follow so wraps
303
- * glide instead of snapping. Once the stream closes and the reveal queue
304
- * drains, the settled full parse (KaTeX math, fence highlighting, file
258
+ * throughout. The outer assistant owner follows all text growth, including
259
+ * the final drain, so wraps glide without a completion handoff. Once the
260
+ * queue drains, the settled full parse (KaTeX math, fence highlighting, file
305
261
  * mentions) swaps in exactly once.
306
262
  */
307
263
  function AnimatedMarkdownText({
@@ -311,14 +267,13 @@ function AnimatedMarkdownText({
311
267
  streaming,
312
268
  logarithmicFade,
313
269
  motionReduced,
314
- ownFollow,
315
270
  followSpeedCpsRef,
316
271
  followRevealedCharsRef,
317
272
  followRevealScaleRef,
318
273
  onPredictiveChange,
274
+ onRevealActivityChange,
319
275
  preset,
320
276
  shouldHoldBack,
321
- controlScroll = true,
322
277
  }: AnimatedMarkdownTextProps) {
323
278
  const reduced = motionReduced
324
279
  const [typing, setTyping] = useState(streaming)
@@ -340,6 +295,7 @@ function AnimatedMarkdownText({
340
295
  })
341
296
  const shown = reduced ? text : displayed
342
297
  const live = typing && !reduced
298
+ const markdownShown = useDecoupledMarkdown(shown, live)
343
299
  useLogarithmicFade(followRootRef, logarithmicFade && !reduced, live, speedCpsRef)
344
300
 
345
301
  useEffect(() => {
@@ -366,6 +322,10 @@ function AnimatedMarkdownText({
366
322
  onPredictiveChange(next)
367
323
  }, [live, onPredictiveChange, shown, streaming, text])
368
324
 
325
+ useLayoutEffect(() => {
326
+ onRevealActivityChange?.(live)
327
+ }, [live, onRevealActivityChange])
328
+
369
329
  // The stream closed: keep revealing the remaining queue, then swap to the
370
330
  // settled parse exactly once. The markdown tree stays mounted until then.
371
331
  useEffect(() => {
@@ -380,26 +340,20 @@ function AnimatedMarkdownText({
380
340
  if (streaming) setTyping(true)
381
341
  }, [streaming])
382
342
 
343
+ if (!streaming && !live && text.trim() === '') return null
344
+
383
345
  return (
384
- <FollowHost
385
- active={live && ownFollow}
386
- speedCpsRef={speedCpsRef}
387
- revealedCharsRef={followRevealedCharsRef}
388
- revealScaleRef={followRevealScaleRef}
389
- predictive={streaming}
390
- controlScroll={controlScroll}
391
- hostRef={followRootRef}
392
- >
346
+ <div ref={followRootRef} className={css.follow} data-follow-text="">
393
347
  <MarkdownText
394
348
  // `shown` stays authoritative through the completion drain: the
395
349
  // settled parse swaps in only when the queue has actually emptied.
396
350
  // Rendering `text` early bypasses the drain and teleports the tail.
397
- text={live ? shown : text}
351
+ text={live ? markdownShown : text}
398
352
  streaming={live}
399
353
  labels={labels}
400
354
  fileMentions={live ? undefined : fileMentions}
401
355
  />
402
- </FollowHost>
356
+ </div>
403
357
  )
404
358
  }
405
359
 
@@ -418,7 +372,7 @@ function imageLabels(t: AssistantProps['t']): MessageImageLabels {
418
372
  }
419
373
 
420
374
  /**
421
- * Apply searchable hidden state without unmounting a stable subtree — the
375
+ * Apply searchable hidden state without unmounting a stable subtree 鈥?the
422
376
  * Host's completion fold hides the answer-inline reasoning this way, so the
423
377
  * takeover renderer must reproduce it to stay inside the contract: the row
424
378
  * disappears into the Host's process summary and comes back on find-in-page.
@@ -488,8 +442,8 @@ function latestLine(text: string): string {
488
442
  * click stay on the disclosure chrome, which the plugin's AnimatedDisclosure
489
443
  * renders with a height-animated body (the harness primitive would mount and
490
444
  * unmount it, which cannot glide). The row opens only while this block is
491
- * the streaming tail and closes as soon as thinking ends — a later block,
492
- * or the assistant node settling — not when the rest of the reply is
445
+ * the streaming tail and closes as soon as thinking ends 鈥?a later block,
446
+ * or the assistant node settling 鈥?not when the rest of the reply is
493
447
  * still streaming.
494
448
  */
495
449
  function AnimatedReasoning({
@@ -502,6 +456,7 @@ function AnimatedReasoning({
502
456
  shouldHoldBack,
503
457
  followSpeedCpsRef,
504
458
  followRevealScaleRef,
459
+ snapAutoCollapse,
505
460
  t,
506
461
  }: {
507
462
  text: string
@@ -513,6 +468,8 @@ function AnimatedReasoning({
513
468
  shouldHoldBack: () => boolean
514
469
  followSpeedCpsRef?: { current: number } | undefined
515
470
  followRevealScaleRef?: { current: number } | undefined
471
+ /** 0.1.x single rows snap the auto-close and let their follower absorb the step; on 0.2.x the collapse lands exactly at the ownership handoff, so it must glide. */
472
+ snapAutoCollapse: boolean
516
473
  t: AssistantProps['t']
517
474
  }) {
518
475
  const reduced = motionReduced
@@ -520,10 +477,21 @@ function AnimatedReasoning({
520
477
  const [autoClosed, setAutoClosed] = useState(false)
521
478
  const summaryRef = useRef<HTMLSpanElement>(null)
522
479
  const fadeRootRef = useRef<HTMLDivElement>(null)
480
+ // The custom thinking auto-scroll drives the SAME node as the logarithmic
481
+ // fade, so it reuses that ref object rather than installing a second one
482
+ // (a callback ref would re-attach on every render).
483
+ const thinkBodyRef = fadeRootRef
523
484
  const localFadeSpeedRef = useRef(35)
524
485
  const fadeSpeedRef = followSpeedCpsRef ?? localFadeSpeedRef
486
+ const userScrolledRef = useRef(false)
487
+ const rafIdRef = useRef(0)
488
+ // Latest "the live stream still owns this scroller" state. Tracked on every
489
+ // render so the queued frame can re-read it: a stop or a collapse landing
490
+ // between scheduling and the frame must not move the viewport.
491
+ const followActiveRef = useRef(false)
492
+ followActiveRef.current = running && expanded
525
493
  const commitAnchorRef = useRef<HTMLDivElement>(null)
526
- // The running→false flip is the AUTO-close: it collapses instantly and the
494
+ // The running鈫抐alse flip is the AUTO-close: it collapses instantly and the
527
495
  // follower's settle spring absorbs the height step. A later manual toggle
528
496
  // keeps the CSS glide.
529
497
  const displayed = useSmoothStreamContent(text, {
@@ -545,12 +513,87 @@ function AnimatedReasoning({
545
513
  setExpanded(running)
546
514
  setAutoClosed(!running)
547
515
  }
516
+ if (running) {
517
+ userScrolledRef.current = false
518
+ }
548
519
  // A commit that changes this block's height must hand the follower its
549
520
  // correction in the SAME task, before the grown-but-uncompensated frame
550
521
  // can reach a paint.
551
522
  notifyFollowCommit(commitAnchorRef.current)
552
523
  }, [running, thinkAutoExpand])
553
524
 
525
+ useEffect(() => {
526
+ // Only the live stream owns the reading position. A settled block is
527
+ // something to read from the top, so expanding a finished reasoning card
528
+ // must not scroll it 鈥?that would make its first lines unreachable.
529
+ if (!running || !expanded || userScrolledRef.current) return
530
+ const el = thinkBodyRef.current
531
+ if (el === null) return
532
+ if (rafIdRef.current === 0) {
533
+ rafIdRef.current = requestAnimationFrame(() => {
534
+ rafIdRef.current = 0
535
+ // Re-checked at frame time: the stream may have stopped, the block may
536
+ // have collapsed, or the user may have scrolled since scheduling. A
537
+ // queued frame never steals the position back.
538
+ if (!followActiveRef.current || userScrolledRef.current || el === null) return
539
+ const delta = el.scrollHeight - el.scrollTop - el.clientHeight
540
+ if (delta > 2) {
541
+ el.scrollTop = Number.MAX_SAFE_INTEGER
542
+ }
543
+ })
544
+ }
545
+ }, [running, expanded, shown])
546
+
547
+ useEffect(() => {
548
+ return () => {
549
+ if (rafIdRef.current !== 0) {
550
+ cancelAnimationFrame(rafIdRef.current)
551
+ rafIdRef.current = 0
552
+ }
553
+ }
554
+ }, [])
555
+
556
+ useEffect(() => {
557
+ const el = thinkBodyRef.current
558
+ if (el === null) return
559
+ let isPointerDown = false
560
+ const onWheel = (e: WheelEvent) => {
561
+ if (e.deltaY < 0) {
562
+ userScrolledRef.current = true
563
+ } else if (e.deltaY > 0) {
564
+ if (el.scrollHeight - el.scrollTop - el.clientHeight <= 30) {
565
+ userScrolledRef.current = false
566
+ }
567
+ }
568
+ }
569
+ const onPointerDown = () => {
570
+ isPointerDown = true
571
+ }
572
+ const onPointerUp = () => {
573
+ isPointerDown = false
574
+ }
575
+ const onScroll = () => {
576
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 30
577
+ if (atBottom) {
578
+ userScrolledRef.current = false
579
+ } else if (isPointerDown) {
580
+ userScrolledRef.current = true
581
+ }
582
+ }
583
+ el.addEventListener('wheel', onWheel, { passive: true })
584
+ el.addEventListener('pointerdown', onPointerDown, { passive: true })
585
+ window.addEventListener('pointerup', onPointerUp, { passive: true })
586
+ window.addEventListener('pointercancel', onPointerUp, { passive: true })
587
+ el.addEventListener('scroll', onScroll, { passive: true })
588
+ return () => {
589
+ el.removeEventListener('wheel', onWheel)
590
+ el.removeEventListener('pointerdown', onPointerDown)
591
+ window.removeEventListener('pointerup', onPointerUp)
592
+ window.removeEventListener('pointercancel', onPointerUp)
593
+ el.removeEventListener('scroll', onScroll)
594
+ }
595
+ }, [])
596
+
554
597
  useEffect(() => {
555
598
  const element = summaryRef.current
556
599
  if (element === null) return
@@ -567,18 +610,29 @@ function AnimatedReasoning({
567
610
  leadingClassName={css.thinkLeading}
568
611
  titleClassName={css.thinkTitle}
569
612
  chevronClassName={css.thinkChevron}
570
- icon={<IconThinkOutline14 size={14} />}
571
- title="Think"
613
+ icon={<IconThink size={14} />}
614
+ // `message.think` is not in the `conversation` key union this prop is
615
+ // typed with: no Harness version owns it there. On 0.1.5+ it lives in
616
+ // the `chat` namespace, and on older builds only this plugin's own
617
+ // fallback dictionary has it. The layered lookup installed in
618
+ // index.ts resolves it either way, so the cast widens the key domain
619
+ // rather than skipping a lookup. No hardcoded label and no
620
+ // `<html lang>` sniffing: a key that resolves nowhere shows the key,
621
+ // which the locale routing test catches.
622
+ title={(t as unknown as (key: string) => string)('message.think')}
572
623
  open={expanded}
573
624
  onToggle={() => {
574
625
  setAutoClosed(false)
575
626
  setExpanded(value => !value)
576
627
  }}
577
- // The auto-close at stream end snaps (no grid-track animation): the
578
- // follower's settle spring absorbs the height step through the
579
- // compositor, so animating the track too would double-animate the
580
- // collapse. Manual toggles while streaming keep the glide.
581
- bodyTransition={!autoClosed}
628
+ // The auto-close at stream end snaps on 0.1.x (no grid-track
629
+ // animation): the single row's follower absorbs the height step
630
+ // through the compositor, so animating the track too would
631
+ // double-animate the collapse. On 0.2.x the collapse lands exactly
632
+ // at the ownership handoff with nothing left to absorb it, so the
633
+ // track itself glides and the floor retreats gradually instead of
634
+ // teleporting. Manual toggles while streaming keep the glide.
635
+ bodyTransition={!autoClosed || !snapAutoCollapse}
582
636
  collapsedContent={(
583
637
  <>
584
638
  <span className={css.thinkSeparator} aria-hidden />
@@ -598,10 +652,11 @@ function AnimatedReasoning({
598
652
  * streaming is revealed by the smoother through the Harness Markdown
599
653
  * renderer at a rate that tracks arrival. Reasoning blocks keep the
600
654
  * built-in Think disclosure and only receive a smoothed text feed; the
601
- * outer node owns conversation-port follow while streaming; the final text
602
- * block keeps ownership while its settled reveal queue drains. The FPS guard
603
- * holds offscreen reveals when the frame rate is degraded. Settled text
604
- * renders with the full Markdown pipeline.
655
+ * outer node owns conversation-port follow through both streaming and the
656
+ * final text drain. Keeping one owner avoids a lifecycle handoff that would
657
+ * reopen and later retire a second runway. The FPS guard holds offscreen
658
+ * reveals when the frame rate is degraded. Settled text renders with the full
659
+ * Markdown pipeline.
605
660
  */
606
661
  export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
607
662
  mode: _mode = DEFAULT_STREAM_CONFIG.mode,
@@ -614,6 +669,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
614
669
  controlScroll = true,
615
670
  motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
616
671
  node,
672
+ groupPart,
617
673
  useTurnData,
618
674
  openFile,
619
675
  loadImage,
@@ -621,6 +677,14 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
621
677
  turnProcess,
622
678
  t,
623
679
  }: AssistantProps & {
680
+ /**
681
+ * dsh 0.2.x row split for one assistant step: the flow mounts the node
682
+ * twice — once with `groupPart: 'reasoning'` (reasoning blocks only) and
683
+ * once with `groupPart: 'response'` (everything else). Absent on 0.1.x,
684
+ * where the step renders as a single row; the pinned type predates the
685
+ * field, so it is declared here and erased at compile time.
686
+ */
687
+ groupPart?: 'reasoning' | 'response' | undefined
624
688
  mode?: StreamMode
625
689
  preset?: StreamSmoothingPreset
626
690
  revealCharsPerSec?: number
@@ -633,6 +697,31 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
633
697
  }) {
634
698
  const data = node.data
635
699
  const streaming = data.status === 'running'
700
+ // dsh 0.2.x mounts the assistant step twice with a `groupPart` split: the
701
+ // `reasoning` row carries the reasoning blocks and the `response` row the
702
+ // rest. Mirrors the built-in AssistantMarkdown filter so the takeover does
703
+ // not render every block in both rows (which duplicated each reasoning
704
+ // paragraph in the transcript on 0.2.x).
705
+ const blocks = groupPart === 'reasoning'
706
+ ? data.blocks.filter(block => block.kind === 'reasoning')
707
+ : groupPart === 'response'
708
+ ? data.blocks.filter(block => block.kind !== 'reasoning')
709
+ : data.blocks
710
+ // dsh 0.2.x mounts this view twice on the same conversation scrollport —
711
+ // the reasoning row above and the response row below. Handing the port to
712
+ // whichever row carries the streaming tail still left the think phase
713
+ // jumpy: while reasoning streamed, the reasoning-row host owned the port
714
+ // from a mid-transcript anchor (the empty response row and the turn status
715
+ // render below it), and the viewport hopped upward as the glide steered
716
+ // toward positions its bottom-row tuning does not describe. The response
717
+ // row is the geometric bottom row for the whole turn, so owning it from
718
+ // the first streamed block reproduces the 0.1.x single-row configuration
719
+ // the glide was tuned for: think growth happens above the anchor, the
720
+ // text reveal grows inside it, and ownership never flips mid-turn. The
721
+ // reasoning row never touches the port — its think box paces its own
722
+ // reveal and auto-scrolls internally. 0.1.x mounts a single row
723
+ // (groupPart undefined) and keeps the previous behavior.
724
+ const rowOwnsPort = groupPart !== 'reasoning'
636
725
  const reduced = useMotionReduced(motionPreference)
637
726
  // The Host's completion-fold decision for THIS node's inline reasoning:
638
727
  // only the answer step folds, only in compact-transcript mode, and only
@@ -648,8 +737,19 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
648
737
  const rootSpeedRef = useRef(35)
649
738
  const rootRevealedCharsRef = useRef(0)
650
739
  const rootRevealScaleRef = useRef(1)
651
- const reasoningTailIndex = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
652
- ? data.blocks.length - 1
740
+ const previousStreamingRef = useRef(streaming)
741
+ const [textRevealActive, setTextRevealActive] = useState(false)
742
+ const completionCandidate = !streaming
743
+ && previousStreamingRef.current
744
+ && blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
745
+ useLayoutEffect(() => {
746
+ previousStreamingRef.current = streaming
747
+ }, [streaming])
748
+ const updateTextRevealActivity = useCallback((active: boolean): void => {
749
+ setTextRevealActive(previous => previous === active ? previous : active)
750
+ }, [])
751
+ const reasoningTailIndex = streaming && blocks[blocks.length - 1]?.kind === 'reasoning'
752
+ ? blocks.length - 1
653
753
  : -1
654
754
  const reasoningOwnsSpeed = reasoningTailIndex !== -1
655
755
  const rootPredictiveRef = useRef(false)
@@ -690,25 +790,36 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
690
790
  })
691
791
  const hasVisible = streaming
692
792
  || data.status === 'interrupted'
693
- || data.blocks.some(block => block.kind !== 'tool-call')
793
+ || blocks.some(block => block.kind !== 'tool-call')
694
794
  if (!hasVisible) return null
695
- const announcementText = data.blocks
795
+ const announcementText = blocks
696
796
  .filter(block => block.kind === 'text')
697
797
  .map(block => block.text)
698
798
  .join('\n')
799
+ // 0.2.x reasoning rows see a blocks array filtered to reasoning only, so
800
+ // `index === last` stays true while a later tool-call or text block runs in
801
+ // the same step — the disclosure then waits for the whole step to settle
802
+ // before snapping shut, and that late full-height collapse jumps the page.
803
+ // Tail-ness must be judged against the ORIGINAL step blocks: any later
804
+ // block of any kind closes the think box promptly, exactly as the 0.1.x
805
+ // single-row layout did by construction.
806
+ const stepTailIsReasoning = data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
699
807
 
700
808
  const rendered: ReactNode[] = []
701
- const last = data.blocks.length - 1
809
+ const last = blocks.length - 1
702
810
  let lastFollow = -1
703
- for (let index = 0; index < data.blocks.length; index += 1) {
704
- const kind = data.blocks[index]?.kind
811
+ let lastText = -1
812
+ for (let index = 0; index < blocks.length; index += 1) {
813
+ const kind = blocks[index]?.kind
705
814
  if (kind === 'text' || kind === 'reasoning') lastFollow = index
815
+ if (kind === 'text') lastText = index
706
816
  }
707
- for (let index = 0; index < data.blocks.length; index += 1) {
708
- const block = data.blocks[index]
817
+ for (let index = 0; index < blocks.length; index += 1) {
818
+ const block = blocks[index]
709
819
  if (block === undefined) continue
710
820
  switch (block.kind) {
711
821
  case 'text':
822
+ if (!streaming && block.text.trim() === '') break
712
823
  rendered.push(
713
824
  <AnimatedMarkdownText
714
825
  key={index}
@@ -718,14 +829,13 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
718
829
  streaming={streaming}
719
830
  logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
720
831
  motionReduced={reduced}
721
- ownFollow={!streaming && index === lastFollow}
722
832
  followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
723
833
  followRevealedCharsRef={index === lastFollow ? rootRevealedCharsRef : undefined}
724
834
  followRevealScaleRef={index === lastFollow ? rootRevealScaleRef : undefined}
725
835
  onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
836
+ onRevealActivityChange={index === lastText ? updateTextRevealActivity : undefined}
726
837
  preset={preset}
727
838
  shouldHoldBack={shouldHoldBack}
728
- controlScroll={controlScroll}
729
839
  />,
730
840
  )
731
841
  break
@@ -734,7 +844,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
734
844
  <FoldableReasoning key={index} hidden={reasoningHidden} reveal={revealProcess}>
735
845
  <AnimatedReasoning
736
846
  text={block.text}
737
- running={streaming && index === last}
847
+ running={streaming && index === last && (groupPart !== 'reasoning' || stepTailIsReasoning)}
738
848
  preset={preset}
739
849
  thinkAutoExpand={thinkAutoExpand}
740
850
  logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
@@ -742,6 +852,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
742
852
  shouldHoldBack={shouldHoldBack}
743
853
  followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
744
854
  followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
855
+ snapAutoCollapse={groupPart === undefined}
745
856
  t={t}
746
857
  />
747
858
  </FoldableReasoning>,
@@ -750,8 +861,8 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
750
861
  case 'image': {
751
862
  const start = index
752
863
  const group = [block]
753
- while (index + 1 < data.blocks.length) {
754
- const next = data.blocks[index + 1]
864
+ while (index + 1 < blocks.length) {
865
+ const next = blocks[index + 1]
755
866
  if (next === undefined || next.kind !== 'image') break
756
867
  group.push(next)
757
868
  index += 1
@@ -780,12 +891,15 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
780
891
  <div ref={guardRef} className={css.root} data-streaming={streaming || undefined}>
781
892
  <StreamAnnouncement text={announcementText} active={streaming && !reduced} />
782
893
  <FollowHost
783
- active={streaming && !reduced}
894
+ // The status can close before its final text arm drains. Retain this
895
+ // same owner across that boundary; the candidate bridges the one
896
+ // layout commit before the child reports its live reveal state.
897
+ active={!reduced && (streaming || completionCandidate || textRevealActive)}
784
898
  speedCpsRef={rootSpeedRef}
785
899
  revealedCharsRef={rootRevealedCharsRef}
786
900
  revealScaleRef={rootRevealScaleRef}
787
901
  predictiveRef={rootPredictiveRef}
788
- controlScroll={controlScroll}
902
+ controlScroll={controlScroll && rowOwnsPort}
789
903
  >
790
904
  <div className={css.body}>
791
905
  {rendered}
@@ -30,12 +30,46 @@ export interface DebugMetrics {
30
30
  followRevealScale: number
31
31
  followFollowing: boolean
32
32
  followConstrained: boolean
33
+ /**
34
+ * Terminal-follow phase. `terminal-drain` (producer stopped, reveal queue
35
+ * still owes text) and `host-cascade` (host swapping status / collapsing
36
+ * Think / mounting the tail) need OPPOSITE corrections, so they must be
37
+ * distinguishable in the panel rather than inferred from `followActive`.
38
+ */
39
+ followTerminalPhase: FollowTerminalPhase
40
+ /** Physical owned bottom margin currently written into the DOM. */
41
+ followRunwayPx: number
42
+ /** Budget the terminal phase may spend, capped by the runway. */
43
+ followTerminalBudgetPx: number
44
+ /** `runwayPx - visibleReserve`: the term that turns into visible motion. */
45
+ followBaselineShiftPx: number
46
+ /** Screen-space reading-anchor delta this frame (null when unmeasurable). */
47
+ followAnchorDeltaPx: number | null
48
+ /** Reveal characters the smoother still owes after the producer stopped. */
49
+ followRemainingRevealChars: number
33
50
  scrollTop: number | null
34
51
  scrollHeight: number | null
35
52
  clientHeight: number | null
36
53
  lastUpdatedMs: number | null
37
54
  }
38
55
 
56
+ export type FollowTerminalPhase = 'live' | 'terminal-drain' | 'host-cascade' | 'natural'
57
+
58
+ /**
59
+ * Allocation-free read of the newest stream metric. The follower polls this
60
+ * every frame to learn whether the producer has stopped while reveal work
61
+ * remains, and `getSnapshot()` would allocate a fresh state object per frame
62
+ * just to answer one boolean.
63
+ */
64
+ export function readNewestStreamMetric(): { producerComplete: boolean; backlog: number } | null {
65
+ let newest: StreamMetric | undefined
66
+ for (const candidate of streamMetrics.values()) {
67
+ if (newest === undefined || candidate.updatedAt > newest.updatedAt) newest = candidate
68
+ }
69
+ if (newest === undefined) return null
70
+ return { producerComplete: newest.producerComplete, backlog: newest.backlog }
71
+ }
72
+
39
73
  export interface DebugRuntimeState {
40
74
  available: boolean
41
75
  enabled: boolean
@@ -70,6 +104,12 @@ interface StreamMetric {
70
104
  targetChars: number
71
105
  displayedChars: number
72
106
  active: boolean
107
+ /**
108
+ * The producer (model stream) has stopped even if the reveal queue still owes
109
+ * characters. The follower needs exactly this bit to tell a `terminal-drain`
110
+ * from live streaming, and it cannot be derived from `backlog > 0` alone.
111
+ */
112
+ producerComplete: boolean
73
113
  updatedAt: number
74
114
  }
75
115
 
@@ -85,6 +125,12 @@ interface FollowMetric {
85
125
  scrollHeight: number
86
126
  clientHeight: number
87
127
  active: boolean
128
+ terminalPhase: FollowTerminalPhase
129
+ runwayPx: number
130
+ terminalBudgetPx: number
131
+ baselineShiftPx: number
132
+ readingAnchorDeltaPx: number | null
133
+ remainingRevealChars: number
88
134
  updatedAt: number
89
135
  }
90
136
 
@@ -105,6 +151,12 @@ const EMPTY_METRICS: DebugMetrics = {
105
151
  followRevealScale: 1,
106
152
  followFollowing: false,
107
153
  followConstrained: false,
154
+ followTerminalPhase: 'live',
155
+ followRunwayPx: 0,
156
+ followTerminalBudgetPx: 0,
157
+ followBaselineShiftPx: 0,
158
+ followAnchorDeltaPx: null,
159
+ followRemainingRevealChars: 0,
108
160
  scrollTop: null,
109
161
  scrollHeight: null,
110
162
  clientHeight: null,
@@ -181,6 +233,12 @@ function currentMetrics(timestamp: number): DebugMetrics {
181
233
  followRevealScale: follow?.revealScale ?? 1,
182
234
  followFollowing: follow?.following ?? false,
183
235
  followConstrained: follow?.constrained ?? false,
236
+ followTerminalPhase: follow?.terminalPhase ?? 'live',
237
+ followRunwayPx: follow?.runwayPx ?? 0,
238
+ followTerminalBudgetPx: follow?.terminalBudgetPx ?? 0,
239
+ followBaselineShiftPx: follow?.baselineShiftPx ?? 0,
240
+ followAnchorDeltaPx: follow?.readingAnchorDeltaPx ?? null,
241
+ followRemainingRevealChars: follow?.remainingRevealChars ?? 0,
184
242
  scrollTop: follow?.scrollTop ?? null,
185
243
  scrollHeight: follow?.scrollHeight ?? null,
186
244
  clientHeight: follow?.clientHeight ?? null,