dsh-smooth-stream 0.6.0 → 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,11 +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'
10
+ import { useLogarithmicFade } from './useLogarithmicFade.ts'
11
+ import { useDecoupledMarkdown } from './useDecoupledMarkdown.ts'
9
12
  import { FollowHost } from './FollowHost.tsx'
10
13
  import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
11
14
  import { DEFAULT_STREAM_SETTINGS, type StreamMotionPreference } from '../settings.ts'
@@ -45,17 +48,17 @@ function useMotionReduced(preference: StreamMotionPreference): boolean {
45
48
 
46
49
  interface AnimatedMarkdownTextProps extends MarkdownProps {
47
50
  streaming: boolean
51
+ logarithmicFade: boolean
48
52
  /** Whether the resolved reduced-motion gate keeps the reveal engine off. */
49
53
  motionReduced: boolean
50
- /** True on the last text block: that block owns conversation follow. */
51
- ownFollow: boolean
52
54
  followSpeedCpsRef?: { current: number } | undefined
53
55
  followRevealedCharsRef?: { current: number } | undefined
54
56
  followRevealScaleRef?: { current: number } | undefined
55
57
  onPredictiveChange?: ((predictive: boolean) => void) | undefined
58
+ /** Reports whether the final text arm still has visible reveal work. */
59
+ onRevealActivityChange?: ((active: boolean) => void) | undefined
56
60
  preset: StreamSmoothingPreset
57
61
  shouldHoldBack: () => boolean
58
- controlScroll?: boolean
59
62
  }
60
63
 
61
64
  /** Conservative fallback before the streaming Markdown tail has geometry. */
@@ -80,50 +83,6 @@ function approximateInlineWidth(text: string, emPx: number): number {
80
83
  return width
81
84
  }
82
85
 
83
- function measurePendingTextGeometry(root: HTMLElement, visibleText: string): PendingTextGeometry {
84
- if (
85
- typeof document.createTreeWalker !== 'function'
86
- || typeof NodeFilter === 'undefined'
87
- ) {
88
- return { root, visibleText, fontSize: 14, wrapThresholdWidth: null }
89
- }
90
- const rootRect = root.getBoundingClientRect()
91
- const rootWidth = Math.max(0, rootRect.width, rootRect.right - rootRect.left, root.clientWidth)
92
- if (rootWidth <= 0) return { root, visibleText, fontSize: 14, wrapThresholdWidth: null }
93
-
94
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
95
- let tail: Text | null = null
96
- for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
97
- if ((node.textContent ?? '').length > 0) tail = node as Text
98
- }
99
- const parent = tail?.parentElement ?? root
100
- const fontSize = Number.parseFloat(getComputedStyle(parent).fontSize) || 14
101
- if (tail === null || typeof document.createRange !== 'function') {
102
- return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
103
- }
104
-
105
- try {
106
- const length = tail.textContent?.length ?? 0
107
- if (length <= 0) return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
108
- const range = document.createRange()
109
- range.setStart(tail, Math.max(0, length - 1))
110
- range.setEnd(tail, length)
111
- const tailRect = range.getBoundingClientRect()
112
- const contentRight = rootRect.right
113
- if (!Number.isFinite(tailRect.right) || tailRect.right <= rootRect.left || contentRight <= rootRect.left) {
114
- return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
115
- }
116
- const remainingWidth = Math.max(0, contentRight - tailRect.right)
117
- return {
118
- root,
119
- visibleText,
120
- fontSize,
121
- wrapThresholdWidth: remainingWidth + fontSize * 0.35,
122
- }
123
- } catch {
124
- return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
125
- }
126
- }
127
86
 
128
87
  /** Whether buffered source can reach a new visual line before it drains. */
129
88
  function pendingTextCanGrow(
@@ -135,17 +94,16 @@ function pendingTextCanGrow(
135
94
  if (pending === '') return false
136
95
  if (/[\r\n]/u.test(pending)) return true
137
96
  const pendingChars = [...pending]
138
- 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
139
99
 
140
100
  let geometry = geometryRef.current
141
- if (geometry?.root !== root || geometry.visibleText !== visibleText) {
142
- 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) }
143
104
  geometryRef.current = geometry
144
105
  }
