@jcy2387/dsh-conversation-ui 0.1.0-rc.12

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +123 -0
  4. package/conversation-ui-off.yml +3 -0
  5. package/cordis.patch.yml +10 -0
  6. package/lib/client.js +3672 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/index.js +302 -0
  9. package/package.json +182 -0
  10. package/src/boot-config.ts +28 -0
  11. package/src/client/AnimatedDisclosure.tsx +89 -0
  12. package/src/client/ConversationCard.module.css +253 -0
  13. package/src/client/ConversationCard.tsx +122 -0
  14. package/src/client/DeliverablesCard.module.css +187 -0
  15. package/src/client/DeliverablesCard.tsx +142 -0
  16. package/src/client/FollowHost.tsx +25 -0
  17. package/src/client/ToolActivityGroup.tsx +341 -0
  18. package/src/client/TurnPreludeUserNodeView.tsx +258 -0
  19. package/src/client/TurnProcessMember.tsx +92 -0
  20. package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
  21. package/src/client/TypewriterAssistantNodeView.tsx +390 -0
  22. package/src/client/TypewriterToolNodeView.tsx +916 -0
  23. package/src/client/conversation-ui-card-controller.ts +162 -0
  24. package/src/client/conversation-ui-settings-api.ts +65 -0
  25. package/src/client/deliverables.ts +263 -0
  26. package/src/client/index.ts +242 -0
  27. package/src/client/locales.ts +74 -0
  28. package/src/client/teleprompterGlide.ts +590 -0
  29. package/src/client/turnElapsed.ts +27 -0
  30. package/src/client/turnProcessFold.ts +224 -0
  31. package/src/client/useConversationContent.ts +471 -0
  32. package/src/client/useFpsGuard.ts +84 -0
  33. package/src/config.ts +44 -0
  34. package/src/css-modules.d.ts +9 -0
  35. package/src/index.ts +2 -0
  36. package/src/package-meta.ts +39 -0
  37. package/src/plugin.ts +155 -0
  38. package/src/profile-installation.ts +121 -0
  39. package/src/settings-api.ts +33 -0
  40. package/src/settings.ts +30 -0
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Conversation-port follow while an assistant reply streams.
3
+ *
4
+ * The demo's damped-spring lerp is driven from a float `animatedH` — the
5
+ * smoothed content height — never from the rounded `scrollTop` the browser
6
+ * reports back. A wrap raises the content height by a line; if the engine
7
+ * were allowed to snap `scrollTop` to the new floor, the previous line would
8
+ * hop up in one frame. This overlay:
9
+ *
10
+ * - exposes its current follow write through `data-follow-owned` for styling,
11
+ * diagnostics, and same-port coordination;
12
+ * - sets `overflow-anchor: none` so CSS scroll-anchoring does not snap;
13
+ * - restores `animatedH` in a ResizeObserver (before paint) so a layout
14
+ * pass cannot flash a snapped frame;
15
+ * - while the port has scroll room, writes the interpolated lag as
16
+ * `translate3d` on `[data-chat-transcript]` (message rows only), so the
17
+ * turn-status chrome stays pinned at the floor;
18
+ * - before the port has scroll room (content shorter than the viewport), the
19
+ * same lag is a content-height lag written as a negative translate on
20
+ * `[data-chat-turn-status]`, so the status label descends smoothly with
21
+ * each wrap instead of hopping a line at a time.
22
+ *
23
+ * A reader gesture that unpins the port first writes the effective visual top
24
+ * (`engine - lag`) into `scrollTop` before clearing the transform, so that
25
+ * frame stays continuous. Lifecycle teardown may retain the last same-geometry
26
+ * visual snapshot for a small-lag close, but never derives a new handback from
27
+ * stale `animatedH`: a changed geometry or near-top snapshot in a long Session
28
+ * settles at the physical floor. Port migration does not rewrite the old port.
29
+ * These guards prevent a newer `scrollHeight` from turning old lag into
30
+ * `scrollTop = 0` and manufacturing reader intent in ChatView's ledger.
31
+ *
32
+ * Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
33
+ * floor; a `scrollTop` delta from our own write must not release the pin.
34
+ */
35
+
36
+ import { useEffect, useRef, type RefObject } from 'react'
37
+
38
+ /**
39
+ * Plugin-local marker containing the last programmatic `scrollTop`. DSH rc.6
40
+ * and rc.7 do not consume this attribute; the actual write ownership is
41
+ * enforced by {@link followDrivers} and {@link followOwners} below.
42
+ */
43
+ const FOLLOW_OWNED_ATTR = 'data-follow-owned'
44
+
45
+ /** Demo `1 - exp(-dt / 18)` time constant, in ms. */
46
+ export const FOLLOW_LERP_DT_MS = 18
47
+
48
+ /** Floor / ceiling of the per-frame lerp fraction before the dt term. */
49
+ export const FOLLOW_LERP_MIN = 0.05
50
+ export const FOLLOW_LERP_MAX = 0.25
51
+
52
+ /** Lag (px) at which the lag term of the lerp saturates. */
53
+ export const FOLLOW_LERP_LAG_REF_PX = 160
54
+
55
+ /** Reveal cps at which the speed factor is 1. */
56
+ export const FOLLOW_SPEED_REF_CPS = 35
57
+
58
+ export const FOLLOW_SPEED_FACTOR_MIN = 0.7
59
+ export const FOLLOW_SPEED_FACTOR_MAX = 2.2
60
+
61
+ /** Reader-return / still-pinned boundary, matching ChatView + the demo. */
62
+ export const FOLLOW_SLACK_PX = 25
63
+
64
+ /**
65
+ * Upward wheel/touch distance that releases the pin. The engine `scrollTop`
66
+ * is held on the floor while following, so a small trackpad tick never
67
+ * appears as `reportedLag` and cannot be judged against {@link FOLLOW_SLACK_PX}.
68
+ */
69
+ export const FOLLOW_UNPIN_GESTURE_PX = 8
70
+
71
+ /** How long a gesture keeps `isUserInteracting` so the next scroll can unpin. */
72
+ export const FOLLOW_GESTURE_MS = 800
73
+
74
+ const GESTURE_EVENTS = ['wheel', 'touchmove', 'pointerdown', 'keydown'] as const
75
+
76
+ export interface FollowGlideInput {
77
+ /** How far the interpolated top trails the floor, in px. */
78
+ readonly lag: number
79
+ /** Observed reveal rate in chars/s; 0 uses {@link FOLLOW_SPEED_REF_CPS}. */
80
+ readonly speedEma: number
81
+ /** Optional minimum visual follow speed from plugin configuration. */
82
+ readonly minSpeedPxPerSec?: number | undefined
83
+ /** Optional maximum visual follow speed from plugin configuration. */
84
+ readonly maxSpeedPxPerSec?: number | undefined
85
+ }
86
+
87
+ export interface FollowGlideStep {
88
+ /** Pixels to advance `animatedH` this frame (fractional). */
89
+ readonly advancePx: number
90
+ /** Applied lerp fraction, for tests. */
91
+ readonly lerpStep: number
92
+ }
93
+
94
+ function clamp(value: number, min: number, max: number): number {
95
+ return Math.min(max, Math.max(min, value))
96
+ }
97
+
98
+ /**
99
+ * One spring-lerp frame from the silky markdown demo.
100
+ * @param dtMs - Frame delta in ms.
101
+ * @param input - Current lag (from the float top, not the rounded engine top) and reveal-speed EMA.
102
+ * @returns The fractional advance and the lerp fraction.
103
+ */
104
+ export function computeFollowStep(dtMs: number, input: FollowGlideInput): FollowGlideStep {
105
+ if (input.lag <= 0.1 || dtMs <= 0) return { advancePx: 0, lerpStep: 0 }
106
+ const speed = input.speedEma > 0 ? input.speedEma : FOLLOW_SPEED_REF_CPS
107
+ const speedFactor = clamp(speed / FOLLOW_SPEED_REF_CPS, FOLLOW_SPEED_FACTOR_MIN, FOLLOW_SPEED_FACTOR_MAX)
108
+ const baseLerp = clamp((input.lag / FOLLOW_LERP_LAG_REF_PX) * speedFactor, FOLLOW_LERP_MIN, FOLLOW_LERP_MAX)
109
+ const lerpStep = baseLerp * (1 - Math.exp(-dtMs / FOLLOW_LERP_DT_MS))
110
+ const desired = input.lag * lerpStep
111
+ const minAdvance = Math.max(0, input.minSpeedPxPerSec ?? 0) * dtMs / 1000
112
+ const maxAdvance = Math.max(0, input.maxSpeedPxPerSec ?? Number.POSITIVE_INFINITY) * dtMs / 1000
113
+ const upper = Math.min(input.lag, maxAdvance)
114
+ const lower = Math.min(upper, minAdvance)
115
+ return { advancePx: clamp(desired, lower, upper), lerpStep }
116
+ }
117
+
118
+ /** The message-rows box the lag transform rides on, when the host has one. */
119
+ function shiftRootOf(port: HTMLElement): HTMLElement | null {
120
+ return port.querySelector('[data-chat-transcript]')
121
+ }
122
+
123
+ /**
124
+ * Row wrappers that carry only messages/tools. Hosts without a transcript
125
+ * box keep the turn-status chrome as a sibling of the rows inside
126
+ * `[data-chat-flow]`, so shifting that whole flow would drag the chrome
127
+ * along; shifting the rows individually leaves every non-message sibling
128
+ * (turn status, steering bubbles) pinned while the text glides.
129
+ *
130
+ * Only the outermost rows are shifted: a tool call nests its subcalls as
131
+ * descendant `[data-chat-anchor-key]` rows, and writing the lag on each of
132
+ * them would double (or further multiply) the shift, tearing the subcalls
133
+ * away from their parent every frame. Descendants ride the parent's
134
+ * transform instead.
135
+ */
136
+ function shiftRowsOf(port: HTMLElement): HTMLElement[] {
137
+ return [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
138
+ .filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
139
+ }
140
+
141
+ /** Element whose resize signals flow growth for the before-paint restore. */
142
+ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
143
+ return port.querySelector('[data-chat-transcript]') ?? port.querySelector('[data-chat-flow]')
144
+ }
145
+
146
+ /** True when the glide expresses its lag as transforms instead of engine top. */
147
+ function hasShiftSurface(port: HTMLElement): boolean {
148
+ return shiftRootOf(port) !== null || shiftRowsOf(port).length > 0
149
+ }
150
+
151
+ function setShift(element: HTMLElement, px: number, negative = false): void {
152
+ const value = negative ? -px : px
153
+ if (Math.abs(value) > 0.01) {
154
+ element.style.transform = `translate3d(0, ${value}px, 0)`
155
+ element.style.willChange = 'transform'
156
+ } else {
157
+ element.style.transform = ''
158
+ element.style.willChange = ''
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The running-turn label. Before the port scrolls (content shorter than the
164
+ * viewport) the content-height lag has no `scrollTop` room to ride, so it is
165
+ * expressed as a negative translate on this sibling: the label descends
166
+ * smoothly with each wrap instead of hopping down a line at a time.
167
+ */
168
+ function chromeShiftOf(port: HTMLElement): HTMLElement | null {
169
+ return port.querySelector<HTMLElement>('[data-chat-turn-status]')
170
+ }
171
+
172
+ /**
173
+ * Render the single smoothed extent `animatedH`. Before the port scrolls
174
+ * (content shorter than the viewport) `animatedH` is clamped to the content
175
+ * height and the content-height lag rides a negative translate on the
176
+ * turn-status label; once the port has scroll room the engine is pinned at
177
+ * the floor and the lag rides `[data-chat-transcript]` (or each message row
178
+ * when the host has no transcript box) so the chrome stays put. Without any
179
+ * shift surface the engine itself carries the interpolated top. Returns the
180
+ * effective visual top produced by the same geometry write.
181
+ */
182
+ function applyVisual(port: HTMLElement, animatedH: number): number {
183
+ const contentHeight = Math.max(0, port.scrollHeight)
184
+ const floor = Math.max(0, contentHeight - port.clientHeight)
185
+ const extent = Math.min(contentHeight, Math.max(0, animatedH))
186
+ const lag = contentHeight - extent
187
+ port.style.overflowAnchor = 'none'
188
+ port.style.scrollBehavior = 'auto'
189
+ const shiftRoot = shiftRootOf(port)
190
+ if (shiftRoot !== null) {
191
+ if (floor <= 0) {
192
+ port.scrollTop = 0
193
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
194
+ const chrome = chromeShiftOf(port)
195
+ if (chrome !== null) setShift(chrome, lag, true)
196
+ setShift(shiftRoot, 0)
197
+ return port.scrollTop
198
+ }
199
+ port.scrollTop = floor
200
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
201
+ setShift(shiftRoot, lag)
202
+ return Math.min(floor, Math.max(0, port.scrollTop - lag))
203
+ }
204
+ const rows = shiftRowsOf(port)
205
+ if (rows.length > 0) {
206
+ if (floor <= 0) {
207
+ port.scrollTop = 0
208
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
209
+ const chrome = chromeShiftOf(port)
210
+ if (chrome !== null) setShift(chrome, lag, true)
211
+ for (const row of rows) setShift(row, 0)
212
+ return port.scrollTop
213
+ }
214
+ port.scrollTop = floor
215
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
216
+ for (const row of rows) setShift(row, lag)
217
+ return Math.min(floor, Math.max(0, port.scrollTop - lag))
218
+ }
219
+ port.scrollTop = Math.min(floor, Math.max(0, extent))
220
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
221
+ return port.scrollTop
222
+ }
223
+
224
+ function clearVisual(port: HTMLElement): void {
225
+ port.removeAttribute(FOLLOW_OWNED_ATTR)
226
+ port.style.overflowAnchor = ''
227
+ port.style.scrollBehavior = ''
228
+ const shiftRoot = shiftRootOf(port)
229
+ if (shiftRoot !== null) {
230
+ shiftRoot.style.transform = ''
231
+ shiftRoot.style.willChange = ''
232
+ } else {
233
+ for (const row of shiftRowsOf(port)) {
234
+ row.style.transform = ''
235
+ row.style.willChange = ''
236
+ }
237
+ }
238
+ const chrome = chromeShiftOf(port)
239
+ if (chrome !== null) {
240
+ chrome.style.transform = ''
241
+ chrome.style.willChange = ''
242
+ }
243
+ }
244
+
245
+ interface FollowVisualSnapshot {
246
+ readonly scrollHeight: number
247
+ readonly clientHeight: number
248
+ readonly top: number
249
+ }
250
+
251
+ /** Active visual holders per port, normally zero or the elected driver. */
252
+ const followOwners = new WeakMap<HTMLElement, number>()
253
+
254
+ /** Bound active FollowHosts, including loops that did not win the driver token. */
255
+ const followParticipants = new WeakMap<HTMLElement, number>()
256
+
257
+ /** Safe final snapshot retained while a driver token is changing hands. */
258
+ const pendingHandoffs = new WeakMap<HTMLElement, FollowVisualSnapshot>()
259
+
260
+ /**
261
+ * Only one animation loop may write a conversation port at a time. Multiple
262
+ * active FollowHosts can exist during a streamed turn (for example, the
263
+ * assistant body and a tool row); independent loops would fight over
264
+ * scrollTop and transforms and make the transcript jump.
265
+ */
266
+ const followDrivers = new WeakMap<HTMLElement, symbol>()
267
+
268
+ function acquireFollow(port: HTMLElement): void {
269
+ followOwners.set(port, (followOwners.get(port) ?? 0) + 1)
270
+ }
271
+
272
+ function releaseFollow(port: HTMLElement): number {
273
+ const next = (followOwners.get(port) ?? 1) - 1
274
+ if (next <= 0) {
275
+ followOwners.delete(port)
276
+ return 0
277
+ }
278
+ followOwners.set(port, next)
279
+ return next
280
+ }
281
+
282
+ function acquireParticipant(port: HTMLElement): void {
283
+ followParticipants.set(port, (followParticipants.get(port) ?? 0) + 1)
284
+ }
285
+
286
+ function releaseParticipant(port: HTMLElement): number {
287
+ const next = (followParticipants.get(port) ?? 1) - 1
288
+ if (next <= 0) {
289
+ followParticipants.delete(port)
290
+ return 0
291
+ }
292
+ followParticipants.set(port, next)
293
+ return next
294
+ }
295
+
296
+ /**
297
+ * Own the conversation scrollport's bottom-follow while `active` is true.
298
+ *
299
+ * @param rootRef - An element inside the conversation scrollport.
300
+ * @param active - True while the reply is still revealing.
301
+ * @param speedCpsRef - Live reveal-rate EMA from the smoother.
302
+ */
303
+ export function useConversationFollow(
304
+ rootRef: RefObject<HTMLElement | null>,
305
+ active: boolean,
306
+ speedCpsRef: { current: number },
307
+ minSpeedPxPerSec?: number,
308
+ maxSpeedPxPerSec?: number,
309
+ ): void {
310
+ const activeRef = useRef(active)
311
+ useEffect(() => {
312
+ activeRef.current = active
313
+ }, [active])
314
+
315
+ useEffect(() => {
316
+ if (!active) return
317
+ const driverToken = Symbol('conversation-follow')
318
+ let rafId = 0
319
+ let last = performance.now()
320
+ let following = true
321
+ let primed = false
322
+ let animatedH = 0
323
+ let interacting = false
324
+ let interactTimer: ReturnType<typeof setTimeout> | null = null
325
+ let port: HTMLElement | null = null
326
+ let resize: ResizeObserver | null = null
327
+ let holding: HTMLElement | null = null
328
+ let awayPx = 0
329
+ let readerIntent = false
330
+ let lastVisual: FollowVisualSnapshot | null = null
331
+
332
+ const hold = (next: HTMLElement): void => {
333
+ if (holding === next) return
334
+ if (holding !== null) {
335
+ const previous = holding
336
+ const remaining = releaseFollow(previous)
337
+ if (remaining === 0) clearVisual(previous)
338
+ }
339
+ acquireFollow(next)
340
+ holding = next
341
+ }
342
+
343
+ const drop = (next: HTMLElement): void => {
344
+ if (holding !== next) return
345
+ const remaining = releaseFollow(next)
346
+ holding = null
347
+ if (remaining === 0) clearVisual(next)
348
+ }
349
+
350
+ /**
351
+ * Give the reader the visual position the glide was showing before the
352
+ * transforms go away. While transforms carry the lag, the engine sits at
353
+ * the floor and the effective visual top is `engine - lag`; writing that
354
+ * keeps the handover frame-continuous, and the write shows up in
355
+ * ChatView's ledger as reader movement, disarming its snap-follow.
356
+ * Without a shift surface the engine already holds the interpolated top,
357
+ * so there is nothing to compensate.
358
+ */
359
+ const handBackVisual = (next: HTMLElement): void => {
360
+ if (!hasShiftSurface(next)) return
361
+ const floor = Math.max(0, next.scrollHeight - next.clientHeight)
362
+ if (floor <= 0) return
363
+ const lag = Math.max(0, next.scrollHeight - animatedH)
364
+ next.scrollTop = Math.min(floor, Math.max(0, next.scrollTop - lag))
365
+ }
366
+
367
+ const applyCurrentVisual = (next: HTMLElement): void => {
368
+ const top = applyVisual(next, animatedH)
369
+ lastVisual = {
370
+ scrollHeight: next.scrollHeight,
371
+ clientHeight: next.clientHeight,
372
+ top,
373
+ }
374
+ }
375
+
376
+ const markGesture = (event: Event): void => {
377
+ interacting = true
378
+ if (event instanceof WheelEvent && event.deltaY < 0) {
379
+ readerIntent = true
380
+ awayPx += -event.deltaY
381
+ } else if (event.type === 'touchmove' || event.type === 'pointerdown' || event.type === 'keydown') {
382
+ // Record intent synchronously: the stream may settle before the next
383
+ // animation frame gets a chance to release follow.
384
+ readerIntent = true
385
+ awayPx += FOLLOW_UNPIN_GESTURE_PX
386
+ }
387
+ if (readerIntent && port !== null) pendingHandoffs.delete(port)
388
+ if (interactTimer !== null) clearTimeout(interactTimer)
389
+ interactTimer = setTimeout(() => {
390
+ interacting = false
391
+ interactTimer = null
392
+ awayPx = 0
393
+ }, FOLLOW_GESTURE_MS)
394
+ }
395
+
396
+ const restoreBeforePaint = (): void => {
397
+ if (
398
+ !primed
399
+ || !following
400
+ || port === null
401
+ || holding !== port
402
+ || followDrivers.get(port) !== driverToken
403
+ ) return
404
+ applyCurrentVisual(port)
405
+ }
406
+
407
+ const bindPort = (next: HTMLElement): void => {
408
+ if (port === next) return
409
+ if (port !== null) {
410
+ const previous = port
411
+ const drovePrevious = followDrivers.get(previous) === driverToken
412
+ for (const name of GESTURE_EVENTS) previous.removeEventListener(name, markGesture)
413
+ resize?.disconnect()
414
+ resize = null
415
+ if (holding === previous) {
416
+ const remaining = releaseFollow(previous)
417
+ holding = null
418
+ if (remaining === 0) clearVisual(previous)
419
+ }
420
+ releaseParticipant(previous)
421
+ if (drovePrevious) followDrivers.delete(previous)
422
+ primed = false
423
+ following = true
424
+ animatedH = 0
425
+ awayPx = 0
426
+ readerIntent = false
427
+ lastVisual = null
428
+ }
429
+ port = next
430
+ acquireParticipant(next)
431
+ for (const name of GESTURE_EVENTS) {
432
+ port.addEventListener(name, markGesture, { passive: true })
433
+ }
434
+ if (typeof ResizeObserver !== 'undefined') {
435
+ resize = new ResizeObserver(restoreBeforePaint)
436
+ resize.observe(port)
437
+ const proxy = resizeProxyOf(port)
438
+ if (proxy !== null) resize.observe(proxy)
439
+ }
440
+ }
441
+
442
+ const frame = (now: number) => {
443
+ rafId = requestAnimationFrame(frame)
444
+ const dt = Math.min(50, now - last)
445
+ last = now
446
+ const root = rootRef.current
447
+ if (root === null) return
448
+ const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
449
+ if (nextPort === null) return
450
+ bindPort(nextPort)
451
+ const owner = followDrivers.get(nextPort)
452
+ const inherited = owner === undefined ? pendingHandoffs.get(nextPort) ?? null : null
453
+ if (owner === undefined) followDrivers.set(nextPort, driverToken)
454
+ if (followDrivers.get(nextPort) !== driverToken) return
455
+
456
+ if (!primed && inherited !== null && !readerIntent) {
457
+ const geometryMatches = inherited.scrollHeight === nextPort.scrollHeight
458
+ && inherited.clientHeight === nextPort.clientHeight
459
+ if (geometryMatches) {
460
+ animatedH = hasShiftSurface(nextPort)
461
+ ? Math.min(nextPort.scrollHeight, nextPort.clientHeight + inherited.top)
462
+ : inherited.top
463
+ following = true
464
+ hold(nextPort)
465
+ applyCurrentVisual(nextPort)
466
+ primed = true
467
+ pendingHandoffs.delete(nextPort)
468
+ return
469
+ }
470
+ }
471
+ pendingHandoffs.delete(nextPort)
472
+ if (owner === undefined && (followOwners.get(nextPort) ?? 0) === 0) clearVisual(nextPort)
473
+
474
+ const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight)
475
+ const reportedLag = floor - nextPort.scrollTop
476
+ // Visual extent (content-height equivalent of the reader's scroll top):
477
+ // content shorter than the viewport has no scrollTop to derive from, so
478
+ // the extent is just the content height.
479
+ const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag))
480
+
481
+ if (!primed) {
482
+ animatedH = extent
483
+ following = reportedLag <= FOLLOW_SLACK_PX
484
+ if (following) {
485
+ hold(nextPort)
486
+ applyCurrentVisual(nextPort)
487
+ }
488
+ primed = true
489
+ return
490
+ }
491
+
492
+ if (!following && !interacting && reportedLag <= FOLLOW_SLACK_PX) {
493
+ following = true
494
+ readerIntent = false
495
+ animatedH = extent
496
+ hold(nextPort)
497
+ } else if (following && interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) {
498
+ following = false
499
+ awayPx = 0
500
+ // Compensate before the transform clears, or the flow (turn-status
501
+ // chrome included) jumps up by the whole lag in one frame.
502
+ handBackVisual(nextPort)
503
+ animatedH = nextPort.scrollHeight
504
+ drop(nextPort)
505
+ }
506
+
507
+ if (!activeRef.current || !following) return
508
+ hold(nextPort)
509
+
510
+ const lag = nextPort.scrollHeight - animatedH
511
+ const step = computeFollowStep(dt, {
512
+ lag,
513
+ speedEma: speedCpsRef.current,
514
+ minSpeedPxPerSec,
515
+ maxSpeedPxPerSec,
516
+ })
517
+ if (lag <= 0.1) {
518
+ animatedH = nextPort.scrollHeight
519
+ } else {
520
+ animatedH = Math.min(nextPort.scrollHeight, animatedH + step.advancePx)
521
+ }
522
+ applyCurrentVisual(nextPort)
523
+ }
524
+
525
+ rafId = requestAnimationFrame(frame)
526
+ return () => {
527
+ cancelAnimationFrame(rafId)
528
+ if (interactTimer !== null) clearTimeout(interactTimer)
529
+ resize?.disconnect()
530
+ const root = rootRef.current
531
+ const rootHost = root?.closest<HTMLElement>('[data-conversation-scroll]') ?? null
532
+ // Once a frame has bound a port, that exact port owns this effect's
533
+ // driver/visual state. A root moved into another Session must not make
534
+ // this cleanup release or rewrite the new Session's scrollport.
535
+ const host = port ?? rootHost
536
+ const droveHost = host !== null && followDrivers.get(host) === driverToken
537
+ let remainingParticipants = host === null ? 0 : (followParticipants.get(host) ?? 0)
538
+ if (port !== null) {
539
+ for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
540
+ remainingParticipants = releaseParticipant(port)
541
+ }
542
+ const ownSnapshot = host !== null
543
+ && rootHost === host
544
+ && droveHost
545
+ && primed
546
+ && following
547
+ && holding === host
548
+ && !readerIntent
549
+ ? lastVisual
550
+ : null
551
+ if (host !== null) {
552
+ if (readerIntent) pendingHandoffs.delete(host)
553
+ else if (ownSnapshot !== null && remainingParticipants > 0) pendingHandoffs.set(host, ownSnapshot)
554
+
555
+ if (remainingParticipants === 0) {
556
+ const snapshot = ownSnapshot ?? pendingHandoffs.get(host) ?? null
557
+ pendingHandoffs.delete(host)
558
+ if (rootHost === host && !readerIntent && snapshot !== null) {
559
+ const floor = Math.max(0, host.scrollHeight - host.clientHeight)
560
+ const snapshotValid = snapshot.scrollHeight === host.scrollHeight
561
+ && snapshot.clientHeight === host.clientHeight
562
+ const snapshotTop = snapshotValid
563
+ ? Math.min(floor, Math.max(0, snapshot.top))
564
+ : floor
565
+ // Keep normal small-lag close continuity, but never turn a large
566
+ // stale lag into a jump to the first viewport. Geometry changes
567
+ // and near-top snapshots settle at the physical floor instead.
568
+ const nearTopOfScrollablePort = floor > FOLLOW_SLACK_PX && snapshotTop <= FOLLOW_SLACK_PX
569
+ host.scrollTop = snapshotValid && !nearTopOfScrollablePort ? snapshotTop : floor
570
+ }
571
+ }
572
+ }
573
+ if (droveHost) followDrivers.delete(host)
574
+ if (holding !== null) {
575
+ const held = holding
576
+ const remaining = releaseFollow(held)
577
+ holding = null
578
+ const preserveForSuccessor = held === host
579
+ && remainingParticipants > 0
580
+ && pendingHandoffs.has(held)
581
+ if (remaining === 0 && !preserveForSuccessor) clearVisual(held)
582
+ }
583
+ if (
584
+ host !== null
585
+ && remainingParticipants === 0
586
+ && (followOwners.get(host) ?? 0) === 0
587
+ ) clearVisual(host)
588
+ }
589
+ }, [active, rootRef, speedCpsRef, minSpeedPxPerSec, maxSpeedPxPerSec])
590
+ }
@@ -0,0 +1,27 @@
1
+ import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
2
+
3
+ export type TurnElapsedTranslator = ChatNodeViewProps<'assistant-step'>['t']
4
+
5
+ export function formatRunDuration(ms: number, t: TurnElapsedTranslator): string {
6
+ const total = Math.max(0, Math.floor(ms / 1_000))
7
+ const minutes = Math.floor(total / 60)
8
+ const seconds = total % 60
9
+ return minutes > 0
10
+ ? t('duration.minutes', { minutes, seconds: String(seconds).padStart(2, '0') })
11
+ : t('duration.seconds', { seconds })
12
+ }
13
+
14
+ export function formatTurnElapsed(ms: number, t: TurnElapsedTranslator): string {
15
+ const duration = formatRunDuration(ms, t)
16
+ const label = t('message.ranFor', { duration })
17
+ // The requested Codex surface uses “耗时”; preserve every other locale's
18
+ // native conversation translation unchanged.
19
+ return label === `用时 ${duration}` ? `耗时 ${duration}` : label
20
+ }
21
+
22
+ export function formatTurnProcessed(ms: number, t: TurnElapsedTranslator): string {
23
+ const duration = formatRunDuration(ms, t)
24
+ const label = t('message.ranFor', { duration })
25
+ // Keep the native duration grammar and only replace the Chinese state word.
26
+ return label === `用时 ${duration}` ? `已处理 ${duration}` : label
27
+ }