dsh-smooth-stream 0.3.1 → 0.3.2
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.
|
@@ -218,8 +218,8 @@
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
/*
|
|
221
|
-
* Follow host: ordinary document flow. The
|
|
222
|
-
*
|
|
221
|
+
* Follow host: ordinary document flow. The assistant node owns follow during
|
|
222
|
+
* streaming; the last text block takes over while its settled queue drains
|
|
223
223
|
* (see useConversationFollow).
|
|
224
224
|
*/
|
|
225
225
|
.follow {
|
|
@@ -128,6 +128,7 @@ function AnimatedReasoning({
|
|
|
128
128
|
preset,
|
|
129
129
|
thinkAutoExpand,
|
|
130
130
|
shouldHoldBack,
|
|
131
|
+
followSpeedCpsRef,
|
|
131
132
|
t,
|
|
132
133
|
}: {
|
|
133
134
|
text: string
|
|
@@ -135,17 +136,17 @@ function AnimatedReasoning({
|
|
|
135
136
|
preset: StreamSmoothingPreset
|
|
136
137
|
thinkAutoExpand: boolean
|
|
137
138
|
shouldHoldBack: () => boolean
|
|
139
|
+
followSpeedCpsRef?: { current: number } | undefined
|
|
138
140
|
t: AssistantProps['t']
|
|
139
141
|
}) {
|
|
140
142
|
const reduced = usePrefersReducedMotion()
|
|
141
143
|
const [expanded, setExpanded] = useState(running && thinkAutoExpand)
|
|
142
144
|
const summaryRef = useRef<HTMLSpanElement>(null)
|
|
143
|
-
const speedCpsRef = useRef(35)
|
|
144
145
|
const displayed = useSmoothStreamContent(text, {
|
|
145
146
|
enabled: running && !reduced,
|
|
146
147
|
preset,
|
|
147
148
|
shouldHoldBack,
|
|
148
|
-
speedCpsRef,
|
|
149
|
+
speedCpsRef: followSpeedCpsRef,
|
|
149
150
|
})
|
|
150
151
|
const shown = running && !reduced ? displayed : text
|
|
151
152
|
const summary = running ? latestLine(shown) : firstLine(text)
|
|
@@ -162,8 +163,9 @@ function AnimatedReasoning({
|
|
|
162
163
|
element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
|
|
163
164
|
}, [running, summary])
|
|
164
165
|
|
|
166
|
+
// Preserve FollowHost's layout wrapper without mounting a second scroll owner.
|
|
165
167
|
return (
|
|
166
|
-
<
|
|
168
|
+
<div className={css.follow}>
|
|
167
169
|
<div className={css.think} data-variant="think" data-state={running ? 'running' : 'ok'}>
|
|
168
170
|
{running && <span className={css.visuallyHidden}>{t('row.running')}</span>}
|
|
169
171
|
<AnimatedDisclosure
|
|
@@ -185,7 +187,7 @@ function AnimatedReasoning({
|
|
|
185
187
|
<div className={css.thinkBody}>{shown}</div>
|
|
186
188
|
</AnimatedDisclosure>
|
|
187
189
|
</div>
|
|
188
|
-
</
|
|
190
|
+
</div>
|
|
189
191
|
)
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -194,10 +196,10 @@ function AnimatedReasoning({
|
|
|
194
196
|
* streaming is revealed by the smoother through the Harness Markdown
|
|
195
197
|
* renderer at a rate that tracks arrival. Reasoning blocks keep the
|
|
196
198
|
* built-in Think disclosure and only receive a smoothed text feed; the
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* reveals when the frame rate is degraded. Settled text
|
|
200
|
-
* full Markdown pipeline.
|
|
199
|
+
* outer node owns conversation-port follow while streaming; the final text
|
|
200
|
+
* block keeps ownership while its settled reveal queue drains. The FPS guard
|
|
201
|
+
* holds offscreen reveals when the frame rate is degraded. Settled text
|
|
202
|
+
* renders with the full Markdown pipeline.
|
|
201
203
|
*/
|
|
202
204
|
export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
|
|
203
205
|
mode: _mode = DEFAULT_STREAM_CONFIG.mode,
|
|
@@ -224,6 +226,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
224
226
|
const streaming = data.status === 'running'
|
|
225
227
|
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
226
228
|
const rootSpeedRef = useRef(35)
|
|
229
|
+
const reasoningOwnsSpeed = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!reasoningOwnsSpeed) rootSpeedRef.current = 35
|
|
232
|
+
}, [reasoningOwnsSpeed])
|
|
227
233
|
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
|
228
234
|
? node.location.turn
|
|
229
235
|
: undefined
|
|
@@ -281,6 +287,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
281
287
|
preset={preset}
|
|
282
288
|
thinkAutoExpand={thinkAutoExpand}
|
|
283
289
|
shouldHoldBack={shouldHoldBack}
|
|
290
|
+
followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
|
|
284
291
|
t={t}
|
|
285
292
|
/>,
|
|
286
293
|
)
|
|
@@ -14,17 +14,15 @@
|
|
|
14
14
|
* - while the port has scroll room, writes the interpolated lag as
|
|
15
15
|
* `translate3d` on `[data-chat-transcript]` (message rows only), so the
|
|
16
16
|
* turn-status chrome stays pinned at the floor;
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* - clips only the part of the translated surface that crosses the
|
|
18
|
+
* turn-status line, preserving the normal gap above that line;
|
|
19
|
+
* - leaves `[data-chat-turn-status]` in normal flow at every height, so its
|
|
20
|
+
* layout gap cannot be consumed by an interpolated transform.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `FOLLOW_THRESHOLD`, releasing its native snap-follow instead of fighting
|
|
27
|
-
* the gesture for the port.
|
|
22
|
+
* A real reader unpin first writes the effective visual top (`engine - lag`)
|
|
23
|
+
* into `scrollTop` before clearing the transform, so the frame stays
|
|
24
|
+
* continuous. Lifecycle completion is different: without a reader gesture,
|
|
25
|
+
* the last owner settles at the floor and preserves bottom-follow.
|
|
28
26
|
*
|
|
29
27
|
* Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
|
|
30
28
|
* floor; a `scrollTop` delta from our own write must not release the pin.
|
|
@@ -136,35 +134,54 @@ function hasShiftSurface(port: HTMLElement): boolean {
|
|
|
136
134
|
return shiftRootOf(port) !== null || shiftRowsOf(port).length > 0
|
|
137
135
|
}
|
|
138
136
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
/** Cached natural gap from a shifted surface's bottom to the status line. */
|
|
138
|
+
const statusGapCache = new WeakMap<HTMLElement, { chrome: HTMLElement; px: number }>()
|
|
139
|
+
|
|
140
|
+
function currentShiftOf(element: HTMLElement): number {
|
|
141
|
+
return Number(/translate3d\(0, ([\d.]+)px, 0\)/.exec(element.style.transform)?.[1] ?? 0)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function statusGapBelow(surface: HTMLElement, chrome: HTMLElement): number {
|
|
145
|
+
const cached = statusGapCache.get(surface)
|
|
146
|
+
if (cached?.chrome === chrome) return cached.px
|
|
147
|
+
// getBoundingClientRect includes our current transform. Remove it to recover
|
|
148
|
+
// the layout bottom, then keep the existing gap available to the reveal.
|
|
149
|
+
const naturalBottom = surface.getBoundingClientRect().bottom - currentShiftOf(surface)
|
|
150
|
+
const px = Math.max(0, chrome.getBoundingClientRect().top - naturalBottom)
|
|
151
|
+
statusGapCache.set(surface, { chrome, px })
|
|
152
|
+
return px
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setShift(element: HTMLElement, px: number, paintAllowance?: number): void {
|
|
156
|
+
if (Math.abs(px) > 0.01) {
|
|
157
|
+
element.style.transform = `translate3d(0, ${px}px, 0)`
|
|
143
158
|
element.style.willChange = 'transform'
|
|
159
|
+
const bottomClip = paintAllowance === undefined ? 0 : Math.max(0, px - paintAllowance)
|
|
160
|
+
element.style.clipPath = bottomClip > 0.01 ? `inset(0 0 ${bottomClip}px 0)` : ''
|
|
144
161
|
} else {
|
|
145
162
|
element.style.transform = ''
|
|
146
163
|
element.style.willChange = ''
|
|
164
|
+
element.style.clipPath = ''
|
|
147
165
|
}
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
/**
|
|
151
|
-
* The running-turn label.
|
|
152
|
-
*
|
|
153
|
-
* expressed as a negative translate on this sibling: the label descends
|
|
154
|
-
* smoothly with each wrap instead of hopping down a line at a time.
|
|
169
|
+
* The running-turn label. It stays in normal flow so the host column's gap
|
|
170
|
+
* always separates it from the transcript.
|
|
155
171
|
*/
|
|
156
|
-
function
|
|
157
|
-
return port.querySelector<HTMLElement>(
|
|
172
|
+
function statusChromeOf(port: HTMLElement): HTMLElement | null {
|
|
173
|
+
return port.querySelector<HTMLElement>(
|
|
174
|
+
'[data-chat-turn-status], [data-chat-flow] > [role="status"]',
|
|
175
|
+
)
|
|
158
176
|
}
|
|
159
177
|
|
|
160
178
|
/**
|
|
161
|
-
* Render the single smoothed extent `animatedH`.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* shift surface the engine itself carries the interpolated top.
|
|
179
|
+
* Render the single smoothed extent `animatedH`. Once the port has scroll
|
|
180
|
+
* room the engine is pinned at the floor and the lag rides
|
|
181
|
+
* `[data-chat-transcript]` (or each message row when the host has no
|
|
182
|
+
* transcript box). Before that, message rows and status chrome remain in
|
|
183
|
+
* normal flow. Without any shift surface the engine carries the interpolated
|
|
184
|
+
* top itself.
|
|
168
185
|
*/
|
|
169
186
|
function applyVisual(port: HTMLElement, animatedH: number): void {
|
|
170
187
|
const contentHeight = Math.max(0, port.scrollHeight)
|
|
@@ -173,19 +190,19 @@ function applyVisual(port: HTMLElement, animatedH: number): void {
|
|
|
173
190
|
const lag = contentHeight - extent
|
|
174
191
|
port.style.overflowAnchor = 'none'
|
|
175
192
|
port.style.scrollBehavior = 'auto'
|
|
193
|
+
const chrome = statusChromeOf(port)
|
|
194
|
+
if (chrome !== null) setShift(chrome, 0)
|
|
176
195
|
const shiftRoot = shiftRootOf(port)
|
|
177
196
|
if (shiftRoot !== null) {
|
|
178
197
|
if (floor <= 0) {
|
|
179
198
|
port.scrollTop = 0
|
|
180
199
|
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
181
|
-
const chrome = chromeShiftOf(port)
|
|
182
|
-
if (chrome !== null) setShift(chrome, lag, true)
|
|
183
200
|
setShift(shiftRoot, 0)
|
|
184
201
|
return
|
|
185
202
|
}
|
|
186
203
|
port.scrollTop = floor
|
|
187
204
|
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
188
|
-
setShift(shiftRoot, lag)
|
|
205
|
+
setShift(shiftRoot, lag, chrome === null ? undefined : statusGapBelow(shiftRoot, chrome))
|
|
189
206
|
return
|
|
190
207
|
}
|
|
191
208
|
const rows = shiftRowsOf(port)
|
|
@@ -193,14 +210,16 @@ function applyVisual(port: HTMLElement, animatedH: number): void {
|
|
|
193
210
|
if (floor <= 0) {
|
|
194
211
|
port.scrollTop = 0
|
|
195
212
|
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
196
|
-
const chrome = chromeShiftOf(port)
|
|
197
|
-
if (chrome !== null) setShift(chrome, lag, true)
|
|
198
213
|
for (const row of rows) setShift(row, 0)
|
|
199
214
|
return
|
|
200
215
|
}
|
|
201
216
|
port.scrollTop = floor
|
|
202
217
|
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
203
|
-
|
|
218
|
+
const last = rows.length - 1
|
|
219
|
+
for (const [index, row] of rows.entries()) {
|
|
220
|
+
const allowance = index === last && chrome !== null ? statusGapBelow(row, chrome) : undefined
|
|
221
|
+
setShift(row, lag, allowance)
|
|
222
|
+
}
|
|
204
223
|
return
|
|
205
224
|
}
|
|
206
225
|
port.scrollTop = Math.min(floor, Math.max(0, extent))
|
|
@@ -213,21 +232,32 @@ function clearVisual(port: HTMLElement): void {
|
|
|
213
232
|
port.style.scrollBehavior = ''
|
|
214
233
|
const shiftRoot = shiftRootOf(port)
|
|
215
234
|
if (shiftRoot !== null) {
|
|
235
|
+
statusGapCache.delete(shiftRoot)
|
|
216
236
|
shiftRoot.style.transform = ''
|
|
217
237
|
shiftRoot.style.willChange = ''
|
|
238
|
+
shiftRoot.style.clipPath = ''
|
|
218
239
|
} else {
|
|
219
240
|
for (const row of shiftRowsOf(port)) {
|
|
241
|
+
statusGapCache.delete(row)
|
|
220
242
|
row.style.transform = ''
|
|
221
243
|
row.style.willChange = ''
|
|
244
|
+
row.style.clipPath = ''
|
|
222
245
|
}
|
|
223
246
|
}
|
|
224
|
-
const chrome =
|
|
247
|
+
const chrome = statusChromeOf(port)
|
|
225
248
|
if (chrome !== null) {
|
|
226
249
|
chrome.style.transform = ''
|
|
227
250
|
chrome.style.willChange = ''
|
|
251
|
+
chrome.style.clipPath = ''
|
|
228
252
|
}
|
|
229
253
|
}
|
|
230
254
|
|
|
255
|
+
function settleAtFloor(port: HTMLElement): void {
|
|
256
|
+
const floor = Math.max(0, port.scrollHeight - port.clientHeight)
|
|
257
|
+
port.scrollTop = floor
|
|
258
|
+
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
259
|
+
}
|
|
260
|
+
|
|
231
261
|
/** Live follow hosts per conversation port so one unmount does not clear another. */
|
|
232
262
|
const followOwners = new WeakMap<HTMLElement, number>()
|
|
233
263
|
|
|
@@ -412,15 +442,15 @@ export function useConversationFollow(
|
|
|
412
442
|
const root = rootRef.current
|
|
413
443
|
const host = root?.closest<HTMLElement>('[data-conversation-scroll]') ?? port
|
|
414
444
|
if (host === null) return
|
|
415
|
-
if (following) {
|
|
416
|
-
// Same handback as an unpin: land on the effective visual top rather
|
|
417
|
-
// than the floor, so the owner change (stream close, unmount) does
|
|
418
|
-
// not snap the flow up by the remaining lag.
|
|
419
|
-
handBackVisual(host)
|
|
420
|
-
}
|
|
421
445
|
const remaining = holding === host ? releaseFollow(host) : (followOwners.get(host) ?? 0)
|
|
422
446
|
holding = null
|
|
423
|
-
if (remaining === 0)
|
|
447
|
+
if (remaining === 0) {
|
|
448
|
+
if (following) {
|
|
449
|
+
if (interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) handBackVisual(host)
|
|
450
|
+
else settleAtFloor(host)
|
|
451
|
+
}
|
|
452
|
+
clearVisual(host)
|
|
453
|
+
}
|
|
424
454
|
}
|
|
425
455
|
}, [active, rootRef, speedCpsRef])
|
|
426
456
|
}
|