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/README.en.md +9 -14
- package/README.md +9 -14
- package/lib/client.js +265 -68
- package/lib/client.js.map +1 -1
- package/package.json +7 -2
- package/src/client/TypewriterAssistantNodeView.tsx +226 -49
- package/src/client/teleprompterGlide.ts +64 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-smooth-stream",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "DeepSeek Harness
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|| typeof document.createTreeWalker !== 'function'
|
|
65
|
+
typeof document.createTreeWalker !== 'function'
|
|
63
66
|
|| typeof NodeFilter === 'undefined'
|
|
64
67
|
) {
|
|
65
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
94
|
+
return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
|
|
94
95
|
}
|
|
95
96
|
const remainingWidth = Math.max(0, contentRight - tailRect.right)
|
|
96
|
-
return
|
|
97
|
+
return {
|
|
98
|
+
root,
|
|
99
|
+
visibleText,
|
|
100
|
+
fontSize,
|
|
101
|
+
wrapThresholdWidth: remainingWidth + fontSize * 0.35,
|
|
102
|
+
}
|
|
97
103
|
} catch {
|
|
98
|
-
return
|
|
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 &&
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|