dsh-smooth-stream 0.5.0 → 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/lib/client.js +226 -76
- package/lib/client.js.map +1 -1
- package/lib/index.js +16 -0
- package/package.json +1 -1
- package/src/client/SmoothStreamCard.module.css +34 -0
- package/src/client/SmoothStreamCard.tsx +29 -0
- package/src/client/TypewriterAssistantNodeView.tsx +115 -16
- package/src/client/index.ts +2 -0
- package/src/client/locales.ts +20 -0
- package/src/client/smooth-stream-card-controller.ts +7 -3
- package/src/client/smooth-stream-settings-api.ts +4 -2
- package/src/client/turn-process-face.d.ts +27 -0
- package/src/plugin.ts +23 -0
- package/src/settings-api.ts +2 -0
- package/src/settings.ts +20 -0
package/lib/index.js
CHANGED
|
@@ -164,6 +164,7 @@ const STREAM_SETTINGS_NS = "smooth-stream";
|
|
|
164
164
|
const DEFAULT_STREAM_SETTINGS = {
|
|
165
165
|
enabled: true,
|
|
166
166
|
controlScroll: true,
|
|
167
|
+
motionPreference: "auto",
|
|
167
168
|
thinkAutoExpand: true,
|
|
168
169
|
debugEnabled: false,
|
|
169
170
|
debugTuning: {
|
|
@@ -200,6 +201,11 @@ const Config = Schema.object({
|
|
|
200
201
|
const StreamSettingsSchema = Schema.object({
|
|
201
202
|
enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
|
|
202
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),
|
|
203
209
|
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
204
210
|
debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
|
|
205
211
|
debugTuning: Schema.object({
|
|
@@ -241,6 +247,7 @@ function apply(ctx, config) {
|
|
|
241
247
|
writable: connectionCtx.settings.writable,
|
|
242
248
|
enabled: settings.enabled,
|
|
243
249
|
controlScroll: settings.controlScroll,
|
|
250
|
+
motionPreference: settings.motionPreference,
|
|
244
251
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
245
252
|
canUpgrade: installation.kind === "npm"
|
|
246
253
|
};
|
|
@@ -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,
|
|
@@ -293,6 +308,7 @@ function apply(ctx, config) {
|
|
|
293
308
|
await scope.update({
|
|
294
309
|
enabled: next.enabled,
|
|
295
310
|
controlScroll: next.controlScroll,
|
|
311
|
+
...next.motionPreference === void 0 ? {} : { motionPreference: next.motionPreference },
|
|
296
312
|
thinkAutoExpand: next.thinkAutoExpand,
|
|
297
313
|
...hasDebug ? {
|
|
298
314
|
debugEnabled: next.debugEnabled,
|
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",
|
|
@@ -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;
|
|
@@ -84,6 +84,35 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
|
84
84
|
</span>
|
|
85
85
|
<span className={css.hint}>{t('controlScrollHint')}</span>
|
|
86
86
|
</label>
|
|
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>
|
|
87
116
|
<label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
88
117
|
<span className={css.fieldHead}>
|
|
89
118
|
<span className={css.label}>{t('thinkAutoExpand')}</span>
|
|
@@ -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,7 +8,7 @@ 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'>
|
|
@@ -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
|
|
@@ -290,6 +307,7 @@ function AnimatedMarkdownText({
|
|
|
290
307
|
labels,
|
|
291
308
|
fileMentions,
|
|
292
309
|
streaming,
|
|
310
|
+
motionReduced,
|
|
293
311
|
ownFollow,
|
|
294
312
|
followSpeedCpsRef,
|
|
295
313
|
followRevealedCharsRef,
|
|
@@ -299,7 +317,7 @@ function AnimatedMarkdownText({
|
|
|
299
317
|
shouldHoldBack,
|
|
300
318
|
controlScroll = true,
|
|
301
319
|
}: AnimatedMarkdownTextProps) {
|
|
302
|
-
const reduced =
|
|
320
|
+
const reduced = motionReduced
|
|
303
321
|
const [typing, setTyping] = useState(streaming)
|
|
304
322
|
const localSpeedCpsRef = useRef(35)
|
|
305
323
|
const followRootRef = useRef<HTMLDivElement>(null)
|
|
@@ -350,6 +368,14 @@ function AnimatedMarkdownText({
|
|
|
350
368
|
if (typing && !streaming && shown.length === text.length) setTyping(false)
|
|
351
369
|
}, [shown, streaming, text, typing])
|
|
352
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
|
+
|
|
353
379
|
return (
|
|
354
380
|
<FollowHost
|
|
355
381
|
active={live && ownFollow}
|
|
@@ -387,6 +413,61 @@ function imageLabels(t: AssistantProps['t']): MessageImageLabels {
|
|
|
387
413
|
}
|
|
388
414
|
}
|
|
389
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
|
+
|
|
390
471
|
function firstLine(text: string): string {
|
|
391
472
|
const newline = text.indexOf('\n')
|
|
392
473
|
return newline === -1 ? text : text.slice(0, newline)
|
|
@@ -412,6 +493,7 @@ function AnimatedReasoning({
|
|
|
412
493
|
running,
|
|
413
494
|
preset,
|
|
414
495
|
thinkAutoExpand,
|
|
496
|
+
motionReduced,
|
|
415
497
|
shouldHoldBack,
|
|
416
498
|
followSpeedCpsRef,
|
|
417
499
|
followRevealScaleRef,
|
|
@@ -421,12 +503,13 @@ function AnimatedReasoning({
|
|
|
421
503
|
running: boolean
|
|
422
504
|
preset: StreamSmoothingPreset
|
|
423
505
|
thinkAutoExpand: boolean
|
|
506
|
+
motionReduced: boolean
|
|
424
507
|
shouldHoldBack: () => boolean
|
|
425
508
|
followSpeedCpsRef?: { current: number } | undefined
|
|
426
509
|
followRevealScaleRef?: { current: number } | undefined
|
|
427
510
|
t: AssistantProps['t']
|
|
428
511
|
}) {
|
|
429
|
-
const reduced =
|
|
512
|
+
const reduced = motionReduced
|
|
430
513
|
const [expanded, setExpanded] = useState(running && thinkAutoExpand)
|
|
431
514
|
const [autoClosed, setAutoClosed] = useState(false)
|
|
432
515
|
const summaryRef = useRef<HTMLSpanElement>(null)
|
|
@@ -518,11 +601,13 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
518
601
|
maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
|
|
519
602
|
thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
520
603
|
controlScroll = true,
|
|
604
|
+
motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
|
|
521
605
|
node,
|
|
522
606
|
useTurnData,
|
|
523
607
|
openFile,
|
|
524
608
|
loadImage,
|
|
525
609
|
fileMentions,
|
|
610
|
+
turnProcess,
|
|
526
611
|
t,
|
|
527
612
|
}: AssistantProps & {
|
|
528
613
|
mode?: StreamMode
|
|
@@ -532,10 +617,21 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
532
617
|
maxScrollSpeedPxPerSec?: number
|
|
533
618
|
thinkAutoExpand?: boolean
|
|
534
619
|
controlScroll?: boolean
|
|
620
|
+
motionPreference?: StreamMotionPreference
|
|
535
621
|
}) {
|
|
536
622
|
const data = node.data
|
|
537
623
|
const streaming = data.status === 'running'
|
|
538
|
-
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])
|
|
539
635
|
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
540
636
|
const rootSpeedRef = useRef(35)
|
|
541
637
|
const rootRevealedCharsRef = useRef(0)
|
|
@@ -608,6 +704,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
608
704
|
labels={markdownLabels}
|
|
609
705
|
fileMentions={mentions}
|
|
610
706
|
streaming={streaming}
|
|
707
|
+
motionReduced={reduced}
|
|
611
708
|
ownFollow={!streaming && index === lastFollow}
|
|
612
709
|
followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
|
|
613
710
|
followRevealedCharsRef={index === lastFollow ? rootRevealedCharsRef : undefined}
|
|
@@ -621,17 +718,19 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
621
718
|
break
|
|
622
719
|
case 'reasoning':
|
|
623
720
|
rendered.push(
|
|
624
|
-
<
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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>,
|
|
635
734
|
)
|
|
636
735
|
break
|
|
637
736
|
case 'image': {
|
package/src/client/index.ts
CHANGED
|
@@ -157,6 +157,7 @@ class SettingsCell {
|
|
|
157
157
|
pending === this.pending
|
|
158
158
|
&& next.enabled === this.value.enabled
|
|
159
159
|
&& next.controlScroll === this.value.controlScroll
|
|
160
|
+
&& next.motionPreference === this.value.motionPreference
|
|
160
161
|
&& next.thinkAutoExpand === this.value.thinkAutoExpand
|
|
161
162
|
&& next.debugEnabled === this.value.debugEnabled
|
|
162
163
|
&& next.debugTuning === this.value.debugTuning
|
|
@@ -270,6 +271,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
270
271
|
maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
|
|
271
272
|
thinkAutoExpand: preferences.thinkAutoExpand,
|
|
272
273
|
controlScroll: preferences.controlScroll,
|
|
274
|
+
motionPreference: preferences.motionPreference,
|
|
273
275
|
})
|
|
274
276
|
}
|
|
275
277
|
ctx.slots.inject('conversation.chat.node', () => {
|
package/src/client/locales.ts
CHANGED
|
@@ -8,6 +8,10 @@ export type SmoothStreamLocaleKey =
|
|
|
8
8
|
| 'title' | 'description'
|
|
9
9
|
| 'enabled' | 'enabledHint'
|
|
10
10
|
| 'controlScroll' | 'controlScrollHint'
|
|
11
|
+
| 'motionPreference' | 'motionPreferenceHint'
|
|
12
|
+
| 'motionAuto' | 'motionAutoHint'
|
|
13
|
+
| 'motionForceSmooth' | 'motionForceSmoothHint'
|
|
14
|
+
| 'motionForceReduced' | 'motionForceReducedHint'
|
|
11
15
|
| 'thinkAutoExpand' | 'thinkAutoExpandHint'
|
|
12
16
|
| 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
|
|
13
17
|
| 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
|
|
@@ -37,6 +41,14 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
|
|
|
37
41
|
enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
|
|
38
42
|
controlScroll: 'Take over scrolling',
|
|
39
43
|
controlScrollHint: 'On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.',
|
|
44
|
+
motionPreference: 'Motion',
|
|
45
|
+
motionPreferenceHint: 'How streaming responds to the system reduce-motion setting.',
|
|
46
|
+
motionAuto: 'Follow system',
|
|
47
|
+
motionAutoHint: 'Reduced-motion systems get raw text (accessibility first).',
|
|
48
|
+
motionForceSmooth: 'Always smooth',
|
|
49
|
+
motionForceSmoothHint: 'Keep the smoothing engine even when the system asks for reduced motion.',
|
|
50
|
+
motionForceReduced: 'Always raw',
|
|
51
|
+
motionForceReducedHint: 'Render raw text even when the system allows motion.',
|
|
40
52
|
thinkAutoExpand: 'Auto-expand thinking',
|
|
41
53
|
thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
|
|
42
54
|
debugEnabled: 'Show render diagnostics',
|
|
@@ -117,6 +129,14 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
|
|
|
117
129
|
enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
|
|
118
130
|
controlScroll: '接管滚动',
|
|
119
131
|
controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
|
|
132
|
+
motionPreference: '动效偏好',
|
|
133
|
+
motionPreferenceHint: '流式渲染如何响应系统的「减少动态效果」设置。',
|
|
134
|
+
motionAuto: '跟随系统',
|
|
135
|
+
motionAutoHint: '系统开启减少动态效果时直接呈现原始文本(优先无障碍)。',
|
|
136
|
+
motionForceSmooth: '始终平滑',
|
|
137
|
+
motionForceSmoothHint: '即使系统要求减少动态效果,仍保持平滑流式渲染。',
|
|
138
|
+
motionForceReduced: '始终原始',
|
|
139
|
+
motionForceReducedHint: '即使系统允许动效,也直接呈现原始文本。',
|
|
120
140
|
thinkAutoExpand: '自动展开思考',
|
|
121
141
|
thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
|
|
122
142
|
debugEnabled: '显示渲染调试面板',
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEFAULT_STREAM_DEBUG_TUNING,
|
|
6
6
|
DEFAULT_STREAM_SETTINGS,
|
|
7
7
|
type StreamDebugTuning,
|
|
8
|
+
type StreamMotionPreference,
|
|
8
9
|
type StreamSettings,
|
|
9
10
|
} from '../settings.ts'
|
|
10
11
|
import type {
|
|
@@ -23,6 +24,7 @@ export interface SmoothStreamCardState {
|
|
|
23
24
|
failed: boolean
|
|
24
25
|
enabled: boolean
|
|
25
26
|
controlScroll: boolean
|
|
27
|
+
motionPreference: StreamMotionPreference
|
|
26
28
|
thinkAutoExpand: boolean
|
|
27
29
|
debugEnabled: boolean
|
|
28
30
|
debugTuning: StreamDebugTuning
|
|
@@ -52,7 +54,7 @@ export class SmoothStreamCardController {
|
|
|
52
54
|
private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
|
|
53
55
|
private loaded: StreamSettingsView | undefined
|
|
54
56
|
private loadedDebug: StreamDebugSettingsView | undefined
|
|
55
|
-
private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> | undefined
|
|
57
|
+
private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> | undefined
|
|
56
58
|
private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
|
|
57
59
|
private saving = false
|
|
58
60
|
private failed = false
|
|
@@ -90,11 +92,12 @@ export class SmoothStreamCardController {
|
|
|
90
92
|
hooks: { smoothStreamCard: this.store },
|
|
91
93
|
edit: (patch) => {
|
|
92
94
|
if (this.saving) return
|
|
93
|
-
if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.thinkAutoExpand !== undefined) {
|
|
95
|
+
if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined) {
|
|
94
96
|
this.stagedBase = {
|
|
95
97
|
...this.baseValues(),
|
|
96
98
|
...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
|
|
97
99
|
...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
|
|
100
|
+
...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
|
|
98
101
|
...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
|
|
99
102
|
}
|
|
100
103
|
}
|
|
@@ -140,10 +143,11 @@ export class SmoothStreamCardController {
|
|
|
140
143
|
}
|
|
141
144
|
}
|
|
142
145
|
|
|
143
|
-
private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> {
|
|
146
|
+
private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> {
|
|
144
147
|
return this.stagedBase ?? {
|
|
145
148
|
enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
|
|
146
149
|
controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
|
|
150
|
+
motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
|
|
147
151
|
thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
148
152
|
}
|
|
149
153
|
}
|
|
@@ -24,6 +24,7 @@ function settingsView(value: unknown): StreamSettingsView {
|
|
|
24
24
|
|| typeof data.writable !== 'boolean'
|
|
25
25
|
|| typeof data.enabled !== 'boolean'
|
|
26
26
|
|| typeof data.controlScroll !== 'boolean'
|
|
27
|
+
|| !['auto', 'force-smooth', 'force-reduced'].includes(data.motionPreference as string)
|
|
27
28
|
|| typeof data.thinkAutoExpand !== 'boolean'
|
|
28
29
|
|| typeof data.canUpgrade !== 'boolean') {
|
|
29
30
|
throw new Error('dsh-smooth-stream: malformed settings response')
|
|
@@ -68,7 +69,7 @@ function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>):
|
|
|
68
69
|
/** Narrow client contract consumed by the staged settings-card controller. */
|
|
69
70
|
export interface SmoothStreamSettingsApi {
|
|
70
71
|
read(): Promise<StreamSettingsView>
|
|
71
|
-
write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
|
|
72
|
+
write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
|
|
72
73
|
readDebug(): Promise<StreamDebugSettingsView>
|
|
73
74
|
writeDebug(settings: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>): Promise<StreamDebugSettingsView>
|
|
74
75
|
upgrade(): Promise<StreamUpgradeView>
|
|
@@ -80,13 +81,14 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
|
|
|
80
81
|
async read(): Promise<StreamSettingsView> {
|
|
81
82
|
return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
|
|
82
83
|
},
|
|
83
|
-
async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
|
|
84
|
+
async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
|
|
84
85
|
return settingsView(accepted(await connection.rpc.call(
|
|
85
86
|
STREAM_SETTINGS_RPC_CHANNEL,
|
|
86
87
|
STREAM_SETTINGS_RPC.write,
|
|
87
88
|
{
|
|
88
89
|
enabled: settings.enabled,
|
|
89
90
|
controlScroll: settings.controlScroll,
|
|
91
|
+
motionPreference: settings.motionPreference,
|
|
90
92
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
91
93
|
...(settings.debugEnabled === undefined || settings.debugTuning === undefined
|
|
92
94
|
? {}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-process fold face for the DSH 0.1.2 completion-collapse contract.
|
|
3
|
+
*
|
|
4
|
+
* The Host seat hands every keyed Chat renderer a `turnProcess` owner prop
|
|
5
|
+
* when the node belongs to a projected Turn: the fold decision lives in the
|
|
6
|
+
* conversation snapshot (turn closed + compact transcript), and the renderer
|
|
7
|
+
* is expected to hide its answer-inline reasoning while folded. The pinned
|
|
8
|
+
* type-only dependency predates the field, so the face is declared here and
|
|
9
|
+
* merged into the owner-props interface; erases at compile time.
|
|
10
|
+
*/
|
|
11
|
+
export interface TurnProcessOwnerFace {
|
|
12
|
+
/** Answer anchor of the projection; null while the answer has not landed. */
|
|
13
|
+
readonly spec: { readonly answerStep: number | null, readonly inlineReasoning: boolean }
|
|
14
|
+
/** Whether this node's process content may fold (turn closed + compact mode). */
|
|
15
|
+
readonly foldable: boolean
|
|
16
|
+
/** Current fold state, persisted by the Host across sessions. */
|
|
17
|
+
readonly open: boolean
|
|
18
|
+
/** Fold or unfold from the renderer side (summary click, find-in-page reveal). */
|
|
19
|
+
setOpen(open: boolean): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
|
23
|
+
interface ChatNodeOwnerProps {
|
|
24
|
+
/** Present only when the node belongs to a projected Turn (0.1.2+). */
|
|
25
|
+
turnProcess?: TurnProcessOwnerFace | undefined
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -56,6 +56,11 @@ export const Config: Schema<Config> = Schema.object({
|
|
|
56
56
|
export const StreamSettingsSchema: Schema<StreamSettings> = Schema.object({
|
|
57
57
|
enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
|
|
58
58
|
controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
|
|
59
|
+
motionPreference: Schema.union([
|
|
60
|
+
Schema.const('auto'),
|
|
61
|
+
Schema.const('force-smooth'),
|
|
62
|
+
Schema.const('force-reduced'),
|
|
63
|
+
] as const).default(DEFAULT_STREAM_SETTINGS.motionPreference),
|
|
59
64
|
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
60
65
|
debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
|
|
61
66
|
debugTuning: Schema.object({
|
|
@@ -118,6 +123,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
118
123
|
writable: connectionCtx.settings.writable,
|
|
119
124
|
enabled: settings.enabled,
|
|
120
125
|
controlScroll: settings.controlScroll,
|
|
126
|
+
motionPreference: settings.motionPreference,
|
|
121
127
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
122
128
|
canUpgrade: installation.kind === 'npm',
|
|
123
129
|
}
|
|
@@ -185,10 +191,26 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
185
191
|
const next = payload as {
|
|
186
192
|
enabled: boolean
|
|
187
193
|
controlScroll: boolean
|
|
194
|
+
motionPreference?: unknown
|
|
188
195
|
thinkAutoExpand: boolean
|
|
189
196
|
debugEnabled?: unknown
|
|
190
197
|
debugTuning?: unknown
|
|
191
198
|
}
|
|
199
|
+
if (
|
|
200
|
+
next.motionPreference !== undefined
|
|
201
|
+
&& next.motionPreference !== 'auto'
|
|
202
|
+
&& next.motionPreference !== 'force-smooth'
|
|
203
|
+
&& next.motionPreference !== 'force-reduced'
|
|
204
|
+
) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
error: {
|
|
208
|
+
code: 'settings-rejected',
|
|
209
|
+
message: 'motionPreference must be one of auto | force-smooth | force-reduced',
|
|
210
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
192
214
|
const hasDebug = next.debugEnabled !== undefined || next.debugTuning !== undefined
|
|
193
215
|
if (hasDebug && (typeof next.debugEnabled !== 'boolean' || !validDebugTuning(next.debugTuning))) {
|
|
194
216
|
return {
|
|
@@ -203,6 +225,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
203
225
|
await scope.update({
|
|
204
226
|
enabled: next.enabled,
|
|
205
227
|
controlScroll: next.controlScroll,
|
|
228
|
+
...(next.motionPreference === undefined ? {} : { motionPreference: next.motionPreference }),
|
|
206
229
|
thinkAutoExpand: next.thinkAutoExpand,
|
|
207
230
|
...(hasDebug ? { debugEnabled: next.debugEnabled, debugTuning: next.debugTuning } : {}),
|
|
208
231
|
})
|
package/src/settings-api.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface StreamSettingsView {
|
|
|
27
27
|
enabled: boolean
|
|
28
28
|
/** Whether smooth-stream also owns conversation bottom-follow. */
|
|
29
29
|
controlScroll: boolean
|
|
30
|
+
/** How the reveal honors the OS reduced-motion preference. */
|
|
31
|
+
motionPreference: import('./settings.ts').StreamMotionPreference
|
|
30
32
|
/** Current resolved preference. */
|
|
31
33
|
thinkAutoExpand: boolean
|
|
32
34
|
/** Whether a fixed npm update command is safe to offer. */
|
package/src/settings.ts
CHANGED
|
@@ -45,6 +45,18 @@ export const DEFAULT_STREAM_DEBUG_TUNING: StreamDebugTuning = {
|
|
|
45
45
|
backpressureMinScale: 0.55,
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* How the typewriter reveal responds to the OS reduced-motion preference.
|
|
50
|
+
* Credit: three-state design proposed by @Zn-Dk in #21/#22.
|
|
51
|
+
*/
|
|
52
|
+
export type StreamMotionPreference =
|
|
53
|
+
/** Follow the OS preference: reduced-motion users get raw text (accessibility first). */
|
|
54
|
+
| 'auto'
|
|
55
|
+
/** Always run the smoothing engine, ignoring the OS preference. */
|
|
56
|
+
| 'force-smooth'
|
|
57
|
+
/** Always render raw text, ignoring the OS preference. */
|
|
58
|
+
| 'force-reduced'
|
|
59
|
+
|
|
48
60
|
/**
|
|
49
61
|
* Preferences a user may set. Deliberately separate from {@link StreamConfig}
|
|
50
62
|
* because the two change at different times: composition-time values go
|
|
@@ -61,6 +73,13 @@ export interface StreamSettings {
|
|
|
61
73
|
* scroll ownership to the Harness; text reveal still runs.
|
|
62
74
|
*/
|
|
63
75
|
controlScroll: boolean
|
|
76
|
+
/**
|
|
77
|
+
* How the reveal honors the OS reduced-motion preference. `auto` preserves
|
|
78
|
+
* the accessibility-first default; `force-smooth` keeps the engine on
|
|
79
|
+
* machines where a system-wide reduce-motion switch (performance tooling,
|
|
80
|
+
* RDP sessions, forced browser flags) would otherwise bypass it silently.
|
|
81
|
+
*/
|
|
82
|
+
motionPreference: StreamMotionPreference
|
|
64
83
|
/**
|
|
65
84
|
* Whether a reasoning ("Think") block auto-expands while it is the
|
|
66
85
|
* streaming tail. Off keeps the block collapsed — the user can still open
|
|
@@ -77,6 +96,7 @@ export interface StreamSettings {
|
|
|
77
96
|
export const DEFAULT_STREAM_SETTINGS: StreamSettings = {
|
|
78
97
|
enabled: true,
|
|
79
98
|
controlScroll: true,
|
|
99
|
+
motionPreference: 'auto',
|
|
80
100
|
thinkAutoExpand: true,
|
|
81
101
|
debugEnabled: false,
|
|
82
102
|
debugTuning: DEFAULT_STREAM_DEBUG_TUNING,
|