dsh-smooth-stream 0.1.0
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.md +88 -0
- package/README.zh.md +88 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1288 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +73 -0
- package/package.json +135 -0
- package/src/boot-config.ts +28 -0
- package/src/client/AnimatedDisclosure.tsx +85 -0
- package/src/client/FollowHost.tsx +21 -0
- package/src/client/TypewriterAssistantNodeView.module.css +240 -0
- package/src/client/TypewriterAssistantNodeView.tsx +319 -0
- package/src/client/TypewriterToolNodeView.tsx +54 -0
- package/src/client/index.ts +122 -0
- package/src/client/teleprompterGlide.ts +426 -0
- package/src/client/useFpsGuard.ts +84 -0
- package/src/client/useSmoothStreamContent.ts +468 -0
- package/src/config.ts +50 -0
- package/src/css-modules.d.ts +9 -0
- package/src/index.ts +2 -0
- package/src/plugin.ts +54 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createElement, type ComponentType } from 'react'
|
|
2
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
3
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
4
|
+
import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
5
|
+
import { TypewriterAssistantNodeView } from './TypewriterAssistantNodeView.tsx'
|
|
6
|
+
import { wrapFollowNodeView, type FollowWrapProps } from './TypewriterToolNodeView.tsx'
|
|
7
|
+
import { DEFAULT_STREAM_CONFIG, STREAM_BOOT_GLOBAL, type StreamConfig } from '../config.ts'
|
|
8
|
+
|
|
9
|
+
/** Cordis services required by the browser half. */
|
|
10
|
+
export const inject = ['slots']
|
|
11
|
+
|
|
12
|
+
type AssistantProps = ChatNodeViewProps<'assistant-step'>
|
|
13
|
+
|
|
14
|
+
const STREAM_MODES: readonly string[] = ['typewriter', 'teleprompter']
|
|
15
|
+
const STREAM_PRESETS: readonly string[] = ['realtime', 'balanced', 'silky']
|
|
16
|
+
|
|
17
|
+
/** Human-authored rows stay on the built-in renderer; `assistant-step` is replaced. */
|
|
18
|
+
const SKIP_WRAP = new Set(['assistant-step', 'user', 'steering', 'command-input'])
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the Host-bridged boot config. The inline script is produced by this
|
|
22
|
+
* plugin's Host half from a schema-validated value, so only the structural
|
|
23
|
+
* guarantees that could break between the two halves are re-checked: the
|
|
24
|
+
* global is absent when the client runs without its Host entry (defaults
|
|
25
|
+
* apply), and any present-but-malformed value fails loudly instead of
|
|
26
|
+
* rendering a half-configured view.
|
|
27
|
+
* @returns The resolved configuration for the assistant node view.
|
|
28
|
+
*/
|
|
29
|
+
function readBootConfig(): StreamConfig {
|
|
30
|
+
const raw = (globalThis as Record<string, unknown>)[STREAM_BOOT_GLOBAL]
|
|
31
|
+
if (raw === undefined) {
|
|
32
|
+
console.info('[dsh-smooth-stream] no host config bridge; using defaults')
|
|
33
|
+
return DEFAULT_STREAM_CONFIG
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
typeof raw !== 'object' || raw === null
|
|
37
|
+
|| !STREAM_MODES.includes((raw as StreamConfig).mode)
|
|
38
|
+
|| !STREAM_PRESETS.includes((raw as StreamConfig).preset)
|
|
39
|
+
|| typeof (raw as StreamConfig).revealCharsPerSec !== 'number'
|
|
40
|
+
|| typeof (raw as StreamConfig).scrollSpeedPxPerSec !== 'number'
|
|
41
|
+
|| typeof (raw as StreamConfig).maxScrollSpeedPxPerSec !== 'number'
|
|
42
|
+
) {
|
|
43
|
+
throw new Error(`[dsh-smooth-stream] malformed ${STREAM_BOOT_GLOBAL} boot global: ${JSON.stringify(raw)}`)
|
|
44
|
+
}
|
|
45
|
+
return raw as StreamConfig
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wrap every keyed Chat row except `assistant-step` in place. A second
|
|
50
|
+
* register with the same `children` table throws because the child slot is
|
|
51
|
+
* already declared, and only the winning entry receives `renderSlot`;
|
|
52
|
+
* swapping `entry.component` keeps the original children, locale, and inject
|
|
53
|
+
* seats. `assistant-step` is replaced below so text and Think use the
|
|
54
|
+
* typewriter reveal.
|
|
55
|
+
* @param ctx - Browser context carrying the slot registry.
|
|
56
|
+
* @returns Restorer that puts the original components back.
|
|
57
|
+
*/
|
|
58
|
+
function wrapGrowingChatRows(ctx: ClientContext): () => void {
|
|
59
|
+
const restores: Array<() => void> = []
|
|
60
|
+
const wrapped = new WeakSet<object>()
|
|
61
|
+
|
|
62
|
+
const wrapAll = (): void => {
|
|
63
|
+
for (const entry of ctx.slots.entries('conversation.chat.node')) {
|
|
64
|
+
const key = entry.options.key
|
|
65
|
+
if (key === undefined || SKIP_WRAP.has(key)) continue
|
|
66
|
+
const current = entry.component
|
|
67
|
+
if (typeof current !== 'function' || wrapped.has(current)) continue
|
|
68
|
+
const inner = current as ComponentType<FollowWrapProps>
|
|
69
|
+
const next = wrapFollowNodeView(inner)
|
|
70
|
+
wrapped.add(next)
|
|
71
|
+
entry.component = next
|
|
72
|
+
restores.push(() => {
|
|
73
|
+
if (entry.component === next) entry.component = inner
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
wrapAll()
|
|
79
|
+
const off = ctx.on('slots/changed', (key: string) => {
|
|
80
|
+
if (key === 'conversation.chat.node') wrapAll()
|
|
81
|
+
})
|
|
82
|
+
return () => {
|
|
83
|
+
off()
|
|
84
|
+
for (const restore of restores) restore()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register the typewriter renderer after the conversation package declares the
|
|
90
|
+
* keyed Chat node seat. A lower priority shadows the built-in assistant row;
|
|
91
|
+
* every other growing row is wrapped in place so Tool cards, retries, and
|
|
92
|
+
* workflow runs share conversation follow. The Host-bridged configuration
|
|
93
|
+
* selects the render direction, smoothing preset, and glide speed.
|
|
94
|
+
* @param ctx - Browser context carrying the shared slot registry.
|
|
95
|
+
*/
|
|
96
|
+
export function apply(ctx: ClientContext): void {
|
|
97
|
+
const config = readBootConfig()
|
|
98
|
+
const configured = function StreamConfiguredView(props: AssistantProps) {
|
|
99
|
+
return createElement(TypewriterAssistantNodeView, {
|
|
100
|
+
...props,
|
|
101
|
+
mode: config.mode,
|
|
102
|
+
preset: config.preset,
|
|
103
|
+
revealCharsPerSec: config.revealCharsPerSec,
|
|
104
|
+
scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
|
|
105
|
+
maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
ctx.slots.inject('conversation.chat.node', () => {
|
|
109
|
+
const unwrap = wrapGrowingChatRows(ctx)
|
|
110
|
+
const unshadow = ctx.slots.register({
|
|
111
|
+
name: 'conversation.chat.node',
|
|
112
|
+
key: 'assistant-step',
|
|
113
|
+
priority: -100,
|
|
114
|
+
locale: 'conversation',
|
|
115
|
+
registrant: 'dsh-smooth-stream',
|
|
116
|
+
}, configured)
|
|
117
|
+
return () => {
|
|
118
|
+
unshadow()
|
|
119
|
+
unwrap()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
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
|
+
* - owns follow via `data-follow-owned` so ChatView does not snap;
|
|
11
|
+
* - sets `overflow-anchor: none` so CSS scroll-anchoring does not snap;
|
|
12
|
+
* - restores `animatedH` in a ResizeObserver (before paint) so a layout
|
|
13
|
+
* pass cannot flash a snapped frame;
|
|
14
|
+
* - while the port has scroll room, writes the interpolated lag as
|
|
15
|
+
* `translate3d` on `[data-chat-transcript]` (message rows only), so the
|
|
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.
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
|
|
30
|
+
* floor; a `scrollTop` delta from our own write must not release the pin.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { useEffect, useRef, type RefObject } from 'react'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Matches ChatView's `FOLLOW_OWNED_ATTR`. The overlay writes the last
|
|
37
|
+
* programmatic `scrollTop` here so ChatView yields snap-follow and does not
|
|
38
|
+
* treat the write as reader input.
|
|
39
|
+
*/
|
|
40
|
+
const FOLLOW_OWNED_ATTR = 'data-follow-owned'
|
|
41
|
+
|
|
42
|
+
/** Demo `1 - exp(-dt / 18)` time constant, in ms. */
|
|
43
|
+
export const FOLLOW_LERP_DT_MS = 18
|
|
44
|
+
|
|
45
|
+
/** Floor / ceiling of the per-frame lerp fraction before the dt term. */
|
|
46
|
+
export const FOLLOW_LERP_MIN = 0.05
|
|
47
|
+
export const FOLLOW_LERP_MAX = 0.25
|
|
48
|
+
|
|
49
|
+
/** Lag (px) at which the lag term of the lerp saturates. */
|
|
50
|
+
export const FOLLOW_LERP_LAG_REF_PX = 160
|
|
51
|
+
|
|
52
|
+
/** Reveal cps at which the speed factor is 1. */
|
|
53
|
+
export const FOLLOW_SPEED_REF_CPS = 35
|
|
54
|
+
|
|
55
|
+
export const FOLLOW_SPEED_FACTOR_MIN = 0.7
|
|
56
|
+
export const FOLLOW_SPEED_FACTOR_MAX = 2.2
|
|
57
|
+
|
|
58
|
+
/** Reader-return / still-pinned boundary, matching ChatView + the demo. */
|
|
59
|
+
export const FOLLOW_SLACK_PX = 25
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Upward wheel/touch distance that releases the pin. The engine `scrollTop`
|
|
63
|
+
* is held on the floor while following, so a small trackpad tick never
|
|
64
|
+
* appears as `reportedLag` and cannot be judged against {@link FOLLOW_SLACK_PX}.
|
|
65
|
+
*/
|
|
66
|
+
export const FOLLOW_UNPIN_GESTURE_PX = 8
|
|
67
|
+
|
|
68
|
+
/** How long a gesture keeps `isUserInteracting` so the next scroll can unpin. */
|
|
69
|
+
export const FOLLOW_GESTURE_MS = 800
|
|
70
|
+
|
|
71
|
+
const GESTURE_EVENTS = ['wheel', 'touchmove', 'pointerdown', 'keydown'] as const
|
|
72
|
+
|
|
73
|
+
export interface FollowGlideInput {
|
|
74
|
+
/** How far the interpolated top trails the floor, in px. */
|
|
75
|
+
readonly lag: number
|
|
76
|
+
/** Observed reveal rate in chars/s; 0 uses {@link FOLLOW_SPEED_REF_CPS}. */
|
|
77
|
+
readonly speedEma: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface FollowGlideStep {
|
|
81
|
+
/** Pixels to advance `animatedH` this frame (fractional). */
|
|
82
|
+
readonly advancePx: number
|
|
83
|
+
/** Applied lerp fraction, for tests. */
|
|
84
|
+
readonly lerpStep: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clamp(value: number, min: number, max: number): number {
|
|
88
|
+
return Math.min(max, Math.max(min, value))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One spring-lerp frame from the silky markdown demo.
|
|
93
|
+
* @param dtMs - Frame delta in ms.
|
|
94
|
+
* @param input - Current lag (from the float top, not the rounded engine top) and reveal-speed EMA.
|
|
95
|
+
* @returns The fractional advance and the lerp fraction.
|
|
96
|
+
*/
|
|
97
|
+
export function computeFollowStep(dtMs: number, input: FollowGlideInput): FollowGlideStep {
|
|
98
|
+
if (input.lag <= 0.1 || dtMs <= 0) return { advancePx: 0, lerpStep: 0 }
|
|
99
|
+
const speed = input.speedEma > 0 ? input.speedEma : FOLLOW_SPEED_REF_CPS
|
|
100
|
+
const speedFactor = clamp(speed / FOLLOW_SPEED_REF_CPS, FOLLOW_SPEED_FACTOR_MIN, FOLLOW_SPEED_FACTOR_MAX)
|
|
101
|
+
const baseLerp = clamp((input.lag / FOLLOW_LERP_LAG_REF_PX) * speedFactor, FOLLOW_LERP_MIN, FOLLOW_LERP_MAX)
|
|
102
|
+
const lerpStep = baseLerp * (1 - Math.exp(-dtMs / FOLLOW_LERP_DT_MS))
|
|
103
|
+
return { advancePx: input.lag * lerpStep, lerpStep }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The message-rows box the lag transform rides on, when the host has one. */
|
|
107
|
+
function shiftRootOf(port: HTMLElement): HTMLElement | null {
|
|
108
|
+
return port.querySelector('[data-chat-transcript]')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Row wrappers that carry only messages/tools. Hosts without a transcript
|
|
113
|
+
* box keep the turn-status chrome as a sibling of the rows inside
|
|
114
|
+
* `[data-chat-flow]`, so shifting that whole flow would drag the chrome
|
|
115
|
+
* along; shifting the rows individually leaves every non-message sibling
|
|
116
|
+
* (turn status, steering bubbles) pinned while the text glides.
|
|
117
|
+
*
|
|
118
|
+
* Only the outermost rows are shifted: a tool call nests its subcalls as
|
|
119
|
+
* descendant `[data-chat-anchor-key]` rows, and writing the lag on each of
|
|
120
|
+
* them would double (or further multiply) the shift, tearing the subcalls
|
|
121
|
+
* away from their parent every frame. Descendants ride the parent's
|
|
122
|
+
* transform instead.
|
|
123
|
+
*/
|
|
124
|
+
function shiftRowsOf(port: HTMLElement): HTMLElement[] {
|
|
125
|
+
return [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
|
126
|
+
.filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Element whose resize signals flow growth for the before-paint restore. */
|
|
130
|
+
function resizeProxyOf(port: HTMLElement): HTMLElement | null {
|
|
131
|
+
return port.querySelector('[data-chat-transcript]') ?? port.querySelector('[data-chat-flow]')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when the glide expresses its lag as transforms instead of engine top. */
|
|
135
|
+
function hasShiftSurface(port: HTMLElement): boolean {
|
|
136
|
+
return shiftRootOf(port) !== null || shiftRowsOf(port).length > 0
|
|
137
|
+
}
|
|
138
|
+
|
|
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)`
|
|
143
|
+
element.style.willChange = 'transform'
|
|
144
|
+
} else {
|
|
145
|
+
element.style.transform = ''
|
|
146
|
+
element.style.willChange = ''
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
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.
|
|
155
|
+
*/
|
|
156
|
+
function chromeShiftOf(port: HTMLElement): HTMLElement | null {
|
|
157
|
+
return port.querySelector<HTMLElement>('[data-chat-turn-status]')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
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.
|
|
168
|
+
*/
|
|
169
|
+
function applyVisual(port: HTMLElement, animatedH: number): void {
|
|
170
|
+
const contentHeight = Math.max(0, port.scrollHeight)
|
|
171
|
+
const floor = Math.max(0, contentHeight - port.clientHeight)
|
|
172
|
+
const extent = Math.min(contentHeight, Math.max(0, animatedH))
|
|
173
|
+
const lag = contentHeight - extent
|
|
174
|
+
port.style.overflowAnchor = 'none'
|
|
175
|
+
port.style.scrollBehavior = 'auto'
|
|
176
|
+
const shiftRoot = shiftRootOf(port)
|
|
177
|
+
if (shiftRoot !== null) {
|
|
178
|
+
if (floor <= 0) {
|
|
179
|
+
port.scrollTop = 0
|
|
180
|
+
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
181
|
+
const chrome = chromeShiftOf(port)
|
|
182
|
+
if (chrome !== null) setShift(chrome, lag, true)
|
|
183
|
+
setShift(shiftRoot, 0)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
port.scrollTop = floor
|
|
187
|
+
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
188
|
+
setShift(shiftRoot, lag)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
const rows = shiftRowsOf(port)
|
|
192
|
+
if (rows.length > 0) {
|
|
193
|
+
if (floor <= 0) {
|
|
194
|
+
port.scrollTop = 0
|
|
195
|
+
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
196
|
+
const chrome = chromeShiftOf(port)
|
|
197
|
+
if (chrome !== null) setShift(chrome, lag, true)
|
|
198
|
+
for (const row of rows) setShift(row, 0)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
port.scrollTop = floor
|
|
202
|
+
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
203
|
+
for (const row of rows) setShift(row, lag)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
port.scrollTop = Math.min(floor, Math.max(0, extent))
|
|
207
|
+
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function clearVisual(port: HTMLElement): void {
|
|
211
|
+
port.removeAttribute(FOLLOW_OWNED_ATTR)
|
|
212
|
+
port.style.overflowAnchor = ''
|
|
213
|
+
port.style.scrollBehavior = ''
|
|
214
|
+
const shiftRoot = shiftRootOf(port)
|
|
215
|
+
if (shiftRoot !== null) {
|
|
216
|
+
shiftRoot.style.transform = ''
|
|
217
|
+
shiftRoot.style.willChange = ''
|
|
218
|
+
} else {
|
|
219
|
+
for (const row of shiftRowsOf(port)) {
|
|
220
|
+
row.style.transform = ''
|
|
221
|
+
row.style.willChange = ''
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const chrome = chromeShiftOf(port)
|
|
225
|
+
if (chrome !== null) {
|
|
226
|
+
chrome.style.transform = ''
|
|
227
|
+
chrome.style.willChange = ''
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Live follow hosts per conversation port so one unmount does not clear another. */
|
|
232
|
+
const followOwners = new WeakMap<HTMLElement, number>()
|
|
233
|
+
|
|
234
|
+
function acquireFollow(port: HTMLElement): void {
|
|
235
|
+
followOwners.set(port, (followOwners.get(port) ?? 0) + 1)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function releaseFollow(port: HTMLElement): number {
|
|
239
|
+
const next = (followOwners.get(port) ?? 1) - 1
|
|
240
|
+
if (next <= 0) {
|
|
241
|
+
followOwners.delete(port)
|
|
242
|
+
return 0
|
|
243
|
+
}
|
|
244
|
+
followOwners.set(port, next)
|
|
245
|
+
return next
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Own the conversation scrollport's bottom-follow while `active` is true.
|
|
250
|
+
*
|
|
251
|
+
* @param rootRef - An element inside the conversation scrollport.
|
|
252
|
+
* @param active - True while the reply is still revealing.
|
|
253
|
+
* @param speedCpsRef - Live reveal-rate EMA from the smoother.
|
|
254
|
+
*/
|
|
255
|
+
export function useConversationFollow(
|
|
256
|
+
rootRef: RefObject<HTMLElement | null>,
|
|
257
|
+
active: boolean,
|
|
258
|
+
speedCpsRef: { current: number },
|
|
259
|
+
): void {
|
|
260
|
+
const activeRef = useRef(active)
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
activeRef.current = active
|
|
263
|
+
}, [active])
|
|
264
|
+
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!active) return
|
|
267
|
+
let rafId = 0
|
|
268
|
+
let last = performance.now()
|
|
269
|
+
let following = true
|
|
270
|
+
let primed = false
|
|
271
|
+
let animatedH = 0
|
|
272
|
+
let interacting = false
|
|
273
|
+
let interactTimer: ReturnType<typeof setTimeout> | null = null
|
|
274
|
+
let port: HTMLElement | null = null
|
|
275
|
+
let resize: ResizeObserver | null = null
|
|
276
|
+
let holding: HTMLElement | null = null
|
|
277
|
+
let awayPx = 0
|
|
278
|
+
|
|
279
|
+
const hold = (next: HTMLElement): void => {
|
|
280
|
+
if (holding === next) return
|
|
281
|
+
if (holding !== null) releaseFollow(holding)
|
|
282
|
+
acquireFollow(next)
|
|
283
|
+
holding = next
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const drop = (next: HTMLElement): void => {
|
|
287
|
+
if (holding === next) {
|
|
288
|
+
releaseFollow(next)
|
|
289
|
+
holding = null
|
|
290
|
+
}
|
|
291
|
+
clearVisual(next)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Give the reader the visual position the glide was showing before the
|
|
296
|
+
* transforms go away. While transforms carry the lag, the engine sits at
|
|
297
|
+
* the floor and the effective visual top is `engine - lag`; writing that
|
|
298
|
+
* keeps the handover frame-continuous, and the write shows up in
|
|
299
|
+
* ChatView's ledger as reader movement, disarming its snap-follow.
|
|
300
|
+
* Without a shift surface the engine already holds the interpolated top,
|
|
301
|
+
* so there is nothing to compensate.
|
|
302
|
+
*/
|
|
303
|
+
const handBackVisual = (next: HTMLElement): void => {
|
|
304
|
+
if (!hasShiftSurface(next)) return
|
|
305
|
+
const floor = Math.max(0, next.scrollHeight - next.clientHeight)
|
|
306
|
+
if (floor <= 0) return
|
|
307
|
+
const lag = Math.max(0, next.scrollHeight - animatedH)
|
|
308
|
+
next.scrollTop = Math.min(floor, Math.max(0, next.scrollTop - lag))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const markGesture = (event: Event): void => {
|
|
312
|
+
interacting = true
|
|
313
|
+
if (event instanceof WheelEvent && event.deltaY < 0) awayPx += -event.deltaY
|
|
314
|
+
else if (event.type === 'touchmove' || event.type === 'keydown') awayPx += FOLLOW_UNPIN_GESTURE_PX
|
|
315
|
+
if (interactTimer !== null) clearTimeout(interactTimer)
|
|
316
|
+
interactTimer = setTimeout(() => {
|
|
317
|
+
interacting = false
|
|
318
|
+
interactTimer = null
|
|
319
|
+
awayPx = 0
|
|
320
|
+
}, FOLLOW_GESTURE_MS)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const restoreBeforePaint = (): void => {
|
|
324
|
+
if (!following || port === null) return
|
|
325
|
+
applyVisual(port, animatedH)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const bindPort = (next: HTMLElement): void => {
|
|
329
|
+
if (port === next) return
|
|
330
|
+
if (port !== null) {
|
|
331
|
+
for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
|
|
332
|
+
resize?.disconnect()
|
|
333
|
+
}
|
|
334
|
+
port = next
|
|
335
|
+
for (const name of GESTURE_EVENTS) {
|
|
336
|
+
port.addEventListener(name, markGesture, { passive: true })
|
|
337
|
+
}
|
|
338
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
339
|
+
resize = new ResizeObserver(restoreBeforePaint)
|
|
340
|
+
resize.observe(port)
|
|
341
|
+
const proxy = resizeProxyOf(port)
|
|
342
|
+
if (proxy !== null) resize.observe(proxy)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const frame = (now: number) => {
|
|
347
|
+
rafId = requestAnimationFrame(frame)
|
|
348
|
+
const dt = Math.min(50, now - last)
|
|
349
|
+
last = now
|
|
350
|
+
const root = rootRef.current
|
|
351
|
+
if (root === null) return
|
|
352
|
+
const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
|
|
353
|
+
if (nextPort === null) return
|
|
354
|
+
bindPort(nextPort)
|
|
355
|
+
|
|
356
|
+
const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight)
|
|
357
|
+
const reportedLag = floor - nextPort.scrollTop
|
|
358
|
+
// Visual extent (content-height equivalent of the reader's scroll top):
|
|
359
|
+
// content shorter than the viewport has no scrollTop to derive from, so
|
|
360
|
+
// the extent is just the content height.
|
|
361
|
+
const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag))
|
|
362
|
+
|
|
363
|
+
if (!primed) {
|
|
364
|
+
animatedH = extent
|
|
365
|
+
following = reportedLag <= FOLLOW_SLACK_PX
|
|
366
|
+
if (following) {
|
|
367
|
+
hold(nextPort)
|
|
368
|
+
applyVisual(nextPort, animatedH)
|
|
369
|
+
}
|
|
370
|
+
primed = true
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!following && !interacting && reportedLag <= FOLLOW_SLACK_PX) {
|
|
375
|
+
following = true
|
|
376
|
+
animatedH = extent
|
|
377
|
+
hold(nextPort)
|
|
378
|
+
} else if (following && interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) {
|
|
379
|
+
following = false
|
|
380
|
+
awayPx = 0
|
|
381
|
+
// Compensate before the transform clears, or the flow (turn-status
|
|
382
|
+
// chrome included) jumps up by the whole lag in one frame.
|
|
383
|
+
handBackVisual(nextPort)
|
|
384
|
+
animatedH = nextPort.scrollHeight
|
|
385
|
+
drop(nextPort)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!activeRef.current || !following) return
|
|
389
|
+
hold(nextPort)
|
|
390
|
+
|
|
391
|
+
const lag = nextPort.scrollHeight - animatedH
|
|
392
|
+
const step = computeFollowStep(dt, {
|
|
393
|
+
lag,
|
|
394
|
+
speedEma: speedCpsRef.current,
|
|
395
|
+
})
|
|
396
|
+
if (lag <= 0.1) {
|
|
397
|
+
animatedH = nextPort.scrollHeight
|
|
398
|
+
} else {
|
|
399
|
+
animatedH = Math.min(nextPort.scrollHeight, animatedH + step.advancePx)
|
|
400
|
+
}
|
|
401
|
+
applyVisual(nextPort, animatedH)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
rafId = requestAnimationFrame(frame)
|
|
405
|
+
return () => {
|
|
406
|
+
cancelAnimationFrame(rafId)
|
|
407
|
+
if (interactTimer !== null) clearTimeout(interactTimer)
|
|
408
|
+
resize?.disconnect()
|
|
409
|
+
if (port !== null) {
|
|
410
|
+
for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
|
|
411
|
+
}
|
|
412
|
+
const root = rootRef.current
|
|
413
|
+
const host = root?.closest<HTMLElement>('[data-conversation-scroll]') ?? port
|
|
414
|
+
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
|
+
const remaining = holding === host ? releaseFollow(host) : (followOwners.get(host) ?? 0)
|
|
422
|
+
holding = null
|
|
423
|
+
if (remaining === 0) clearVisual(host)
|
|
424
|
+
}
|
|
425
|
+
}, [active, rootRef, speedCpsRef])
|
|
426
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance guard for the streaming reveal.
|
|
3
|
+
*
|
|
4
|
+
* Feeds an EMA-smoothed frame-rate monitor from a rAF loop while streaming
|
|
5
|
+
* and tracks whether the reply is on-screen. The returned `shouldHoldBack`
|
|
6
|
+
* predicate is true only while the frame rate is below the threshold AND the
|
|
7
|
+
* reply is offscreen — exactly the spec's "skip offscreen DOM updates when
|
|
8
|
+
* FPS < 30" rule. The smoother consumes the predicate as its commit veto.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
12
|
+
|
|
13
|
+
const FPS_THRESHOLD = 30
|
|
14
|
+
const FPS_ALPHA = 0.12
|
|
15
|
+
const RECOVER_FRAMES = 6
|
|
16
|
+
const MAX_FRAME_MS = 100
|
|
17
|
+
|
|
18
|
+
interface MutableFps {
|
|
19
|
+
emaMs: number
|
|
20
|
+
lastMs: number
|
|
21
|
+
healthyRun: number
|
|
22
|
+
degraded: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useFpsGuard(active: boolean): {
|
|
26
|
+
ref: (element: HTMLElement | null) => void
|
|
27
|
+
shouldHoldBack: () => boolean
|
|
28
|
+
} {
|
|
29
|
+
const fpsRef = useRef<MutableFps>({ emaMs: 0, lastMs: 0, healthyRun: 0, degraded: false })
|
|
30
|
+
const visibleRef = useRef(true)
|
|
31
|
+
const elementRef = useRef<HTMLElement | null>(null)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!active) return
|
|
35
|
+
let rafId = 0
|
|
36
|
+
const frame = (now: number) => {
|
|
37
|
+
rafId = requestAnimationFrame(frame)
|
|
38
|
+
const fps = fpsRef.current
|
|
39
|
+
if (fps.lastMs === 0) {
|
|
40
|
+
fps.lastMs = now
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const delta = Math.min(MAX_FRAME_MS, Math.max(1, now - fps.lastMs))
|
|
44
|
+
fps.lastMs = now
|
|
45
|
+
fps.emaMs = fps.emaMs === 0 ? delta : fps.emaMs + FPS_ALPHA * (delta - fps.emaMs)
|
|
46
|
+
const currentFps = 1000 / fps.emaMs
|
|
47
|
+
if (currentFps < FPS_THRESHOLD) {
|
|
48
|
+
fps.healthyRun = 0
|
|
49
|
+
fps.degraded = true
|
|
50
|
+
} else if (fps.degraded) {
|
|
51
|
+
fps.healthyRun += 1
|
|
52
|
+
if (fps.healthyRun >= RECOVER_FRAMES) fps.degraded = false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
rafId = requestAnimationFrame(frame)
|
|
56
|
+
return () => {
|
|
57
|
+
cancelAnimationFrame(rafId)
|
|
58
|
+
fpsRef.current = { emaMs: 0, lastMs: 0, healthyRun: 0, degraded: false }
|
|
59
|
+
}
|
|
60
|
+
}, [active])
|
|
61
|
+
|
|
62
|
+
const ref = useCallback((element: HTMLElement | null) => {
|
|
63
|
+
elementRef.current = element
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const element = elementRef.current
|
|
68
|
+
if (element === null || typeof IntersectionObserver === 'undefined') return
|
|
69
|
+
const observer = new IntersectionObserver(
|
|
70
|
+
(entries) => {
|
|
71
|
+
for (const entry of entries) visibleRef.current = entry.isIntersecting
|
|
72
|
+
},
|
|
73
|
+
{ rootMargin: '120px 0px' },
|
|
74
|
+
)
|
|
75
|
+
observer.observe(element)
|
|
76
|
+
return () => observer.disconnect()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const shouldHoldBack = useCallback(() => {
|
|
80
|
+
return active && fpsRef.current.degraded && !visibleRef.current
|
|
81
|
+
}, [active])
|
|
82
|
+
|
|
83
|
+
return { ref, shouldHoldBack }
|
|
84
|
+
}
|