dsh-smooth-stream 0.3.3 → 0.3.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.3.3",
4
- "description": "DeepSeek Harness (dsh) plugin: silky streaming reveal, no flicker. dsh 丝滑流式渲染插件。",
3
+ "version": "0.3.4",
4
+ "description": "Fluid streaming rendering and silky scrolling for DeepSeek Harness replies, including Markdown, code blocks, tables, and tool results.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "engines": {
@@ -23,6 +23,11 @@
23
23
  "cordis",
24
24
  "streaming",
25
25
  "smooth-streaming",
26
+ "fluid-streaming",
27
+ "streaming-renderer",
28
+ "markdown-streaming",
29
+ "smooth-scrolling",
30
+ "smooth-scroll",
26
31
  "smooth-stream",
27
32
  "typewriter",
28
33
  "typewriter-effect",
@@ -29,7 +29,6 @@ function usePrefersReducedMotion(): boolean {
29
29
 
30
30
  interface AnimatedMarkdownTextProps extends MarkdownProps {
31
31
  streaming: boolean
32
- announce: boolean
33
32
  /** True on the last text block: that block owns conversation follow. */
34
33
  ownFollow: boolean
35
34
  followSpeedCpsRef?: { current: number } | undefined
@@ -41,6 +40,15 @@ interface AnimatedMarkdownTextProps extends MarkdownProps {
41
40
 
42
41
  /** Conservative fallback before the streaming Markdown tail has geometry. */
43
42
  const PREDICTIVE_WRAP_FALLBACK_CHARS = 32
43
+ const STREAM_ANNOUNCEMENT_INTERVAL_MS = 800
44
+ const STREAM_ANNOUNCEMENT_MAX_CHARS = 320
45
+
46
+ interface PendingTextGeometry {
47
+ root: HTMLElement
48
+ visibleText: string
49
+ fontSize: number
50
+ wrapThresholdWidth: number | null
51
+ }
44
52
 
45
53
  function approximateInlineWidth(text: string, emPx: number): number {
46
54
  let width = 0
@@ -52,22 +60,16 @@ function approximateInlineWidth(text: string, emPx: number): number {
52
60
  return width
53
61
  }
54
62
 
55
- /** Whether buffered source can reach a new visual line before it drains. */
56
- function pendingTextCanGrow(root: HTMLElement | null, pending: string): boolean {
57
- if (pending === '') return false
58
- if (/[\r\n]/u.test(pending)) return true
59
- const pendingChars = [...pending]
63
+ function measurePendingTextGeometry(root: HTMLElement, visibleText: string): PendingTextGeometry {
60
64
  if (
61
- root === null
62
- || typeof document.createTreeWalker !== 'function'
65
+ typeof document.createTreeWalker !== 'function'
63
66
  || typeof NodeFilter === 'undefined'
64
67
  ) {
65
- return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
68
+ return { root, visibleText, fontSize: 14, wrapThresholdWidth: null }
66
69
  }
67
-
68
70
  const rootRect = root.getBoundingClientRect()
69
71
  const rootWidth = Math.max(0, rootRect.width, rootRect.right - rootRect.left, root.clientWidth)
70
- if (rootWidth <= 0) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
72
+ if (rootWidth <= 0) return { root, visibleText, fontSize: 14, wrapThresholdWidth: null }
71
73
 
72
74
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
73
75
  let tail: Text | null = null
@@ -76,29 +78,199 @@ function pendingTextCanGrow(root: HTMLElement | null, pending: string): boolean
76
78
  }
77
79
  const parent = tail?.parentElement ?? root
78
80
  const fontSize = Number.parseFloat(getComputedStyle(parent).fontSize) || 14
79
- const pendingWidth = approximateInlineWidth(pending, fontSize)
80
81
  if (tail === null || typeof document.createRange !== 'function') {
81
- return pendingWidth >= rootWidth
82
+ return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
82
83
  }
83
84
 
84
85
  try {
85
86
  const length = tail.textContent?.length ?? 0
86
- if (length <= 0) return pendingWidth >= rootWidth
87
+ if (length <= 0) return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
87
88
  const range = document.createRange()
88
89
  range.setStart(tail, Math.max(0, length - 1))
89
90
  range.setEnd(tail, length)
90
91
  const tailRect = range.getBoundingClientRect()
91
92
  const contentRight = rootRect.right
92
93
  if (!Number.isFinite(tailRect.right) || tailRect.right <= rootRect.left || contentRight <= rootRect.left) {
93
- return pendingWidth >= rootWidth
94
+ return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
94
95
  }
95
96
  const remainingWidth = Math.max(0, contentRight - tailRect.right)
96
- return pendingWidth >= remainingWidth + fontSize * 0.35
97
+ return {
98
+ root,
99
+ visibleText,
100
+ fontSize,
101
+ wrapThresholdWidth: remainingWidth + fontSize * 0.35,
102
+ }
97
103
  } catch {
98
- return pendingWidth >= rootWidth
104
+ return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
105
+ }
106
+ }
107
+
108
+ /** Whether buffered source can reach a new visual line before it drains. */
109
+ function pendingTextCanGrow(
110
+ root: HTMLElement | null,
111
+ visibleText: string,
112
+ pending: string,
113
+ geometryRef: { current: PendingTextGeometry | null },
114
+ ): boolean {
115
+ if (pending === '') return false
116
+ if (/[\r\n]/u.test(pending)) return true
117
+ const pendingChars = [...pending]
118
+ if (root === null) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
119
+
120
+ let geometry = geometryRef.current
121
+ if (geometry?.root !== root || geometry.visibleText !== visibleText) {
122
+ geometry = measurePendingTextGeometry(root, visibleText)
123
+ geometryRef.current = geometry
99
124
  }
125
+ if (geometry.wrapThresholdWidth === null) {
126
+ return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
127
+ }
128
+ return approximateInlineWidth(pending, geometry.fontSize) >= geometry.wrapThresholdWidth
129
+ }
130
+
131
+ function announcementChunkEnd(source: string, start: number): number {
132
+ const hardEnd = Math.min(source.length, start + STREAM_ANNOUNCEMENT_MAX_CHARS)
133
+ if (hardEnd === source.length) return hardEnd
134
+ const softStart = start + Math.floor(STREAM_ANNOUNCEMENT_MAX_CHARS * 0.6)
135
+ for (let index = hardEnd - 1; index >= softStart; index -= 1) {
136
+ if (/[\s.,;:!?]/u.test(source[index] ?? '')) return index + 1
137
+ }
138
+ return hardEnd
100
139
  }
101
140
 
141
+ /** Pace screen-reader updates independently from visual reveal frames. */
142
+ interface StreamAnnouncementState {
143
+ text: string
144
+ revision: number
145
+ present: boolean
146
+ }
147
+
148
+ /** A commit-driven live region isolated from the visible Markdown subtree. */
149
+ const StreamAnnouncement = memo(function StreamAnnouncement({
150
+ text,
151
+ active,
152
+ }: {
153
+ text: string
154
+ active: boolean
155
+ }) {
156
+ const [announcement, setAnnouncement] = useState<StreamAnnouncementState>({
157
+ text: '',
158
+ revision: 0,
159
+ present: active,
160
+ })
161
+ const sourceRef = useRef(text)
162
+ const activeRef = useRef(active)
163
+ const announcedOffsetRef = useRef(active ? 0 : text.length)
164
+ const drainSourceRef = useRef<string | null>(null)
165
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
166
+
167
+ useEffect(() => {
168
+ const clearTimer = (): void => {
169
+ if (timerRef.current === null) return
170
+ clearTimeout(timerRef.current)
171
+ timerRef.current = null
172
+ }
173
+
174
+ const publishNext = (source: string): boolean => {
175
+ const start = Math.min(announcedOffsetRef.current, source.length)
176
+ const end = announcementChunkEnd(source, start)
177
+ if (end <= start) return false
178
+ announcedOffsetRef.current = end
179
+ setAnnouncement(previous => ({
180
+ text: source.slice(start, end),
181
+ revision: previous.revision + 1,
182
+ present: true,
183
+ }))
184
+ return end < source.length
185
+ }
186
+
187
+ const hideAfterLinger = (): void => {
188
+ timerRef.current = setTimeout(() => {
189
+ timerRef.current = null
190
+ if (activeRef.current) return
191
+ drainSourceRef.current = null
192
+ setAnnouncement(previous => ({ ...previous, present: false }))
193
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS)
194
+ }
195
+
196
+ const drainNext = (): void => {
197
+ const source = drainSourceRef.current
198
+ if (source === null) return
199
+ if (!publishNext(source)) {
200
+ hideAfterLinger()
201
+ return
202
+ }
203
+ timerRef.current = setTimeout(() => {
204
+ timerRef.current = null
205
+ if (activeRef.current) return
206
+ drainNext()
207
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS)
208
+ }
209
+
210
+ const scheduleLive = (): void => {
211
+ if (timerRef.current !== null || announcedOffsetRef.current >= sourceRef.current.length) return
212
+ timerRef.current = setTimeout(() => {
213
+ timerRef.current = null
214
+ if (!activeRef.current) return
215
+ const source = sourceRef.current
216
+ if (publishNext(source)) scheduleLive()
217
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS)
218
+ }
219
+
220
+ const wasActive = activeRef.current
221
+ const previousSource = sourceRef.current
222
+ const continuingDrain = !active && !wasActive && drainSourceRef.current !== null
223
+ if (
224
+ !continuingDrain
225
+ && (!text.startsWith(previousSource) || announcedOffsetRef.current > text.length)
226
+ ) {
227
+ announcedOffsetRef.current = 0
228
+ }
229
+ sourceRef.current = text
230
+ activeRef.current = active
231
+
232
+ if (active) {
233
+ if (!wasActive) {
234
+ clearTimer()
235
+ drainSourceRef.current = null
236
+ setAnnouncement(previous => ({
237
+ text: '',
238
+ revision: previous.revision + 1,
239
+ present: true,
240
+ }))
241
+ }
242
+ scheduleLive()
243
+ return
244
+ }
245
+
246
+ if (!wasActive) {
247
+ if (drainSourceRef.current === null) announcedOffsetRef.current = text.length
248
+ return
249
+ }
250
+
251
+ clearTimer()
252
+ drainSourceRef.current = text
253
+ drainNext()
254
+ }, [active, text])
255
+
256
+ useEffect(() => () => {
257
+ if (timerRef.current !== null) clearTimeout(timerRef.current)
258
+ timerRef.current = null
259
+ }, [])
260
+
261
+ if (!active && !announcement.present) return null
262
+ const activating = active && !activeRef.current
263
+ return (
264
+ <span
265
+ className={css.visuallyHidden}
266
+ aria-live="polite"
267
+ aria-atomic="true"
268
+ >
269
+ <span key={announcement.revision}>{activating ? '' : announcement.text}</span>
270
+ </span>
271
+ )
272
+ })
273
+
102
274
  /**
103
275
  * Smooth streaming text arm. While the reply runs, the accumulated source is
104
276
  * revealed through the smoother at a rate that tracks the model's arrival
@@ -115,7 +287,6 @@ function AnimatedMarkdownText({
115
287
  codeLabels,
116
288
  fileMentions,
117
289
  streaming,
118
- announce,
119
290
  ownFollow,
120
291
  followSpeedCpsRef,
121
292
  followRevealScaleRef,
@@ -129,6 +300,7 @@ function AnimatedMarkdownText({
129
300
  const followRootRef = useRef<HTMLDivElement>(null)
130
301
  const predictionSourceRef = useRef<string | null>(null)
131
302
  const predictionStateRef = useRef(false)
303
+ const predictionGeometryRef = useRef<PendingTextGeometry | null>(null)
132
304
  const speedCpsRef = followSpeedCpsRef ?? localSpeedCpsRef
133
305
  const displayed = useSmoothStreamContent(text, {
134
306
  enabled: typing && !reduced,
@@ -141,6 +313,16 @@ function AnimatedMarkdownText({
141
313
  const shown = reduced ? text : displayed
142
314
  const live = typing && !reduced
143
315
 
316
+ useEffect(() => {
317
+ const root = followRootRef.current
318
+ if (root === null || typeof ResizeObserver === 'undefined') return
319
+ const observer = new ResizeObserver(() => {
320
+ if (predictionGeometryRef.current?.root === root) predictionGeometryRef.current = null
321
+ })
322
+ observer.observe(root)
323
+ return () => { observer.disconnect() }
324
+ }, [])
325
+
144
326
  useLayoutEffect(() => {
145
327
  if (onPredictiveChange === undefined) return
146
328
  const pending = text.slice(shown.length)
@@ -148,7 +330,7 @@ function AnimatedMarkdownText({
148
330
  const next = !live || !streaming || pending === ''
149
331
  ? false
150
332
  : sourceChanged
151
- ? pendingTextCanGrow(followRootRef.current, pending)
333
+ ? pendingTextCanGrow(followRootRef.current, shown, pending, predictionGeometryRef)
152
334
  : predictionStateRef.current
153
335
  predictionSourceRef.current = text
154
336
  predictionStateRef.current = next
@@ -162,23 +344,20 @@ function AnimatedMarkdownText({
162
344
  }, [shown, streaming, text, typing])
163
345
 
164
346
  return (
165
- <>
166
- {live && announce && <span className={css.visuallyHidden} aria-live="polite">{text}</span>}
167
- <FollowHost
168
- active={live && ownFollow}
169
- speedCpsRef={speedCpsRef}
170
- revealScaleRef={followRevealScaleRef}
171
- predictive={streaming}
172
- hostRef={followRootRef}
173
- >
174
- <MarkdownText
175
- text={live ? shown : text}
176
- streaming={live}
177
- codeLabels={codeLabels}
178
- fileMentions={live ? undefined : fileMentions}
179
- />
180
- </FollowHost>
181
- </>
347
+ <FollowHost
348
+ active={live && ownFollow}
349
+ speedCpsRef={speedCpsRef}
350
+ revealScaleRef={followRevealScaleRef}
351
+ predictive={streaming}
352
+ hostRef={followRootRef}
353
+ >
354
+ <MarkdownText
355
+ text={live ? shown : text}
356
+ streaming={live}
357
+ codeLabels={codeLabels}
358
+ fileMentions={live ? undefined : fileMentions}
359
+ />
360
+ </FollowHost>
182
361
  )
183
362
  }
184
363
 
@@ -224,7 +403,6 @@ function AnimatedReasoning({
224
403
  shouldHoldBack,
225
404
  followSpeedCpsRef,
226
405
  followRevealScaleRef,
227
- onExpandedChange,
228
406
  t,
229
407
  }: {
230
408
  text: string
@@ -234,7 +412,6 @@ function AnimatedReasoning({
234
412
  shouldHoldBack: () => boolean
235
413
  followSpeedCpsRef?: { current: number } | undefined
236
414
  followRevealScaleRef?: { current: number } | undefined
237
- onExpandedChange?: ((expanded: boolean) => void) | undefined
238
415
  t: AssistantProps['t']
239
416
  }) {
240
417
  const reduced = usePrefersReducedMotion()
@@ -256,10 +433,6 @@ function AnimatedReasoning({
256
433
  if (thinkAutoExpand) setExpanded(running)
257
434
  }, [running, thinkAutoExpand])
258
435
 
259
- useLayoutEffect(() => {
260
- onExpandedChange?.(expanded)
261
- }, [expanded, onExpandedChange])
262
-
263
436
  useEffect(() => {
264
437
  const element = summaryRef.current
265
438
  if (element === null) return
@@ -338,14 +511,15 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
338
511
  const rootPredictiveRef = useRef(false)
339
512
  const previousReasoningTailRef = useRef(-1)
340
513
  if (reasoningTailIndex !== previousReasoningTailRef.current) {
341
- rootPredictiveRef.current = reasoningOwnsSpeed ? thinkAutoExpand : false
514
+ // Think growth is already paced by its own text reveal. Opening additional
515
+ // speculative runway here exposes that runway as an empty gap above the
516
+ // fixed turn status, especially when reasoning arrives in fast bursts.
517
+ // The follower still smooths real height growth within the measured gap
518
+ // and catches up any unsafe remainder in the same frame.
519
+ rootPredictiveRef.current = false
342
520
  if (!reasoningOwnsSpeed) rootSpeedRef.current = 35
343
521
  previousReasoningTailRef.current = reasoningTailIndex
344
522
  }
345
- const updateReasoningExpanded = useMemo(
346
- () => (expanded: boolean): void => { rootPredictiveRef.current = expanded },
347
- [],
348
- )
349
523
  const updateTextPrediction = useMemo(
350
524
  () => (predictive: boolean): void => { rootPredictiveRef.current = predictive },
351
525
  [],
@@ -371,6 +545,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
371
545
  || data.status === 'interrupted'
372
546
  || data.blocks.some(block => block.kind !== 'tool-call')
373
547
  if (!hasVisible) return null
548
+ const announcementText = data.blocks
549
+ .filter(block => block.kind === 'text')
550
+ .map(block => block.text)
551
+ .join('\n')
374
552
 
375
553
  const rendered: ReactNode[] = []
376
554
  const last = data.blocks.length - 1
@@ -391,7 +569,6 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
391
569
  codeLabels={codeLabels}
392
570
  fileMentions={mentions}
393
571
  streaming={streaming}
394
- announce={index === last}
395
572
  ownFollow={!streaming && index === lastFollow}
396
573
  followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
397
574
  followRevealScaleRef={index === lastFollow ? rootRevealScaleRef : undefined}
@@ -412,7 +589,6 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
412
589
  shouldHoldBack={shouldHoldBack}
413
590
  followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
414
591
  followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
415
- onExpandedChange={index === reasoningTailIndex ? updateReasoningExpanded : undefined}
416
592
  t={t}
417
593
  />,
418
594
  )
@@ -448,6 +624,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
448
624
 
449
625
  return (
450
626
  <div ref={guardRef} className={css.root} data-streaming={streaming || undefined}>
627
+ <StreamAnnouncement text={announcementText} active={streaming && !reduced} />
451
628
  <FollowHost
452
629
  active={streaming && !reduced}
453
630
  speedCpsRef={rootSpeedRef}
@@ -269,7 +269,18 @@ interface FollowRunway {
269
269
  readonly property: 'marginBottom' | 'marginTop'
270
270
  }
271
271
 
272
- const followRunways = new WeakMap<HTMLElement, FollowRunway>()
272
+ /**
273
+ * The plugin can be reinjected without replacing the conversation DOM. Keep
274
+ * runway ownership in the page realm so a fresh bundle adopts the existing
275
+ * margin instead of treating it as host layout and adding another 48px.
276
+ */
277
+ const FOLLOW_RUNWAYS_SYMBOL = Symbol.for('dsh-smooth-stream.follow-runways')
278
+ const followRunwayRegistry = globalThis as typeof globalThis & {
279
+ [key: symbol]: WeakMap<HTMLElement, FollowRunway> | undefined
280
+ }
281
+ const followRunways = followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL]
282
+ ?? new WeakMap<HTMLElement, FollowRunway>()
283
+ followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL] = followRunways
273
284
 
274
285
  interface FollowPaintLimit {
275
286
  readonly clientHeight: number
@@ -316,9 +327,61 @@ function restoreRunway(port: HTMLElement): void {
316
327
  invalidatePaintLimit(port)
317
328
  }
318
329
 
330
+ function isLegacyRunway(value: string): boolean {
331
+ if (value === '') return false
332
+ const terms = [...value.matchAll(/([\d.]+)px/g)]
333
+ if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, '') !== '') return false
334
+ return terms.every(([, raw]) => {
335
+ const px = Number(raw)
336
+ return Number.isFinite(px)
337
+ && px >= FOLLOW_STATUS_RUNWAY_PX
338
+ && Math.abs(px % FOLLOW_STATUS_RUNWAY_PX) <= Number.EPSILON
339
+ })
340
+ }
341
+
342
+ /** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
343
+ function migrateLegacyRunway(
344
+ port: HTMLElement,
345
+ surfaces: readonly HTMLElement[],
346
+ status: HTMLElement | null,
347
+ composer: HTMLElement | null,
348
+ ): void {
349
+ if (followRunways.has(port)) return
350
+ let migrated = false
351
+ if (status !== null && isLegacyRunway(status.style.marginTop)) {
352
+ // Harness TurnStatus has no inline margin; exact 48px multiples here are
353
+ // values emitted by the old runway writer, including reload accumulation.
354
+ status.style.marginTop = ''
355
+ migrated = true
356
+ }
357
+ const last = surfaces.at(-1)
358
+ if (
359
+ status === null
360
+ && composer !== null
361
+ && last !== undefined
362
+ && isLegacyRunway(last.style.marginBottom)
363
+ ) {
364
+ // Without TurnStatus the old writer used the current final message as its
365
+ // completion runway. Limit migration to that same target topology.
366
+ last.style.marginBottom = ''
367
+ migrated = true
368
+ }
369
+ if (migrated) invalidatePaintLimit(port)
370
+ }
371
+
319
372
  function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void {
320
373
  const status = turnStatusOf(port)
321
374
  const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
375
+ migrateLegacyRunway(port, surfaces, status, composer)
376
+ // A runway is useful only after the natural conversation already has a
377
+ // scroll floor for its equal message transform to ride. Before that point
378
+ // applyVisual keeps every surface in normal flow, so adding status margin
379
+ // would expose the whole runway as empty space below a short/early Think.
380
+ const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
381
+ if (port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
382
+ restoreRunway(port)
383
+ return
384
+ }
322
385
  const target = status === null
323
386
  ? { element: composer === null ? undefined : surfaces.at(-1), property: 'marginBottom' as const }
324
387
  : { element: status, property: 'marginTop' as const }