@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.
- package/LICENSE +21 -0
- package/README.en.md +123 -0
- package/README.md +123 -0
- package/conversation-ui-off.yml +3 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +3672 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +302 -0
- package/package.json +182 -0
- package/src/boot-config.ts +28 -0
- package/src/client/AnimatedDisclosure.tsx +89 -0
- package/src/client/ConversationCard.module.css +253 -0
- package/src/client/ConversationCard.tsx +122 -0
- package/src/client/DeliverablesCard.module.css +187 -0
- package/src/client/DeliverablesCard.tsx +142 -0
- package/src/client/FollowHost.tsx +25 -0
- package/src/client/ToolActivityGroup.tsx +341 -0
- package/src/client/TurnPreludeUserNodeView.tsx +258 -0
- package/src/client/TurnProcessMember.tsx +92 -0
- package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
- package/src/client/TypewriterAssistantNodeView.tsx +390 -0
- package/src/client/TypewriterToolNodeView.tsx +916 -0
- package/src/client/conversation-ui-card-controller.ts +162 -0
- package/src/client/conversation-ui-settings-api.ts +65 -0
- package/src/client/deliverables.ts +263 -0
- package/src/client/index.ts +242 -0
- package/src/client/locales.ts +74 -0
- package/src/client/teleprompterGlide.ts +590 -0
- package/src/client/turnElapsed.ts +27 -0
- package/src/client/turnProcessFold.ts +224 -0
- package/src/client/useConversationContent.ts +471 -0
- package/src/client/useFpsGuard.ts +84 -0
- package/src/config.ts +44 -0
- package/src/css-modules.d.ts +9 -0
- package/src/index.ts +2 -0
- package/src/package-meta.ts +39 -0
- package/src/plugin.ts +155 -0
- package/src/profile-installation.ts +121 -0
- package/src/settings-api.ts +33 -0
- 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
|
+
}
|