dsh-smooth-stream 0.4.3 → 0.6.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/README.en.md +13 -0
- package/README.md +13 -0
- package/lib/client.js +962 -1051
- package/lib/client.js.map +1 -1
- package/lib/index.js +22 -6
- package/package.json +11 -5
- package/src/client/AgentRowEntrance.module.css +43 -0
- package/src/client/FollowHost.tsx +18 -1
- package/src/client/SmoothStreamCard.module.css +34 -0
- package/src/client/SmoothStreamCard.tsx +39 -10
- package/src/client/TypewriterAssistantNodeView.tsx +130 -21
- package/src/client/TypewriterToolNodeView.tsx +9 -1
- package/src/client/clientStore.ts +71 -12
- package/src/client/index.ts +15 -29
- package/src/client/locales.ts +25 -5
- package/src/client/smooth-stream-card-controller.ts +10 -6
- package/src/client/smooth-stream-settings-api.ts +6 -4
- package/src/client/teleprompterGlide.ts +1021 -75
- package/src/client/turn-process-face.d.ts +27 -0
- package/src/plugin.ts +30 -6
- package/src/settings-api.ts +4 -2
- package/src/settings.ts +26 -8
- package/src/client/auto-collapse-controller.ts +0 -1072
package/lib/index.js
CHANGED
|
@@ -163,8 +163,9 @@ const STREAM_SETTINGS_NS = "smooth-stream";
|
|
|
163
163
|
/** Defaults shared by the Host schema and the client-side fallback. */
|
|
164
164
|
const DEFAULT_STREAM_SETTINGS = {
|
|
165
165
|
enabled: true,
|
|
166
|
+
controlScroll: true,
|
|
167
|
+
motionPreference: "auto",
|
|
166
168
|
thinkAutoExpand: true,
|
|
167
|
-
autoCollapse: true,
|
|
168
169
|
debugEnabled: false,
|
|
169
170
|
debugTuning: {
|
|
170
171
|
revealScale: 1,
|
|
@@ -199,8 +200,13 @@ const Config = Schema.object({
|
|
|
199
200
|
*/
|
|
200
201
|
const StreamSettingsSchema = Schema.object({
|
|
201
202
|
enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
|
|
203
|
+
controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
|
|
204
|
+
motionPreference: Schema.union([
|
|
205
|
+
Schema.const("auto"),
|
|
206
|
+
Schema.const("force-smooth"),
|
|
207
|
+
Schema.const("force-reduced")
|
|
208
|
+
]).default(DEFAULT_STREAM_SETTINGS.motionPreference),
|
|
202
209
|
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
203
|
-
autoCollapse: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.autoCollapse),
|
|
204
210
|
debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
|
|
205
211
|
debugTuning: Schema.object({
|
|
206
212
|
revealScale: Schema.number().min(.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
|
|
@@ -240,8 +246,9 @@ function apply(ctx, config) {
|
|
|
240
246
|
installation: installation.kind,
|
|
241
247
|
writable: connectionCtx.settings.writable,
|
|
242
248
|
enabled: settings.enabled,
|
|
249
|
+
controlScroll: settings.controlScroll,
|
|
250
|
+
motionPreference: settings.motionPreference,
|
|
243
251
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
244
|
-
autoCollapse: settings.autoCollapse,
|
|
245
252
|
canUpgrade: installation.kind === "npm"
|
|
246
253
|
};
|
|
247
254
|
};
|
|
@@ -263,11 +270,11 @@ function apply(ctx, config) {
|
|
|
263
270
|
value: view()
|
|
264
271
|
};
|
|
265
272
|
if (endpoint === STREAM_SETTINGS_RPC.write) {
|
|
266
|
-
if (typeof payload !== "object" || payload === null || Array.isArray(payload) || typeof payload.enabled !== "boolean" || typeof payload.
|
|
273
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload) || typeof payload.enabled !== "boolean" || typeof payload.controlScroll !== "boolean" || typeof payload.thinkAutoExpand !== "boolean") return {
|
|
267
274
|
ok: false,
|
|
268
275
|
error: {
|
|
269
276
|
code: "settings-rejected",
|
|
270
|
-
message: "enabled,
|
|
277
|
+
message: "enabled, controlScroll and thinkAutoExpand must be booleans",
|
|
271
278
|
details: { ns: STREAM_SETTINGS_NS }
|
|
272
279
|
}
|
|
273
280
|
};
|
|
@@ -281,6 +288,14 @@ function apply(ctx, config) {
|
|
|
281
288
|
};
|
|
282
289
|
try {
|
|
283
290
|
const next = payload;
|
|
291
|
+
if (next.motionPreference !== void 0 && next.motionPreference !== "auto" && next.motionPreference !== "force-smooth" && next.motionPreference !== "force-reduced") return {
|
|
292
|
+
ok: false,
|
|
293
|
+
error: {
|
|
294
|
+
code: "settings-rejected",
|
|
295
|
+
message: "motionPreference must be one of auto | force-smooth | force-reduced",
|
|
296
|
+
details: { ns: STREAM_SETTINGS_NS }
|
|
297
|
+
}
|
|
298
|
+
};
|
|
284
299
|
const hasDebug = next.debugEnabled !== void 0 || next.debugTuning !== void 0;
|
|
285
300
|
if (hasDebug && (typeof next.debugEnabled !== "boolean" || !validDebugTuning(next.debugTuning))) return {
|
|
286
301
|
ok: false,
|
|
@@ -292,8 +307,9 @@ function apply(ctx, config) {
|
|
|
292
307
|
};
|
|
293
308
|
await scope.update({
|
|
294
309
|
enabled: next.enabled,
|
|
310
|
+
controlScroll: next.controlScroll,
|
|
311
|
+
...next.motionPreference === void 0 ? {} : { motionPreference: next.motionPreference },
|
|
295
312
|
thinkAutoExpand: next.thinkAutoExpand,
|
|
296
|
-
autoCollapse: next.autoCollapse,
|
|
297
313
|
...hasDebug ? {
|
|
298
314
|
debugEnabled: next.debugEnabled,
|
|
299
315
|
debugTuning: next.debugTuning
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-smooth-stream",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Fluid streaming rendering and silky scrolling for DeepSeek Harness replies, including Markdown, code blocks, tables, and tool results.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -81,12 +81,18 @@
|
|
|
81
81
|
"scripts": {
|
|
82
82
|
"benchmark": "node scripts/run-stream-benchmark.mjs",
|
|
83
83
|
"build": "tsdown",
|
|
84
|
+
"dev:watch": "tsdown --watch",
|
|
85
|
+
"host:sync": "pnpm build && node scripts/check-lib-fresh.mjs",
|
|
84
86
|
"build:repro": "esbuild repro/main.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --alias:@deepseek-ai/dsh-client-runtime=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-runtime/client=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-ui-primitives=./repro/shims/primitives.tsx --outfile=repro/bundle.js",
|
|
87
|
+
"build:stress": "esbuild repro/stress-120fps.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --alias:@deepseek-ai/dsh-client-runtime=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-runtime/client=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-ui-primitives=./repro/shims/primitives.tsx --outfile=repro/stress-120fps.bundle.js",
|
|
88
|
+
"stress:120fps": "node scripts/stress-test-120fps.mjs",
|
|
89
|
+
"test:rebound": "node scripts/verify-y-rebound.mjs",
|
|
85
90
|
"prepare": "tsdown",
|
|
86
91
|
"test": "vitest run",
|
|
87
92
|
"typecheck": "tsc -p tsconfig.json --pretty false",
|
|
88
|
-
"test:smooth": "node scripts/probe-tailbob.mjs 1200 20 && node scripts/verify-overflow.mjs --runs 3",
|
|
89
|
-
"probe:overlap": "node scripts/probe-overlap.mjs"
|
|
93
|
+
"test:smooth": "node scripts/verify-y-rebound.mjs && node scripts/probe-tailbob.mjs 1200 20 && node scripts/verify-overflow.mjs --runs 3",
|
|
94
|
+
"probe:overlap": "node scripts/probe-overlap.mjs",
|
|
95
|
+
"probe:stability": "node scripts/verify-follow-stability.mjs"
|
|
90
96
|
},
|
|
91
97
|
"peerDependencies": {
|
|
92
98
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
@@ -139,12 +145,12 @@
|
|
|
139
145
|
"devDependencies": {
|
|
140
146
|
"@deepseek-ai/cordis": "4.0.1",
|
|
141
147
|
"@deepseek-ai/dsh-client-connection": "0.1.0-rc.6",
|
|
142
|
-
"@deepseek-ai/dsh-client-locale": "0.1.
|
|
148
|
+
"@deepseek-ai/dsh-client-locale": "0.1.2-alpha.3",
|
|
143
149
|
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
|
|
144
150
|
"@deepseek-ai/dsh-client-store": "0.1.2-alpha.3",
|
|
145
151
|
"@deepseek-ai/dsh-client-ui-attachment": "0.1.0-rc.6",
|
|
146
152
|
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.6",
|
|
147
|
-
"@deepseek-ai/dsh-client-ui-primitives": "0.1.
|
|
153
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.3",
|
|
148
154
|
"@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.6",
|
|
149
155
|
"@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.0-rc.6",
|
|
150
156
|
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Opaque Agent rows (tools, retries, context, etc.) cannot reuse the
|
|
3
|
+
* assistant's character queue. This is a single compositor-friendly reveal:
|
|
4
|
+
* the row arrives with a short rise/fade and a left-to-right mask, echoing the
|
|
5
|
+
* assistant's reading cadence without re-typing status text or countdowns.
|
|
6
|
+
*/
|
|
7
|
+
.surface {
|
|
8
|
+
min-width: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.surface[data-entrance='active'] {
|
|
12
|
+
animation: dsh-smooth-stream-agent-row-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
13
|
+
will-change: opacity, transform;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@keyframes dsh-smooth-stream-agent-row-in {
|
|
17
|
+
0% {
|
|
18
|
+
opacity: 0;
|
|
19
|
+
transform: translate3d(0, 4px, 0);
|
|
20
|
+
clip-path: inset(0 100% 0 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
55% {
|
|
24
|
+
opacity: 1;
|
|
25
|
+
clip-path: inset(0 18% 0 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
100% {
|
|
29
|
+
opacity: 1;
|
|
30
|
+
transform: translate3d(0, 0, 0);
|
|
31
|
+
clip-path: inset(0 0 0 0);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@media (prefers-reduced-motion: reduce) {
|
|
36
|
+
.surface[data-entrance='active'] {
|
|
37
|
+
animation: none;
|
|
38
|
+
opacity: 1;
|
|
39
|
+
transform: none;
|
|
40
|
+
clip-path: none;
|
|
41
|
+
will-change: auto;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -19,7 +19,10 @@ export function FollowHost({
|
|
|
19
19
|
revealScaleRef,
|
|
20
20
|
predictive = true,
|
|
21
21
|
predictiveRef,
|
|
22
|
+
controlScroll = true,
|
|
22
23
|
hostRef,
|
|
24
|
+
className,
|
|
25
|
+
entranceActive,
|
|
23
26
|
children,
|
|
24
27
|
}: {
|
|
25
28
|
active: boolean
|
|
@@ -32,8 +35,13 @@ export function FollowHost({
|
|
|
32
35
|
revealScaleRef?: { current: number } | undefined
|
|
33
36
|
predictive?: boolean
|
|
34
37
|
predictiveRef?: { current: boolean } | undefined
|
|
38
|
+
/** False leaves conversation bottom-follow to the Host. */
|
|
39
|
+
controlScroll?: boolean
|
|
35
40
|
hostRef?: { current: HTMLDivElement | null } | undefined
|
|
36
41
|
children: ReactNode
|
|
42
|
+
className?: string | undefined
|
|
43
|
+
/** Optional marker for a generic row's one-shot entrance animation. */
|
|
44
|
+
entranceActive?: boolean | undefined
|
|
37
45
|
}) {
|
|
38
46
|
const localRootRef = useRef<HTMLDivElement>(null)
|
|
39
47
|
const rootRef = hostRef ?? localRootRef
|
|
@@ -48,6 +56,7 @@ export function FollowHost({
|
|
|
48
56
|
predictiveRef,
|
|
49
57
|
entranceExtentRef,
|
|
50
58
|
revealedCharsRef,
|
|
59
|
+
controlScroll,
|
|
51
60
|
)
|
|
52
61
|
useEffect(() => {
|
|
53
62
|
if (onGrowth === undefined || typeof ResizeObserver === 'undefined') return
|
|
@@ -79,5 +88,13 @@ export function FollowHost({
|
|
|
79
88
|
if (growthFrame !== null) cancelAnimationFrame(growthFrame)
|
|
80
89
|
}
|
|
81
90
|
}, [onGrowth])
|
|
82
|
-
return
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
ref={rootRef}
|
|
94
|
+
className={className === undefined ? css.follow : `${css.follow} ${className}`}
|
|
95
|
+
data-entrance={entranceActive === undefined ? undefined : entranceActive ? 'active' : 'idle'}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
83
100
|
}
|
|
@@ -157,6 +157,40 @@
|
|
|
157
157
|
outline-offset: 2px;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
.choiceRow {
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-wrap: wrap;
|
|
163
|
+
gap: 6px 14px;
|
|
164
|
+
margin: 2px 0 0;
|
|
165
|
+
padding: 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.choice {
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 5px;
|
|
172
|
+
font-size: 12px;
|
|
173
|
+
line-height: 1.5;
|
|
174
|
+
color: var(--dsw-alias-label-secondary);
|
|
175
|
+
cursor: pointer;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.choiceInput {
|
|
179
|
+
width: 14px;
|
|
180
|
+
height: 14px;
|
|
181
|
+
accent-color: var(--dsw-alias-brand-primary);
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.choice:has(.choiceInput:disabled) {
|
|
186
|
+
cursor: default;
|
|
187
|
+
opacity: 0.55;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.choiceInput:disabled {
|
|
191
|
+
cursor: default;
|
|
192
|
+
}
|
|
193
|
+
|
|
160
194
|
.hint {
|
|
161
195
|
margin: 0;
|
|
162
196
|
font-size: 12px;
|
|
@@ -73,29 +73,58 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
|
73
73
|
</label>
|
|
74
74
|
<label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
75
75
|
<span className={css.fieldHead}>
|
|
76
|
-
<span className={css.label}>{t('
|
|
76
|
+
<span className={css.label}>{t('controlScroll')}</span>
|
|
77
77
|
<input
|
|
78
78
|
type="checkbox"
|
|
79
79
|
className={css.toggle}
|
|
80
|
-
checked={state.
|
|
80
|
+
checked={state.controlScroll}
|
|
81
81
|
disabled={!state.writable || state.saving || !state.enabled}
|
|
82
|
-
onChange={(event) => { props.edit({
|
|
82
|
+
onChange={(event) => { props.edit({ controlScroll: event.target.checked }) }}
|
|
83
83
|
/>
|
|
84
84
|
</span>
|
|
85
|
-
<span className={css.hint}>{t('
|
|
85
|
+
<span className={css.hint}>{t('controlScrollHint')}</span>
|
|
86
86
|
</label>
|
|
87
|
-
|
|
87
|
+
{/* Motion preference is a radio group, so this row is a div:
|
|
88
|
+
nesting the choice labels inside a field <label> would be
|
|
89
|
+
illegal HTML (label within label) and browsers route the
|
|
90
|
+
inner click away, so React never sees onChange. */}
|
|
91
|
+
<div className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
92
|
+
<span className={css.fieldHead}>
|
|
93
|
+
<span className={css.label}>{t('motionPreference')}</span>
|
|
94
|
+
</span>
|
|
95
|
+
<span className={css.hint}>{t('motionPreferenceHint')}</span>
|
|
96
|
+
<span className={css.choiceRow} role="radiogroup" aria-label={t('motionPreference')}>
|
|
97
|
+
{([
|
|
98
|
+
['auto', 'motionAuto', 'motionAutoHint'],
|
|
99
|
+
['force-smooth', 'motionForceSmooth', 'motionForceSmoothHint'],
|
|
100
|
+
['force-reduced', 'motionForceReduced', 'motionForceReducedHint'],
|
|
101
|
+
] as const).map(([value, label, hint]) => (
|
|
102
|
+
<label key={value} className={css.choice} title={t(hint)}>
|
|
103
|
+
<input
|
|
104
|
+
type="radio"
|
|
105
|
+
className={css.choiceInput}
|
|
106
|
+
name="smooth-stream-motion"
|
|
107
|
+
checked={state.motionPreference === value}
|
|
108
|
+
disabled={!state.writable || state.saving || !state.enabled}
|
|
109
|
+
onChange={() => { props.edit({ motionPreference: value }) }}
|
|
110
|
+
/>
|
|
111
|
+
{t(label)}
|
|
112
|
+
</label>
|
|
113
|
+
))}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
<label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
88
117
|
<span className={css.fieldHead}>
|
|
89
|
-
<span className={css.label}>{t('
|
|
118
|
+
<span className={css.label}>{t('thinkAutoExpand')}</span>
|
|
90
119
|
<input
|
|
91
120
|
type="checkbox"
|
|
92
121
|
className={css.toggle}
|
|
93
|
-
checked={state.
|
|
94
|
-
disabled={!state.writable || state.saving}
|
|
95
|
-
onChange={(event) => { props.edit({
|
|
122
|
+
checked={state.thinkAutoExpand}
|
|
123
|
+
disabled={!state.writable || state.saving || !state.enabled}
|
|
124
|
+
onChange={(event) => { props.edit({ thinkAutoExpand: event.target.checked }) }}
|
|
96
125
|
/>
|
|
97
126
|
</span>
|
|
98
|
-
<span className={css.hint}>{t('
|
|
127
|
+
<span className={css.hint}>{t('thinkAutoExpandHint')}</span>
|
|
99
128
|
</label>
|
|
100
129
|
<label className={state.debugAvailable ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
101
130
|
<span className={css.fieldHead}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
|
|
1
|
+
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from 'react'
|
|
2
2
|
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
3
3
|
import { ImageGallery, type ImageLoader, type MessageImageLabels } from '@deepseek-ai/dsh-client-ui-attachment'
|
|
4
4
|
import type { ChatNodeViewProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
@@ -8,11 +8,11 @@ import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothS
|
|
|
8
8
|
import { useFpsGuard } from './useFpsGuard.ts'
|
|
9
9
|
import { FollowHost } from './FollowHost.tsx'
|
|
10
10
|
import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
|
|
11
|
-
import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
|
|
11
|
+
import { DEFAULT_STREAM_SETTINGS, type StreamMotionPreference } from '../settings.ts'
|
|
12
12
|
import css from './TypewriterAssistantNodeView.module.css'
|
|
13
13
|
|
|
14
14
|
type AssistantProps = ChatNodeViewProps<'assistant-step'>
|
|
15
|
-
type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, '
|
|
15
|
+
type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, 'labels' | 'fileMentions' | 'text'>
|
|
16
16
|
|
|
17
17
|
function usePrefersReducedMotion(): boolean {
|
|
18
18
|
const [reduced, setReduced] = useState(
|
|
@@ -28,8 +28,25 @@ function usePrefersReducedMotion(): boolean {
|
|
|
28
28
|
return reduced
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Resolve whether the reveal engine should stay off for this view. The OS
|
|
33
|
+
* preference wins only in `auto` mode: `force-smooth` keeps the engine on
|
|
34
|
+
* machines where a system-wide reduce-motion switch (or a forced browser
|
|
35
|
+
* flag) would otherwise silently bypass smoothing, and `force-reduced`
|
|
36
|
+
* disables it even when the OS asks for motion. Credit: three-state design
|
|
37
|
+
* proposed by @Zn-Dk in #21/#22.
|
|
38
|
+
*/
|
|
39
|
+
function useMotionReduced(preference: StreamMotionPreference): boolean {
|
|
40
|
+
const system = usePrefersReducedMotion()
|
|
41
|
+
if (preference === 'force-smooth') return false
|
|
42
|
+
if (preference === 'force-reduced') return true
|
|
43
|
+
return system
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
interface AnimatedMarkdownTextProps extends MarkdownProps {
|
|
32
47
|
streaming: boolean
|
|
48
|
+
/** Whether the resolved reduced-motion gate keeps the reveal engine off. */
|
|
49
|
+
motionReduced: boolean
|
|
33
50
|
/** True on the last text block: that block owns conversation follow. */
|
|
34
51
|
ownFollow: boolean
|
|
35
52
|
followSpeedCpsRef?: { current: number } | undefined
|
|
@@ -38,6 +55,7 @@ interface AnimatedMarkdownTextProps extends MarkdownProps {
|
|
|
38
55
|
onPredictiveChange?: ((predictive: boolean) => void) | undefined
|
|
39
56
|
preset: StreamSmoothingPreset
|
|
40
57
|
shouldHoldBack: () => boolean
|
|
58
|
+
controlScroll?: boolean
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
/** Conservative fallback before the streaming Markdown tail has geometry. */
|
|
@@ -286,9 +304,10 @@ const StreamAnnouncement = memo(function StreamAnnouncement({
|
|
|
286
304
|
*/
|
|
287
305
|
function AnimatedMarkdownText({
|
|
288
306
|
text,
|
|
289
|
-
|
|
307
|
+
labels,
|
|
290
308
|
fileMentions,
|
|
291
309
|
streaming,
|
|
310
|
+
motionReduced,
|
|
292
311
|
ownFollow,
|
|
293
312
|
followSpeedCpsRef,
|
|
294
313
|
followRevealedCharsRef,
|
|
@@ -296,8 +315,9 @@ function AnimatedMarkdownText({
|
|
|
296
315
|
onPredictiveChange,
|
|
297
316
|
preset,
|
|
298
317
|
shouldHoldBack,
|
|
318
|
+
controlScroll = true,
|
|
299
319
|
}: AnimatedMarkdownTextProps) {
|
|
300
|
-
const reduced =
|
|
320
|
+
const reduced = motionReduced
|
|
301
321
|
const [typing, setTyping] = useState(streaming)
|
|
302
322
|
const localSpeedCpsRef = useRef(35)
|
|
303
323
|
const followRootRef = useRef<HTMLDivElement>(null)
|
|
@@ -348,6 +368,14 @@ function AnimatedMarkdownText({
|
|
|
348
368
|
if (typing && !streaming && shown.length === text.length) setTyping(false)
|
|
349
369
|
}, [shown, streaming, text, typing])
|
|
350
370
|
|
|
371
|
+
// A row can mount before the projection flips its assistant step to
|
|
372
|
+
// `running`, which would freeze `typing` at false forever and leave the
|
|
373
|
+
// reveal engine off for the whole reply. Re-arm on the rising edge so late
|
|
374
|
+
// stream starts are still smoothed. (Adopted from #22 by @Zn-Dk.)
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
if (streaming) setTyping(true)
|
|
377
|
+
}, [streaming])
|
|
378
|
+
|
|
351
379
|
return (
|
|
352
380
|
<FollowHost
|
|
353
381
|
active={live && ownFollow}
|
|
@@ -355,6 +383,7 @@ function AnimatedMarkdownText({
|
|
|
355
383
|
revealedCharsRef={followRevealedCharsRef}
|
|
356
384
|
revealScaleRef={followRevealScaleRef}
|
|
357
385
|
predictive={streaming}
|
|
386
|
+
controlScroll={controlScroll}
|
|
358
387
|
hostRef={followRootRef}
|
|
359
388
|
>
|
|
360
389
|
<MarkdownText
|
|
@@ -363,7 +392,7 @@ function AnimatedMarkdownText({
|
|
|
363
392
|
// Rendering `text` early bypasses the drain and teleports the tail.
|
|
364
393
|
text={live ? shown : text}
|
|
365
394
|
streaming={live}
|
|
366
|
-
|
|
395
|
+
labels={labels}
|
|
367
396
|
fileMentions={live ? undefined : fileMentions}
|
|
368
397
|
/>
|
|
369
398
|
</FollowHost>
|
|
@@ -384,6 +413,61 @@ function imageLabels(t: AssistantProps['t']): MessageImageLabels {
|
|
|
384
413
|
}
|
|
385
414
|
}
|
|
386
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Apply searchable hidden state without unmounting a stable subtree — the
|
|
418
|
+
* Host's completion fold hides the answer-inline reasoning this way, so the
|
|
419
|
+
* takeover renderer must reproduce it to stay inside the contract: the row
|
|
420
|
+
* disappears into the Host's process summary and comes back on find-in-page.
|
|
421
|
+
* (Mirrors the Harness chat kit's `useSearchableHidden`.)
|
|
422
|
+
*/
|
|
423
|
+
function useSearchableHidden(
|
|
424
|
+
hidden: boolean,
|
|
425
|
+
reveal: () => void,
|
|
426
|
+
): RefObject<HTMLDivElement> {
|
|
427
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
428
|
+
useLayoutEffect(() => {
|
|
429
|
+
const element = ref.current
|
|
430
|
+
if (element === null) return
|
|
431
|
+
if (hidden && element.contains(element.ownerDocument.activeElement)) {
|
|
432
|
+
reveal()
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if (hidden) element.setAttribute('hidden', 'until-found')
|
|
436
|
+
else element.removeAttribute('hidden')
|
|
437
|
+
}, [hidden, reveal])
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
const element = ref.current
|
|
440
|
+
if (element === null) return
|
|
441
|
+
element.addEventListener('beforematch', reveal)
|
|
442
|
+
return () => { element.removeEventListener('beforematch', reveal) }
|
|
443
|
+
}, [reveal])
|
|
444
|
+
return ref
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* One answer-inline reasoning block wrapped in the Host's fold contract. The
|
|
449
|
+
* Host computes the fold decision (turn closed, compact transcript, this node
|
|
450
|
+
* is the answer) and delivers it through the `turnProcess` owner prop; when
|
|
451
|
+
* folded the block hides into the Host's "thought" summary row instead of
|
|
452
|
+
* staying mounted above the answer.
|
|
453
|
+
*/
|
|
454
|
+
function FoldableReasoning({
|
|
455
|
+
hidden,
|
|
456
|
+
reveal,
|
|
457
|
+
children,
|
|
458
|
+
}: {
|
|
459
|
+
hidden: boolean
|
|
460
|
+
reveal: () => void
|
|
461
|
+
children: ReactNode
|
|
462
|
+
}) {
|
|
463
|
+
const ref = useSearchableHidden(hidden, reveal)
|
|
464
|
+
return (
|
|
465
|
+
<div ref={ref} data-turn-process-inline={hidden || undefined}>
|
|
466
|
+
{children}
|
|
467
|
+
</div>
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
387
471
|
function firstLine(text: string): string {
|
|
388
472
|
const newline = text.indexOf('\n')
|
|
389
473
|
return newline === -1 ? text : text.slice(0, newline)
|
|
@@ -409,6 +493,7 @@ function AnimatedReasoning({
|
|
|
409
493
|
running,
|
|
410
494
|
preset,
|
|
411
495
|
thinkAutoExpand,
|
|
496
|
+
motionReduced,
|
|
412
497
|
shouldHoldBack,
|
|
413
498
|
followSpeedCpsRef,
|
|
414
499
|
followRevealScaleRef,
|
|
@@ -418,12 +503,13 @@ function AnimatedReasoning({
|
|
|
418
503
|
running: boolean
|
|
419
504
|
preset: StreamSmoothingPreset
|
|
420
505
|
thinkAutoExpand: boolean
|
|
506
|
+
motionReduced: boolean
|
|
421
507
|
shouldHoldBack: () => boolean
|
|
422
508
|
followSpeedCpsRef?: { current: number } | undefined
|
|
423
509
|
followRevealScaleRef?: { current: number } | undefined
|
|
424
510
|
t: AssistantProps['t']
|
|
425
511
|
}) {
|
|
426
|
-
const reduced =
|
|
512
|
+
const reduced = motionReduced
|
|
427
513
|
const [expanded, setExpanded] = useState(running && thinkAutoExpand)
|
|
428
514
|
const [autoClosed, setAutoClosed] = useState(false)
|
|
429
515
|
const summaryRef = useRef<HTMLSpanElement>(null)
|
|
@@ -514,11 +600,14 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
514
600
|
scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
|
|
515
601
|
maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
|
|
516
602
|
thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
603
|
+
controlScroll = true,
|
|
604
|
+
motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
|
|
517
605
|
node,
|
|
518
606
|
useTurnData,
|
|
519
607
|
openFile,
|
|
520
608
|
loadImage,
|
|
521
609
|
fileMentions,
|
|
610
|
+
turnProcess,
|
|
522
611
|
t,
|
|
523
612
|
}: AssistantProps & {
|
|
524
613
|
mode?: StreamMode
|
|
@@ -527,10 +616,22 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
527
616
|
scrollSpeedPxPerSec?: number
|
|
528
617
|
maxScrollSpeedPxPerSec?: number
|
|
529
618
|
thinkAutoExpand?: boolean
|
|
619
|
+
controlScroll?: boolean
|
|
620
|
+
motionPreference?: StreamMotionPreference
|
|
530
621
|
}) {
|
|
531
622
|
const data = node.data
|
|
532
623
|
const streaming = data.status === 'running'
|
|
533
|
-
const reduced =
|
|
624
|
+
const reduced = useMotionReduced(motionPreference)
|
|
625
|
+
// The Host's completion-fold decision for THIS node's inline reasoning:
|
|
626
|
+
// only the answer step folds, only in compact-transcript mode, and only
|
|
627
|
+
// once the turn has closed (foldable implies it). Mirrors the built-in
|
|
628
|
+
// assistant renderer so the takeover keeps the official behavior.
|
|
629
|
+
const reasoningHidden = turnProcess !== undefined
|
|
630
|
+
&& turnProcess.foldable
|
|
631
|
+
&& turnProcess.spec.answerStep === data.step
|
|
632
|
+
&& turnProcess.spec.inlineReasoning
|
|
633
|
+
&& !turnProcess.open
|
|
634
|
+
const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess])
|
|
534
635
|
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
535
636
|
const rootSpeedRef = useRef(35)
|
|
536
637
|
const rootRevealedCharsRef = useRef(0)
|
|
@@ -568,7 +669,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
568
669
|
() => owner === undefined ? undefined : fileMentions(owner),
|
|
569
670
|
[fileMentions, owner],
|
|
570
671
|
)
|
|
571
|
-
const
|
|
672
|
+
const markdownLabels = useMemo(() => ({
|
|
673
|
+
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
|
|
674
|
+
footnotes: t('markdown.footnotes'),
|
|
675
|
+
}), [t])
|
|
572
676
|
const imageLoader: ImageLoader = loadImage ?? (async () => {
|
|
573
677
|
throw new Error(t('image.serviceUnavailable'))
|
|
574
678
|
})
|
|
@@ -597,9 +701,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
597
701
|
<AnimatedMarkdownText
|
|
598
702
|
key={index}
|
|
599
703
|
text={block.text}
|
|
600
|
-
|
|
704
|
+
labels={markdownLabels}
|
|
601
705
|
fileMentions={mentions}
|
|
602
706
|
streaming={streaming}
|
|
707
|
+
motionReduced={reduced}
|
|
603
708
|
ownFollow={!streaming && index === lastFollow}
|
|
604
709
|
followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
|
|
605
710
|
followRevealedCharsRef={index === lastFollow ? rootRevealedCharsRef : undefined}
|
|
@@ -607,22 +712,25 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
607
712
|
onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
|
|
608
713
|
preset={preset}
|
|
609
714
|
shouldHoldBack={shouldHoldBack}
|
|
715
|
+
controlScroll={controlScroll}
|
|
610
716
|
/>,
|
|
611
717
|
)
|
|
612
718
|
break
|
|
613
719
|
case 'reasoning':
|
|
614
720
|
rendered.push(
|
|
615
|
-
<
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
721
|
+
<FoldableReasoning key={index} hidden={reasoningHidden} reveal={revealProcess}>
|
|
722
|
+
<AnimatedReasoning
|
|
723
|
+
text={block.text}
|
|
724
|
+
running={streaming && index === last}
|
|
725
|
+
preset={preset}
|
|
726
|
+
thinkAutoExpand={thinkAutoExpand}
|
|
727
|
+
motionReduced={reduced}
|
|
728
|
+
shouldHoldBack={shouldHoldBack}
|
|
729
|
+
followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
|
|
730
|
+
followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
|
|
731
|
+
t={t}
|
|
732
|
+
/>
|
|
733
|
+
</FoldableReasoning>,
|
|
626
734
|
)
|
|
627
735
|
break
|
|
628
736
|
case 'image': {
|
|
@@ -663,6 +771,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
663
771
|
revealedCharsRef={rootRevealedCharsRef}
|
|
664
772
|
revealScaleRef={rootRevealScaleRef}
|
|
665
773
|
predictiveRef={rootPredictiveRef}
|
|
774
|
+
controlScroll={controlScroll}
|
|
666
775
|
>
|
|
667
776
|
<div className={css.body}>
|
|
668
777
|
{rendered}
|
|
@@ -2,6 +2,7 @@ import { createElement, useCallback, useEffect, useLayoutEffect, useRef, useStat
|
|
|
2
2
|
import { FollowHost } from './FollowHost.tsx'
|
|
3
3
|
import { useProgressiveDomText } from './useProgressiveDomText.ts'
|
|
4
4
|
import { hasRecentConversationFollow } from './teleprompterGlide.ts'
|
|
5
|
+
import entranceCss from './AgentRowEntrance.module.css'
|
|
5
6
|
|
|
6
7
|
/** Props forwarded through a follow wrap; extra kit seats pass through. */
|
|
7
8
|
export type FollowWrapProps = {
|
|
@@ -118,8 +119,12 @@ function liveAgentTailMode(root: HTMLElement): 'turn' | 'handoff' | null {
|
|
|
118
119
|
* @param Inner - The already-registered row component.
|
|
119
120
|
* @returns A follow-hosted row.
|
|
120
121
|
*/
|
|
121
|
-
export function wrapFollowNodeView(
|
|
122
|
+
export function wrapFollowNodeView(
|
|
123
|
+
Inner: ComponentType<FollowWrapProps>,
|
|
124
|
+
useControlScroll?: () => boolean,
|
|
125
|
+
) {
|
|
122
126
|
return function TypewriterFollowNodeView(props: FollowWrapProps) {
|
|
127
|
+
const controlScroll = useControlScroll?.() ?? true
|
|
123
128
|
const speedCpsRef = useRef(35)
|
|
124
129
|
const hostRef = useRef<HTMLDivElement>(null)
|
|
125
130
|
const growing = isGrowingChatNode(props.node)
|
|
@@ -215,12 +220,15 @@ export function wrapFollowNodeView(Inner: ComponentType<FollowWrapProps>) {
|
|
|
215
220
|
onGrowth={followable ? onGrowth : undefined}
|
|
216
221
|
entranceExtentRef={growthExtentRef}
|
|
217
222
|
speedCpsRef={speedCpsRef}
|
|
223
|
+
controlScroll={controlScroll}
|
|
218
224
|
// Generic Agent rows reveal and spring their measured growth, but do
|
|
219
225
|
// not continuously reserve space for a future Markdown line wrap.
|
|
220
226
|
// Keeping that assistant-only prediction here creates an idle gap
|
|
221
227
|
// above TurnStatus and a visible return when a short Tool row settles.
|
|
222
228
|
predictive={false}
|
|
223
229
|
hostRef={hostRef}
|
|
230
|
+
className={entranceCss.surface}
|
|
231
|
+
entranceActive={entering || growthPulse}
|
|
224
232
|
>
|
|
225
233
|
{createElement(Inner, props)}
|
|
226
234
|
</FollowHost>
|