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.
- package/lib/client.js +1508 -362
- package/lib/client.js.map +1 -1
- package/lib/index.js +102 -8
- package/package.json +1 -1
- package/src/client/AgentRowEntrance.module.css +1 -4
- package/src/client/AnimatedDisclosure.tsx +3 -3
- package/src/client/DebugPanel.tsx +34 -15
- package/src/client/FrameCoordinator.ts +180 -0
- package/src/client/SmoothStreamCard.tsx +28 -3
- package/src/client/StreamBuffer.ts +145 -0
- package/src/client/TypewriterAssistantNodeView.module.css +82 -12
- package/src/client/TypewriterAssistantNodeView.tsx +216 -102
- package/src/client/debugRuntime.ts +58 -0
- package/src/client/harnessIcons.ts +27 -0
- package/src/client/index.ts +151 -11
- package/src/client/locales.ts +85 -0
- package/src/client/smooth-stream-card-controller.ts +7 -3
- package/src/client/smooth-stream-settings-api.ts +4 -2
- package/src/client/teleprompterGlide.ts +491 -52
- package/src/client/useDecoupledMarkdown.ts +173 -0
- package/src/client/useFpsGuard.ts +9 -6
- package/src/client/useLogarithmicFade.ts +439 -60
- package/src/client/useProgressiveDomText.ts +27 -11
- package/src/client/useSmoothStreamContent.ts +5 -13
- package/src/config.ts +1 -1
- package/src/plugin.ts +83 -8
- package/src/settings-api.ts +2 -0
- package/src/settings-channel.ts +34 -8
- package/src/settings.ts +11 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from 'react'
|
|
2
|
-
import {
|
|
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 (
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
303
|
-
*
|
|
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
|
-
<
|
|
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 ?
|
|
351
|
+
text={live ? markdownShown : text}
|
|
398
352
|
streaming={live}
|
|
399
353
|
labels={labels}
|
|
400
354
|
fileMentions={live ? undefined : fileMentions}
|
|
401
355
|
/>
|
|
402
|
-
</
|
|
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
|
|
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
|
|
492
|
-
* or the assistant node settling
|
|
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
|
|
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={<
|
|
571
|
-
|
|
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
|
|
578
|
-
//
|
|
579
|
-
// compositor, so animating the track too would
|
|
580
|
-
// collapse.
|
|
581
|
-
|
|
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
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
* renders with the full
|
|
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
|
|
652
|
-
|
|
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
|
-
||
|
|
793
|
+
|| blocks.some(block => block.kind !== 'tool-call')
|
|
694
794
|
if (!hasVisible) return null
|
|
695
|
-
const announcementText =
|
|
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 =
|
|
809
|
+
const last = blocks.length - 1
|
|
702
810
|
let lastFollow = -1
|
|
703
|
-
|
|
704
|
-
|
|
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 <
|
|
708
|
-
const block =
|
|
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 <
|
|
754
|
-
const next =
|
|
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
|
-
|
|
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,
|