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.
- package/README.en.md +4 -1
- package/README.md +5 -1
- package/lib/client.js +1757 -282
- package/lib/client.js.map +1 -1
- package/lib/index.js +299 -3
- 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/LogarithmicFade.module.css +160 -0
- package/src/client/SmoothStreamCard.tsx +41 -3
- package/src/client/StreamBuffer.ts +145 -0
- package/src/client/TypewriterAssistantNodeView.module.css +82 -12
- package/src/client/TypewriterAssistantNodeView.tsx +232 -104
- package/src/client/debugRuntime.ts +58 -0
- package/src/client/harnessIcons.ts +27 -0
- package/src/client/index.ts +153 -11
- package/src/client/locales.ts +90 -0
- package/src/client/smooth-stream-card-controller.ts +10 -3
- package/src/client/smooth-stream-settings-api.ts +7 -3
- package/src/client/teleprompterGlide.ts +499 -70
- package/src/client/useDecoupledMarkdown.ts +173 -0
- package/src/client/useFpsGuard.ts +9 -6
- package/src/client/useLogarithmicFade.ts +672 -0
- package/src/client/useProgressiveDomText.ts +27 -11
- package/src/client/useSmoothStreamContent.ts +5 -13
- package/src/config.ts +1 -1
- package/src/plugin.ts +99 -12
- package/src/settings-api.ts +4 -0
- package/src/settings-channel.ts +303 -0
- package/src/settings.ts +14 -0
|
@@ -1,11 +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'
|
|
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 (
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
301
|
-
*
|
|
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
|
-
<
|
|
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 ?
|
|
351
|
+
text={live ? markdownShown : text}
|
|
394
352
|
streaming={live}
|
|
395
353
|
labels={labels}
|
|
396
354
|
fileMentions={live ? undefined : fileMentions}
|
|
397
355
|
/>
|
|
398
|
-
</
|
|
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
|
|
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
|
|
488
|
-
* 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
|
|
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
|
|
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:
|
|
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={<
|
|
561
|
-
|
|
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
|
|
568
|
-
//
|
|
569
|
-
// compositor, so animating the track too would
|
|
570
|
-
// collapse.
|
|
571
|
-
|
|
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
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* 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.
|
|
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
|
|
640
|
-
|
|
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
|
-
||
|
|
793
|
+
|| blocks.some(block => block.kind !== 'tool-call')
|
|
682
794
|
if (!hasVisible) return null
|
|
683
|
-
const announcementText =
|
|
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 =
|
|
809
|
+
const last = blocks.length - 1
|
|
690
810
|
let lastFollow = -1
|
|
691
|
-
|
|
692
|
-
|
|
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 <
|
|
696
|
-
const block =
|
|
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 <
|
|
740
|
-
const next =
|
|
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
|
-
|
|
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}
|