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 last text block owns the
222
- * conversation scrollport's bottom-follow while the reply reveals
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
- <FollowHost active={running} speedCpsRef={speedCpsRef}>
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
- </FollowHost>
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
- * last text or reasoning block owns conversation-port follow so wraps
198
- * glide. The FPS guard holds offscreen
199
- * reveals when the frame rate is degraded. Settled text renders with the
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
- * - before the port has scroll room (content shorter than the viewport), the
18
- * same lag is a content-height lag written as a negative translate on
19
- * `[data-chat-turn-status]`, so the status label descends smoothly with
20
- * each wrap instead of hopping a line at a time.
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
- * Handing the port back to the reader (unpin, or the owner going inactive)
23
- * first writes the effective visual top (`engine - lag`) into `scrollTop`
24
- * before clearing the transform, so the frame stays continuous. That write
25
- * also lands in ChatView's observed-top ledger as reader input beyond
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
- function setShift(element: HTMLElement, px: number, negative = false): void {
140
- const value = negative ? -px : px
141
- if (Math.abs(value) > 0.01) {
142
- element.style.transform = `translate3d(0, ${value}px, 0)`
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. Before the port scrolls (content shorter than the
152
- * viewport) the content-height lag has no `scrollTop` room to ride, so it is
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 chromeShiftOf(port: HTMLElement): HTMLElement | null {
157
- return port.querySelector<HTMLElement>('[data-chat-turn-status]')
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`. Before the port scrolls
162
- * (content shorter than the viewport) `animatedH` is clamped to the content
163
- * height and the content-height lag rides a negative translate on the
164
- * turn-status label; once the port has scroll room the engine is pinned at
165
- * the floor and the lag rides `[data-chat-transcript]` (or each message row
166
- * when the host has no transcript box) so the chrome stays put. Without any
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
- for (const row of rows) setShift(row, lag)
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 = chromeShiftOf(port)
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) clearVisual(host)
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
  }