145
- if (geometry.wrapThresholdWidth === null) {
146
- return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
147
- }
148
- return approximateInlineWidth(pending, geometry.fontSize) >= geometry.wrapThresholdWidth
106
+ return approximateInlineWidth(pending, geometry.fontSize) >= (geometry.wrapThresholdWidth ?? (geometry.fontSize * PREDICTIVE_WRAP_FALLBACK_CHARS))
149
107
  }
150
108
 
151
109
  function announcementChunkEnd(source: string, start: number): number {
@@ -297,9 +255,9 @@ const StreamAnnouncement = memo(function StreamAnnouncement({
297
255
  * and rendered by the Harness `MarkdownText`
298
256
  * streaming arm (incremental parse, frozen non-tail blocks), so there is no
299
257
  * raw-text tail and no text-to-markdown swap: the tree stays markdown
300
- * throughout. The last text block owns conversation-port follow so wraps
301
- * glide instead of snapping. Once the stream closes and the reveal queue
302
- * 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
303
261
  * mentions) swaps in exactly once.
304
262
  */
305
263
  function AnimatedMarkdownText({
@@ -307,15 +265,15 @@ function AnimatedMarkdownText({
307
265
  labels,
308
266
  fileMentions,
309
267
  streaming,
268
+ logarithmicFade,
310
269
  motionReduced,
311
- ownFollow,
312
270
  followSpeedCpsRef,
313
271
  followRevealedCharsRef,
314
272
  followRevealScaleRef,
315
273
  onPredictiveChange,
274
+ onRevealActivityChange,
316
275
  preset,
317
276
  shouldHoldBack,
318
- controlScroll = true,
319
277
  }: AnimatedMarkdownTextProps) {
320
278
  const reduced = motionReduced
321
279
  const [typing, setTyping] = useState(streaming)
@@ -337,6 +295,8 @@ function AnimatedMarkdownText({
337
295
  })
338
296
  const shown = reduced ? text : displayed
339
297
  const live = typing && !reduced
298
+ const markdownShown = useDecoupledMarkdown(shown, live)
299
+ useLogarithmicFade(followRootRef, logarithmicFade && !reduced, live, speedCpsRef)
340
300
 
341
301
  useEffect(() => {
342
302
  const root = followRootRef.current
@@ -362,6 +322,10 @@ function AnimatedMarkdownText({
362
322
  onPredictiveChange(next)
363
323
  }, [live, onPredictiveChange, shown, streaming, text])
364
324
 
325
+ useLayoutEffect(() => {
326
+ onRevealActivityChange?.(live)
327
+ }, [live, onRevealActivityChange])
328
+
365
329
  // The stream closed: keep revealing the remaining queue, then swap to the
366
330
  // settled parse exactly once. The markdown tree stays mounted until then.
367
331
  useEffect(() => {
@@ -376,26 +340,20 @@ function AnimatedMarkdownText({
376
340
  if (streaming) setTyping(true)
377
341
  }, [streaming])
378
342
 
343
+ if (!streaming && !live && text.trim() === '') return null
344
+
379
345
  return (
380
- <FollowHost
381
- active={live && ownFollow}
382
- speedCpsRef={speedCpsRef}
383
- revealedCharsRef={followRevealedCharsRef}
384
- revealScaleRef={followRevealScaleRef}
385
- predictive={streaming}
386
- controlScroll={controlScroll}
387
- hostRef={followRootRef}
388
- >
346
+ <div ref={followRootRef} className={css.follow} data-follow-text="">
389
347
  <MarkdownText
390
348
  // `shown` stays authoritative through the completion drain: the
391
349
  // settled parse swaps in only when the queue has actually emptied.
392
350
  // Rendering `text` early bypasses the drain and teleports the tail.
393
- text={live ? shown : text}
351
+ text={live ? markdownShown : text}
394
352
  streaming={live}
395
353
  labels={labels}
396
354
  fileMentions={live ? undefined : fileMentions}
397
355
  />
398
- </FollowHost>
356
+ </div>
399
357
  )
400
358
  }
401
359
 
@@ -414,7 +372,7 @@ function imageLabels(t: AssistantProps['t']): MessageImageLabels {
414
372
  }
415
373
 
416
374
  /**
417
- * Apply searchable hidden state without unmounting a stable subtree — the
375
+ * Apply searchable hidden state without unmounting a stable subtree 鈥?the
418
376
  * Host's completion fold hides the answer-inline reasoning this way, so the
419
377
  * takeover renderer must reproduce it to stay inside the contract: the row
420
378
  * disappears into the Host's process summary and comes back on find-in-page.
@@ -484,8 +442,8 @@ function latestLine(text: string): string {
484
442
  * click stay on the disclosure chrome, which the plugin's AnimatedDisclosure
485
443
  * renders with a height-animated body (the harness primitive would mount and
486
444
  * unmount it, which cannot glide). The row opens only while this block is
487
- * the streaming tail and closes as soon as thinking ends — a later block,
488
- * 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
489
447
  * still streaming.
490
448
  */
491
449
  function AnimatedReasoning({
@@ -494,9 +452,11 @@ function AnimatedReasoning({
494
452
  preset,
495
453
  thinkAutoExpand,
496
454
  motionReduced,
455
+ logarithmicFade,
497
456
  shouldHoldBack,
498
457
  followSpeedCpsRef,
499
458
  followRevealScaleRef,
459
+ snapAutoCollapse,
500
460
  t,
501
461
  }: {
502
462
  text: string
@@ -504,29 +464,47 @@ function AnimatedReasoning({
504
464
  preset: StreamSmoothingPreset
505
465
  thinkAutoExpand: boolean
506
466
  motionReduced: boolean
467
+ logarithmicFade: boolean
507
468
  shouldHoldBack: () => boolean
508
469
  followSpeedCpsRef?: { current: number } | undefined
509
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
510
473
  t: AssistantProps['t']
511
474
  }) {
512
475
  const reduced = motionReduced
513
476
  const [expanded, setExpanded] = useState(running && thinkAutoExpand)
514
477
  const [autoClosed, setAutoClosed] = useState(false)
515
478
  const summaryRef = useRef<HTMLSpanElement>(null)
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
484
+ const localFadeSpeedRef = useRef(35)
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
516
493
  const commitAnchorRef = useRef<HTMLDivElement>(null)
517
- // 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
518
495
  // follower's settle spring absorbs the height step. A later manual toggle
519
496
  // keeps the CSS glide.
520
497
  const displayed = useSmoothStreamContent(text, {
521
498
  enabled: running && !reduced,
522
499
  preset,
523
500
  shouldHoldBack,
524
- speedCpsRef: followSpeedCpsRef,
501
+ speedCpsRef: fadeSpeedRef,
525
502
  revealScaleRef: followRevealScaleRef,
526
503
  onRevealCommit: () => { notifyFollowCommit(commitAnchorRef.current) },
527
504
  })
528
505
  const shown = running && !reduced ? displayed : text
529
506
  const summary = running ? latestLine(shown) : firstLine(text)
507
+ useLogarithmicFade(fadeRootRef, logarithmicFade && !reduced && expanded, running, fadeSpeedRef)
530
508
 
531
509
  useLayoutEffect(() => {
532
510
  // Only the running state owns disclosure while auto-expand is on; with it
@@ -535,12 +513,87 @@ function AnimatedReasoning({
535
513
  setExpanded(running)
536
514
  setAutoClosed(!running)
537
515
  }
516
+ if (running) {
517
+ userScrolledRef.current = false
518
+ }
538
519
  // A commit that changes this block's height must hand the follower its
539
520
  // correction in the SAME task, before the grown-but-uncompensated frame
540
521
  // can reach a paint.
541
522
  notifyFollowCommit(commitAnchorRef.current)
542
523
  }, [running, thinkAutoExpand])
543
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
+
544
597
  useEffect(() => {
545
598
  const element = summaryRef.current
546
599
  if (element === null) return
@@ -557,18 +610,29 @@ function AnimatedReasoning({
557
610
  leadingClassName={css.thinkLeading}
558
611
  titleClassName={css.thinkTitle}
559
612
  chevronClassName={css.thinkChevron}
560
- icon={<IconThinkOutline14 size={14} />}
561
- 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')}
562
623
  open={expanded}
563
624
  onToggle={() => {
564
625
  setAutoClosed(false)
565
626
  setExpanded(value => !value)
566
627
  }}
567
- // The auto-close at stream end snaps (no grid-track animation): the
568
- // follower's settle spring absorbs the height step through the
569
- // compositor, so animating the track too would double-animate the
570
- // collapse. Manual toggles while streaming keep the glide.
571
- 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}
572
636
  collapsedContent={(
573
637
  <>
574
638
  <span className={css.thinkSeparator} aria-hidden />
@@ -576,7 +640,7 @@ function AnimatedReasoning({
576
640
  </>
577
641
  )}
578
642
  >
579
- <div className={css.thinkBody}>{shown}</div>
643
+ <div ref={fadeRootRef} className={css.thinkBody}>{shown}</div>
580
644
  </AnimatedDisclosure>
581
645
  </div>
582
646
  </div>
@@ -588,10 +652,11 @@ function AnimatedReasoning({
588
652
  * streaming is revealed by the smoother through the Harness Markdown
589
653
  * renderer at a rate that tracks arrival. Reasoning blocks keep the
590
654
  * built-in Think disclosure and only receive a smoothed text feed; the
591
- * outer node owns conversation-port follow while streaming; the final text
592
- * block keeps ownership while its settled reveal queue drains. The FPS guard
593
- * holds offscreen reveals when the frame rate is degraded. Settled text
594
- * 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.
595
660
  */
596
661
  export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
597
662
  mode: _mode = DEFAULT_STREAM_CONFIG.mode,
@@ -600,9 +665,11 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
600
665
  scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
601
666
  maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
602
667
  thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
668
+ logarithmicFade = DEFAULT_STREAM_SETTINGS.logarithmicFade,
603
669
  controlScroll = true,
604
670
  motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
605
671
  node,
672
+ groupPart,
606
673
  useTurnData,
607
674
  openFile,
608
675
  loadImage,
@@ -610,17 +677,51 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
610
677
  turnProcess,
611
678
  t,
612
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
613
688
  mode?: StreamMode
614
689
  preset?: StreamSmoothingPreset
615
690
  revealCharsPerSec?: number
616
691
  scrollSpeedPxPerSec?: number
617
692
  maxScrollSpeedPxPerSec?: number
618
693
  thinkAutoExpand?: boolean
694
+ logarithmicFade?: boolean
619
695
  controlScroll?: boolean
620
696
  motionPreference?: StreamMotionPreference
621
697
  }) {
622
698
  const data = node.data
623
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'
624
725
  const reduced = useMotionReduced(motionPreference)
625
726
  // The Host's completion-fold decision for THIS node's inline reasoning:
626
727
  // only the answer step folds, only in compact-transcript mode, and only
@@ -636,8 +737,19 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
636
737
  const rootSpeedRef = useRef(35)
637
738
  const rootRevealedCharsRef = useRef(0)
638
739
  const rootRevealScaleRef = useRef(1)
639
- const reasoningTailIndex = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
640
- ? 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
641
753
  : -1
642
754
  const reasoningOwnsSpeed = reasoningTailIndex !== -1
643
755
  const rootPredictiveRef = useRef(false)
@@ -678,25 +790,36 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
678
790
  })
679
791
  const hasVisible = streaming
680
792
  || data.status === 'interrupted'
681
- || data.blocks.some(block => block.kind !== 'tool-call')
793
+ || blocks.some(block => block.kind !== 'tool-call')
682
794
  if (!hasVisible) return null
683
- const announcementText = data.blocks
795
+ const announcementText = blocks
684
796
  .filter(block => block.kind === 'text')
685
797
  .map(block => block.text)
686
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'
687
807
 
688
808
  const rendered: ReactNode[] = []
689
- const last = data.blocks.length - 1
809
+ const last = blocks.length - 1
690
810
  let lastFollow = -1
691
- for (let index = 0; index < data.blocks.length; index += 1) {
692
- 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
693
814
  if (kind === 'text' || kind === 'reasoning') lastFollow = index
815
+ if (kind === 'text') lastText = index
694
816
  }
695
- for (let index = 0; index < data.blocks.length; index += 1) {
696
- const block = data.blocks[index]
817
+ for (let index = 0; index < blocks.length; index += 1) {
818
+ const block = blocks[index]
697
819
  if (block === undefined) continue
698
820
  switch (block.kind) {
699
821
  case 'text':
822
+ if (!streaming && block.text.trim() === '') break
700
823
  rendered.push(
701
824
  <AnimatedMarkdownText
702
825
  key={index}
@@ -704,15 +827,15 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
704
827
  labels={markdownLabels}
705
828
  fileMentions={mentions}
706
829
  streaming={streaming}
830
+ logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
707
831
  motionReduced={reduced}
708
- ownFollow={!streaming && index === lastFollow}
709
832
  followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
710
833
  followRevealedCharsRef={index === lastFollow ? rootRevealedCharsRef : undefined}
711
834
  followRevealScaleRef={index === lastFollow ? rootRevealScaleRef : undefined}
712
835
  onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
836
+ onRevealActivityChange={index === lastText ? updateTextRevealActivity : undefined}
713
837
  preset={preset}
714
838
  shouldHoldBack={shouldHoldBack}
715
- controlScroll={controlScroll}
716
839
  />,
717
840
  )
718
841
  break
@@ -721,13 +844,15 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
721
844
  <FoldableReasoning key={index} hidden={reasoningHidden} reveal={revealProcess}>
722
845
  <AnimatedReasoning
723
846
  text={block.text}
724
- running={streaming && index === last}
847
+ running={streaming && index === last && (groupPart !== 'reasoning' || stepTailIsReasoning)}
725
848
  preset={preset}
726
849
  thinkAutoExpand={thinkAutoExpand}
850
+ logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
727
851
  motionReduced={reduced}
728
852
  shouldHoldBack={shouldHoldBack}
729
853
  followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
730
854
  followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
855
+ snapAutoCollapse={groupPart === undefined}
731
856
  t={t}
732
857
  />
733
858
  </FoldableReasoning>,
@@ -736,8 +861,8 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
736
861
  case 'image': {
737
862
  const start = index
738
863
  const group = [block]
739
- while (index + 1 < data.blocks.length) {
740
- const next = data.blocks[index + 1]
864
+ while (index + 1 < blocks.length) {
865
+ const next = blocks[index + 1]
741
866
  if (next === undefined || next.kind !== 'image') break
742
867
  group.push(next)
743
868
  index += 1
@@ -766,12 +891,15 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
766
891
  <div ref={guardRef} className={css.root} data-streaming={streaming || undefined}>
767
892
  <StreamAnnouncement text={announcementText} active={streaming && !reduced} />
768
893
  <FollowHost
769
- 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)}
770
898
  speedCpsRef={rootSpeedRef}
771
899
  revealedCharsRef={rootRevealedCharsRef}
772
900
  revealScaleRef={rootRevealScaleRef}
773
901
  predictiveRef={rootPredictiveRef}
774
- controlScroll={controlScroll}
902
+ controlScroll={controlScroll && rowOwnsPort}
775
903
  >
776
904
  <div className={css.body}>
777
905
  {rendered}