dsh-smooth-stream 0.4.1 → 0.4.2
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 +41 -7
- package/README.md +40 -6
- package/lib/client.js +1616 -1102
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/package.json +17 -13
- package/src/client/AnimatedDisclosure.tsx +14 -1
- package/src/client/FollowHost.tsx +21 -4
- package/src/client/TypewriterAssistantNodeView.module.css +28 -1
- package/src/client/TypewriterAssistantNodeView.tsx +36 -3
- package/src/client/auto-collapse-controller.ts +134 -39
- package/src/client/clientStore.ts +41 -0
- package/src/client/debugRuntime.ts +1 -1
- package/src/client/smooth-stream-card-controller.ts +1 -1
- package/src/client/teleprompterGlide.ts +878 -114
- package/src/client/useSmoothStreamContent.ts +71 -15
- package/src/settings.ts +1 -1
package/lib/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-smooth-stream",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
7
|
+
"packageManager": "pnpm@11.23.0",
|
|
7
8
|
"engines": {
|
|
8
9
|
"node": "^22.19.0 || >=24.0.0"
|
|
9
10
|
},
|
|
@@ -77,21 +78,29 @@
|
|
|
77
78
|
]
|
|
78
79
|
}
|
|
79
80
|
},
|
|
80
|
-
"
|
|
81
|
-
"
|
|
81
|
+
"scripts": {
|
|
82
|
+
"benchmark": "node scripts/run-stream-benchmark.mjs",
|
|
83
|
+
"build": "tsdown",
|
|
84
|
+
"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",
|
|
85
|
+
"prepare": "tsdown",
|
|
86
|
+
"test": "vitest run",
|
|
87
|
+
"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"
|
|
82
90
|
},
|
|
83
91
|
"peerDependencies": {
|
|
84
92
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
93
|
+
"@deepseek-ai/dsh-client-connection": "*",
|
|
85
94
|
"@deepseek-ai/dsh-client-locale": "*",
|
|
86
95
|
"@deepseek-ai/dsh-client-runtime": "*",
|
|
87
96
|
"@deepseek-ai/dsh-client-ui-attachment": "*",
|
|
88
|
-
"@deepseek-ai/dsh-client-connection": "*",
|
|
89
97
|
"@deepseek-ai/dsh-client-ui-conversation": "*",
|
|
90
98
|
"@deepseek-ai/dsh-client-ui-primitives": "*",
|
|
91
99
|
"@deepseek-ai/dsh-client-ui-settings": "*",
|
|
92
100
|
"@deepseek-ai/dsh-client-ui-settings-plugins": "*",
|
|
93
101
|
"@deepseek-ai/dsh-client-ui-slots": "*",
|
|
94
102
|
"@deepseek-ai/dsh-host-webserver": "*",
|
|
103
|
+
"@deepseek-ai/dsh-settings": "*",
|
|
95
104
|
"@deepseek-ai/schemastery": "*",
|
|
96
105
|
"react": "^18.2.0"
|
|
97
106
|
},
|
|
@@ -129,10 +138,11 @@
|
|
|
129
138
|
},
|
|
130
139
|
"devDependencies": {
|
|
131
140
|
"@deepseek-ai/cordis": "4.0.1",
|
|
141
|
+
"@deepseek-ai/dsh-client-connection": "0.1.0-rc.6",
|
|
132
142
|
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.6",
|
|
133
143
|
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
|
|
144
|
+
"@deepseek-ai/dsh-client-store": "0.1.2-alpha.3",
|
|
134
145
|
"@deepseek-ai/dsh-client-ui-attachment": "0.1.0-rc.6",
|
|
135
|
-
"@deepseek-ai/dsh-client-connection": "0.1.0-rc.6",
|
|
136
146
|
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.6",
|
|
137
147
|
"@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.6",
|
|
138
148
|
"@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.6",
|
|
@@ -148,17 +158,11 @@
|
|
|
148
158
|
"esbuild": "^0.28.2",
|
|
149
159
|
"jsdom": "^26.1.0",
|
|
150
160
|
"lightningcss": "^1.30.1",
|
|
161
|
+
"playwright-core": "^1.62.1",
|
|
151
162
|
"react": "^18.3.1",
|
|
152
163
|
"react-dom": "^18.3.1",
|
|
153
164
|
"tsdown": "^0.22.2",
|
|
154
165
|
"typescript": "^5.9.2",
|
|
155
166
|
"vitest": "^4.1.8"
|
|
156
|
-
},
|
|
157
|
-
"scripts": {
|
|
158
|
-
"benchmark": "node scripts/run-stream-benchmark.mjs",
|
|
159
|
-
"build": "tsdown",
|
|
160
|
-
"build:repro": "esbuild repro/main.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --outfile=repro/bundle.js",
|
|
161
|
-
"test": "vitest run",
|
|
162
|
-
"typecheck": "tsc -p tsconfig.json --pretty false"
|
|
163
167
|
}
|
|
164
|
-
}
|
|
168
|
+
}
|
|
@@ -27,6 +27,13 @@ export interface AnimatedDisclosureProps {
|
|
|
27
27
|
leadingClassName?: string | undefined
|
|
28
28
|
titleClassName?: string | undefined
|
|
29
29
|
chevronClassName?: string | undefined
|
|
30
|
+
/**
|
|
31
|
+
* When false the body snaps between states with no grid-track transition.
|
|
32
|
+
* The auto-close at stream end uses this: the conversation follower's
|
|
33
|
+
* settle spring absorbs the height step through the compositor instead,
|
|
34
|
+
* which glides identically without twelve frames of layout animation.
|
|
35
|
+
*/
|
|
36
|
+
bodyTransition?: boolean
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/**
|
|
@@ -46,6 +53,7 @@ export function AnimatedDisclosure({
|
|
|
46
53
|
leadingClassName,
|
|
47
54
|
titleClassName,
|
|
48
55
|
chevronClassName,
|
|
56
|
+
bodyTransition = true,
|
|
49
57
|
}: AnimatedDisclosureProps) {
|
|
50
58
|
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
51
59
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
@@ -77,7 +85,12 @@ export function AnimatedDisclosure({
|
|
|
77
85
|
<span className={cx(css.disclosureTitle, titleClassName)}>{title}</span>
|
|
78
86
|
{!open && collapsedContent}
|
|
79
87
|
</div>
|
|
80
|
-
<div
|
|
88
|
+
<div
|
|
89
|
+
className={css.disclosureContent}
|
|
90
|
+
data-disclosure-content
|
|
91
|
+
data-collapsed={open ? undefined : ''}
|
|
92
|
+
data-no-transition={bodyTransition ? undefined : ''}
|
|
93
|
+
>
|
|
81
94
|
{children}
|
|
82
95
|
</div>
|
|
83
96
|
</div>
|
|
@@ -15,6 +15,7 @@ export function FollowHost({
|
|
|
15
15
|
onGrowth,
|
|
16
16
|
entranceExtentRef,
|
|
17
17
|
speedCpsRef,
|
|
18
|
+
revealedCharsRef,
|
|
18
19
|
revealScaleRef,
|
|
19
20
|
predictive = true,
|
|
20
21
|
predictiveRef,
|
|
@@ -27,6 +28,7 @@ export function FollowHost({
|
|
|
27
28
|
onGrowth?: ((deltaPx: number) => void) | undefined
|
|
28
29
|
entranceExtentRef?: { current: number | null } | undefined
|
|
29
30
|
speedCpsRef: { current: number }
|
|
31
|
+
revealedCharsRef?: { current: number } | undefined
|
|
30
32
|
revealScaleRef?: { current: number } | undefined
|
|
31
33
|
predictive?: boolean
|
|
32
34
|
predictiveRef?: { current: boolean } | undefined
|
|
@@ -45,22 +47,37 @@ export function FollowHost({
|
|
|
45
47
|
onEntranceSettled,
|
|
46
48
|
predictiveRef,
|
|
47
49
|
entranceExtentRef,
|
|
50
|
+
revealedCharsRef,
|
|
48
51
|
)
|
|
49
52
|
useEffect(() => {
|
|
50
53
|
if (onGrowth === undefined || typeof ResizeObserver === 'undefined') return
|
|
51
54
|
const root = rootRef.current
|
|
52
55
|
if (root === null) return
|
|
53
56
|
let previousHeight: number | null = null
|
|
57
|
+
let pendingGrowth = 0
|
|
58
|
+
let growthFrame: number | null = null
|
|
59
|
+
const flushGrowth = (): void => {
|
|
60
|
+
growthFrame = null
|
|
61
|
+
if (pendingGrowth <= 0) return
|
|
62
|
+
const delta = pendingGrowth
|
|
63
|
+
pendingGrowth = 0
|
|
64
|
+
onGrowth(delta)
|
|
65
|
+
}
|
|
54
66
|
const observer = new ResizeObserver(entries => {
|
|
55
|
-
const
|
|
56
|
-
if (!Number.isFinite(
|
|
67
|
+
const measuredHeight = entries[0]?.contentRect.height
|
|
68
|
+
if (measuredHeight === undefined || !Number.isFinite(measuredHeight)) return
|
|
69
|
+
const nextHeight = measuredHeight
|
|
57
70
|
if (previousHeight !== null && nextHeight > previousHeight + 0.5) {
|
|
58
|
-
|
|
71
|
+
pendingGrowth += nextHeight - previousHeight
|
|
72
|
+
if (growthFrame === null) growthFrame = requestAnimationFrame(flushGrowth)
|
|
59
73
|
}
|
|
60
74
|
previousHeight = nextHeight
|
|
61
75
|
})
|
|
62
76
|
observer.observe(root)
|
|
63
|
-
return () => {
|
|
77
|
+
return () => {
|
|
78
|
+
observer.disconnect()
|
|
79
|
+
if (growthFrame !== null) cancelAnimationFrame(growthFrame)
|
|
80
|
+
}
|
|
64
81
|
}, [onGrowth])
|
|
65
82
|
return <div ref={rootRef} className={css.follow}>{children}</div>
|
|
66
83
|
}
|
|
@@ -178,6 +178,13 @@
|
|
|
178
178
|
visibility 0s var(--ds-transition-duration, 0.2s);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/* Auto-close at stream end snaps instead of animating: the follower's settle
|
|
182
|
+
* spring already absorbs the height step through the compositor, so a grid
|
|
183
|
+
* animation would double-animate the same collapse for a dozen frames. */
|
|
184
|
+
.disclosureContent[data-no-transition] {
|
|
185
|
+
transition: none;
|
|
186
|
+
}
|
|
187
|
+
|
|
181
188
|
.disclosureContent > * {
|
|
182
189
|
min-height: 0;
|
|
183
190
|
overflow: hidden;
|
|
@@ -227,6 +234,22 @@
|
|
|
227
234
|
min-width: 0;
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
/*
|
|
238
|
+
* Inline code is allowed to wrap inside list items. The Harness markdown
|
|
239
|
+
* theme uses `inline-flex` for its single-line code treatment, but an
|
|
240
|
+
* inline-flex box can expose multiple painted line boxes without contributing
|
|
241
|
+
* their full height to a trimmed list-item line box in Chromium. Keep the
|
|
242
|
+
* code's font and padding while using normal inline layout so every wrapped
|
|
243
|
+
* line participates in the parent list item's geometry.
|
|
244
|
+
*/
|
|
245
|
+
.root :not(pre) > code {
|
|
246
|
+
display: inline;
|
|
247
|
+
line-height: inherit;
|
|
248
|
+
white-space: normal;
|
|
249
|
+
overflow-wrap: anywhere;
|
|
250
|
+
vertical-align: baseline;
|
|
251
|
+
}
|
|
252
|
+
|
|
230
253
|
/*
|
|
231
254
|
* Trim the half-leading above and below each text block so line boxes hug
|
|
232
255
|
* the font's ascent/descent edges. `text` edges are used instead of
|
|
@@ -234,7 +257,11 @@
|
|
|
234
257
|
* trimming would clip ideographic glyphs.
|
|
235
258
|
*/
|
|
236
259
|
@supports (text-box-trim: trim-both) {
|
|
237
|
-
|
|
260
|
+
/* List-item line boxes must retain their full leading. A wrapped inline
|
|
261
|
+
* code element is an inline-flex formatting context; Chromium's
|
|
262
|
+
* text-box-trim can shrink the li's layout box below the code's painted
|
|
263
|
+
* second line, letting the following item start inside that line. */
|
|
264
|
+
.root :is(p, h1, h2, h3, h4, h5, h6, blockquote) {
|
|
238
265
|
text-box-trim: trim-both;
|
|
239
266
|
text-box-edge: text;
|
|
240
267
|
}
|
|
@@ -3,6 +3,7 @@ import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-cl
|
|
|
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'
|
|
5
5
|
import { AnimatedDisclosure } from './AnimatedDisclosure.tsx'
|
|
6
|
+
import { notifyFollowCommit } from './teleprompterGlide.ts'
|
|
6
7
|
import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothStreamContent.ts'
|
|
7
8
|
import { useFpsGuard } from './useFpsGuard.ts'
|
|
8
9
|
import { FollowHost } from './FollowHost.tsx'
|
|
@@ -32,6 +33,7 @@ interface AnimatedMarkdownTextProps extends MarkdownProps {
|
|
|
32
33
|
/** True on the last text block: that block owns conversation follow. */
|
|
33
34
|
ownFollow: boolean
|
|
34
35
|
followSpeedCpsRef?: { current: number } | undefined
|
|
36
|
+
followRevealedCharsRef?: { current: number } | undefined
|
|
35
37
|
followRevealScaleRef?: { current: number } | undefined
|
|
36
38
|
onPredictiveChange?: ((predictive: boolean) => void) | undefined
|
|
37
39
|
preset: StreamSmoothingPreset
|
|
@@ -289,6 +291,7 @@ function AnimatedMarkdownText({
|
|
|
289
291
|
streaming,
|
|
290
292
|
ownFollow,
|
|
291
293
|
followSpeedCpsRef,
|
|
294
|
+
followRevealedCharsRef,
|
|
292
295
|
followRevealScaleRef,
|
|
293
296
|
onPredictiveChange,
|
|
294
297
|
preset,
|
|
@@ -308,7 +311,9 @@ function AnimatedMarkdownText({
|
|
|
308
311
|
preset,
|
|
309
312
|
shouldHoldBack,
|
|
310
313
|
speedCpsRef,
|
|
314
|
+
revealedCharsRef: followRevealedCharsRef,
|
|
311
315
|
revealScaleRef: followRevealScaleRef,
|
|
316
|
+
onRevealCommit: () => { notifyFollowCommit(followRootRef.current) },
|
|
312
317
|
})
|
|
313
318
|
const shown = reduced ? text : displayed
|
|
314
319
|
const live = typing && !reduced
|
|
@@ -347,11 +352,15 @@ function AnimatedMarkdownText({
|
|
|
347
352
|
<FollowHost
|
|
348
353
|
active={live && ownFollow}
|
|
349
354
|
speedCpsRef={speedCpsRef}
|
|
355
|
+
revealedCharsRef={followRevealedCharsRef}
|
|
350
356
|
revealScaleRef={followRevealScaleRef}
|
|
351
357
|
predictive={streaming}
|
|
352
358
|
hostRef={followRootRef}
|
|
353
359
|
>
|
|
354
360
|
<MarkdownText
|
|
361
|
+
// `shown` stays authoritative through the completion drain: the
|
|
362
|
+
// settled parse swaps in only when the queue has actually emptied.
|
|
363
|
+
// Rendering `text` early bypasses the drain and teleports the tail.
|
|
355
364
|
text={live ? shown : text}
|
|
356
365
|
streaming={live}
|
|
357
366
|
codeLabels={codeLabels}
|
|
@@ -416,13 +425,19 @@ function AnimatedReasoning({
|
|
|
416
425
|
}) {
|
|
417
426
|
const reduced = usePrefersReducedMotion()
|
|
418
427
|
const [expanded, setExpanded] = useState(running && thinkAutoExpand)
|
|
428
|
+
const [autoClosed, setAutoClosed] = useState(false)
|
|
419
429
|
const summaryRef = useRef<HTMLSpanElement>(null)
|
|
430
|
+
const commitAnchorRef = useRef<HTMLDivElement>(null)
|
|
431
|
+
// The running→false flip is the AUTO-close: it collapses instantly and the
|
|
432
|
+
// follower's settle spring absorbs the height step. A later manual toggle
|
|
433
|
+
// keeps the CSS glide.
|
|
420
434
|
const displayed = useSmoothStreamContent(text, {
|
|
421
435
|
enabled: running && !reduced,
|
|
422
436
|
preset,
|
|
423
437
|
shouldHoldBack,
|
|
424
438
|
speedCpsRef: followSpeedCpsRef,
|
|
425
439
|
revealScaleRef: followRevealScaleRef,
|
|
440
|
+
onRevealCommit: () => { notifyFollowCommit(commitAnchorRef.current) },
|
|
426
441
|
})
|
|
427
442
|
const shown = running && !reduced ? displayed : text
|
|
428
443
|
const summary = running ? latestLine(shown) : firstLine(text)
|
|
@@ -430,7 +445,14 @@ function AnimatedReasoning({
|
|
|
430
445
|
useLayoutEffect(() => {
|
|
431
446
|
// Only the running state owns disclosure while auto-expand is on; with it
|
|
432
447
|
// off, a manual toggle is never wrestled back by the stream.
|
|
433
|
-
if (thinkAutoExpand)
|
|
448
|
+
if (thinkAutoExpand) {
|
|
449
|
+
setExpanded(running)
|
|
450
|
+
setAutoClosed(!running)
|
|
451
|
+
}
|
|
452
|
+
// A commit that changes this block's height must hand the follower its
|
|
453
|
+
// correction in the SAME task, before the grown-but-uncompensated frame
|
|
454
|
+
// can reach a paint.
|
|
455
|
+
notifyFollowCommit(commitAnchorRef.current)
|
|
434
456
|
}, [running, thinkAutoExpand])
|
|
435
457
|
|
|
436
458
|
useEffect(() => {
|
|
@@ -441,7 +463,7 @@ function AnimatedReasoning({
|
|
|
441
463
|
|
|
442
464
|
// Preserve FollowHost's layout wrapper without mounting a second scroll owner.
|
|
443
465
|
return (
|
|
444
|
-
<div className={css.follow}>
|
|
466
|
+
<div className={css.follow} ref={commitAnchorRef}>
|
|
445
467
|
<div className={css.think} data-variant="think" data-state={running ? 'running' : 'ok'}>
|
|
446
468
|
{running && <span className={css.visuallyHidden}>{t('row.running')}</span>}
|
|
447
469
|
<AnimatedDisclosure
|
|
@@ -452,7 +474,15 @@ function AnimatedReasoning({
|
|
|
452
474
|
icon={<IconThinkOutline14 size={14} />}
|
|
453
475
|
title="Think"
|
|
454
476
|
open={expanded}
|
|
455
|
-
onToggle={() => {
|
|
477
|
+
onToggle={() => {
|
|
478
|
+
setAutoClosed(false)
|
|
479
|
+
setExpanded(value => !value)
|
|
480
|
+
}}
|
|
481
|
+
// The auto-close at stream end snaps (no grid-track animation): the
|
|
482
|
+
// follower's settle spring absorbs the height step through the
|
|
483
|
+
// compositor, so animating the track too would double-animate the
|
|
484
|
+
// collapse. Manual toggles while streaming keep the glide.
|
|
485
|
+
bodyTransition={!autoClosed}
|
|
456
486
|
collapsedContent={(
|
|
457
487
|
<>
|
|
458
488
|
<span className={css.thinkSeparator} aria-hidden />
|
|
@@ -503,6 +533,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
503
533
|
const reduced = usePrefersReducedMotion()
|
|
504
534
|
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
505
535
|
const rootSpeedRef = useRef(35)
|
|
536
|
+
const rootRevealedCharsRef = useRef(0)
|
|
506
537
|
const rootRevealScaleRef = useRef(1)
|
|
507
538
|
const reasoningTailIndex = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
|
|
508
539
|
? data.blocks.length - 1
|
|
@@ -571,6 +602,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
571
602
|
streaming={streaming}
|
|
572
603
|
ownFollow={!streaming && index === lastFollow}
|
|
573
604
|
followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
|
|
605
|
+
followRevealedCharsRef={index === lastFollow ? rootRevealedCharsRef : undefined}
|
|
574
606
|
followRevealScaleRef={index === lastFollow ? rootRevealScaleRef : undefined}
|
|
575
607
|
onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
|
|
576
608
|
preset={preset}
|
|
@@ -628,6 +660,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
628
660
|
<FollowHost
|
|
629
661
|
active={streaming && !reduced}
|
|
630
662
|
speedCpsRef={rootSpeedRef}
|
|
663
|
+
revealedCharsRef={rootRevealedCharsRef}
|
|
631
664
|
revealScaleRef={rootRevealScaleRef}
|
|
632
665
|
predictiveRef={rootPredictiveRef}
|
|
633
666
|
>
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* flowing when background tabs suspend rAF.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { hasRecentConversationFollow } from './teleprompterGlide.ts'
|
|
31
|
+
|
|
30
32
|
const STYLE_ID = 'dshss-auto-collapse-style'
|
|
31
33
|
/** Class of the injected per-turn summary row; the follow engine's generalized
|
|
32
34
|
* surface walk picks it up as an ordinary foreign flow sibling. */
|
|
@@ -174,6 +176,7 @@ export class AutoCollapseController {
|
|
|
174
176
|
/** True between start() and stop(); the preference toggle re-enters both. */
|
|
175
177
|
private running = false
|
|
176
178
|
private lastPassError = ''
|
|
179
|
+
private followDeferredAt: number | null = null
|
|
177
180
|
|
|
178
181
|
private flow: HTMLElement | null = null
|
|
179
182
|
/** Stable segment key → summary row + gesture state. */
|
|
@@ -233,6 +236,7 @@ export class AutoCollapseController {
|
|
|
233
236
|
|
|
234
237
|
stop(): void {
|
|
235
238
|
this.running = false
|
|
239
|
+
this.followDeferredAt = null
|
|
236
240
|
if (this.raf !== 0) cancelAnimationFrame(this.raf)
|
|
237
241
|
this.raf = 0
|
|
238
242
|
if (this.timer !== 0) clearTimeout(this.timer)
|
|
@@ -270,13 +274,26 @@ export class AutoCollapseController {
|
|
|
270
274
|
}, AUDIT_TICK_MS)
|
|
271
275
|
}
|
|
272
276
|
|
|
273
|
-
/**
|
|
277
|
+
/** Only mutations inside the active flow can affect collapse geometry. */
|
|
274
278
|
private shouldSchedule(records: MutationRecord[]): boolean {
|
|
275
279
|
if (records.length === 0 || this.flow === null || !this.flow.isConnected) return true
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
+
const flow = this.flow
|
|
281
|
+
return records.some(record => {
|
|
282
|
+
// The harness Tooltip renders its `position: fixed` bubble as a Fragment
|
|
283
|
+
// sibling INSIDE the message row (not a body portal), so hovering a copy
|
|
284
|
+
// button mounts/unmounts a node within the flow. It never affects
|
|
285
|
+
// collapse geometry, but treating it as relevant made hovering re-run
|
|
286
|
+
// the collapse pass against a streaming flow, racing the follower and
|
|
287
|
+
// producing visible flicker/jitter. Ignore purely-fixed additions and
|
|
288
|
+
// removals; keep every layout-affecting mutation.
|
|
289
|
+
const changed = [...record.addedNodes, ...record.removedNodes]
|
|
290
|
+
if (changed.length > 0 && changed.every(node => isFloatingOverlayNode(node))) return false
|
|
291
|
+
if (record.target === flow || nodeWithin(record.target, flow)) return true
|
|
292
|
+
// Child-list additions/removals can target an ancestor only when the
|
|
293
|
+
// changed node itself is inside the flow; inspect added/removed nodes so
|
|
294
|
+
// a body-level tooltip mutation remains ignored.
|
|
295
|
+
return changed.some(node => nodeWithin(node, flow))
|
|
296
|
+
})
|
|
280
297
|
}
|
|
281
298
|
|
|
282
299
|
/** Walk each record to its flow child so reply-cache invalidation stays
|
|
@@ -389,49 +406,94 @@ export class AutoCollapseController {
|
|
|
389
406
|
this.syncProcessedRow(state)
|
|
390
407
|
}
|
|
391
408
|
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
409
|
+
// Start the deadline only after a completed segment actually exists.
|
|
410
|
+
// Opening it during a long-running stream exhausts the full two seconds
|
|
411
|
+
// before completion and lets the fold collide with the final reveal drain.
|
|
412
|
+
const portNow = flow.closest<HTMLElement>('[data-conversation-scroll]')
|
|
413
|
+
if (
|
|
414
|
+
completedKeys.size > 0
|
|
415
|
+
&& portNow !== null
|
|
416
|
+
&& hasRecentConversationFollow(portNow, 140)
|
|
417
|
+
) {
|
|
418
|
+
const now = Date.now()
|
|
419
|
+
this.followDeferredAt ??= now
|
|
420
|
+
if (now - this.followDeferredAt < 2000) {
|
|
421
|
+
this.schedule()
|
|
422
|
+
return
|
|
423
|
+
}
|
|
398
424
|
}
|
|
425
|
+
this.followDeferredAt = null
|
|
399
426
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
427
|
+
// Every layout-affecting decision below lands synchronously: one pass,
|
|
428
|
+
// one reflow, no deferred or animated geometry.
|
|
429
|
+
//
|
|
430
|
+
// Folding moves flow height in one commit. A reader pinned to the floor
|
|
431
|
+
// would see the whole column translate unless scrollTop re-pins to the
|
|
432
|
+
// new floor in the same frame — the host's own ResizeObserver snap fires
|
|
433
|
+
// only for RESIZES it observes and can lose the race to paint. Capture
|
|
434
|
+
// the pin state before the mutations and restore it after.
|
|
435
|
+
const port = flow.closest<HTMLElement>('[data-conversation-scroll]')
|
|
436
|
+
const beforeFloor = Math.max(0, (port?.scrollHeight ?? 0) - (port?.clientHeight ?? 0))
|
|
437
|
+
const pinnedToFloor = port !== null && beforeFloor - port.scrollTop <= 30
|
|
438
|
+
// Anchor on the LAST VISIBLE MESSAGE SURFACE rather than the scroll floor:
|
|
439
|
+
// the floor moves with every height change above AND below the fold point,
|
|
440
|
+
// while the reader's eye is on the answer text. Compensating by this
|
|
441
|
+
// surface's viewport delta pins exactly what the reader is looking at,
|
|
442
|
+
// whatever the fold removed or inserted around it.
|
|
443
|
+
const anchorBefore = lastVisibleAnchorTop(flow)
|
|
444
|
+
|
|
445
|
+
const applyMutations = (): void => {
|
|
446
|
+
for (const [key, state] of [...this.segmentStates]) {
|
|
447
|
+
if (completedKeys.has(key)) continue
|
|
448
|
+
state.row?.remove()
|
|
449
|
+
this.segmentStates.delete(key)
|
|
414
450
|
}
|
|
415
|
-
}
|
|
416
451
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// but keep owning whatever they still hold so the sweep below leaves it.
|
|
420
|
-
for (const segment of segments) {
|
|
421
|
-
if (!hasVisibleSegmentWork(segment)) {
|
|
452
|
+
const desiredHidden = new Set<HTMLElement>()
|
|
453
|
+
for (const segment of segments) {
|
|
422
454
|
const state = this.segmentStates.get(segment.key)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
455
|
+
const collapse = state !== undefined && !state.expanded
|
|
456
|
+
if (collapse) {
|
|
457
|
+
for (const seat of segment.hideSeats) this.hideElement(seat, desiredHidden)
|
|
458
|
+
for (const row of segment.finalThinkRows) this.hideElement(row, desiredHidden)
|
|
459
|
+
// The final answer itself always shows; its thinking rows were hidden
|
|
460
|
+
// above, everything else about it is restored defensively.
|
|
461
|
+
if (segment.finalStep !== null) this.restoreElement(segment.finalStep)
|
|
462
|
+
} else {
|
|
463
|
+
for (const seat of segment.hideSeats) this.restoreElement(seat)
|
|
464
|
+
for (const row of segment.finalThinkRows) this.restoreElement(row)
|
|
465
|
+
if (segment.finalStep !== null) this.restoreElement(segment.finalStep)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Segments whose work is already invisible (hidden by the host or another
|
|
470
|
+
// extension) withdraw their summary row instead of pointing at nothing,
|
|
471
|
+
// but keep owning whatever they still hold so the sweep below leaves it.
|
|
472
|
+
for (const segment of segments) {
|
|
473
|
+
if (!hasVisibleSegmentWork(segment)) {
|
|
474
|
+
const state = this.segmentStates.get(segment.key)
|
|
475
|
+
if (state !== undefined && state.row !== null) {
|
|
476
|
+
state.row.remove()
|
|
477
|
+
state.row = null
|
|
478
|
+
}
|
|
426
479
|
}
|
|
480
|
+
for (const seat of segment.hideSeats) this.retainDisplayControl(seat, desiredHidden)
|
|
481
|
+
for (const row of segment.finalThinkRows) this.retainDisplayControl(row, desiredHidden)
|
|
482
|
+
if (segment.finalStep !== null) this.retainDisplayControl(segment.finalStep, desiredHidden)
|
|
427
483
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
484
|
+
|
|
485
|
+
this.restoreUnusedDisplays(desiredHidden)
|
|
486
|
+
for (const state of this.segmentStates.values()) this.placeProcessedRow(flow, state)
|
|
431
487
|
}
|
|
488
|
+
applyMutations()
|
|
432
489
|
|
|
433
|
-
|
|
434
|
-
|
|
490
|
+
if (port !== null && pinnedToFloor && anchorBefore !== null) {
|
|
491
|
+
const anchorAfter = lastVisibleAnchorTop(flow)
|
|
492
|
+
if (anchorAfter !== null) {
|
|
493
|
+
const delta = anchorAfter - anchorBefore
|
|
494
|
+
if (Math.abs(delta) > 0.5) port.scrollTop += delta
|
|
495
|
+
}
|
|
496
|
+
}
|
|
435
497
|
|
|
436
498
|
for (const key of [...this.runningSince.keys()]) {
|
|
437
499
|
if (!liveSegmentKeys.has(key)) this.runningSince.delete(key)
|
|
@@ -622,6 +684,24 @@ function nodeWithin(node: Node, ancestor: Node): boolean {
|
|
|
622
684
|
return false
|
|
623
685
|
}
|
|
624
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Whether a mutation-added/removed node is a `position: fixed` overlay (the
|
|
689
|
+
* harness Tooltip bubble / a popover) that never affects the flow's collapse
|
|
690
|
+
* geometry. The primitives Tooltip mounts its fixed bubble as a Fragment
|
|
691
|
+
* sibling inside the message row — `position: fixed` arrives from its CSS
|
|
692
|
+
* module class, not inline style — so it appears within `[data-chat-flow]`
|
|
693
|
+
* even though it is viewport-fixed and layout-neutral. Matching on the
|
|
694
|
+
* tooltip's structural role instead of computed style keeps the filter
|
|
695
|
+
* reliable before the node has been laid out.
|
|
696
|
+
*/
|
|
697
|
+
function isFloatingOverlayNode(node: Node): boolean {
|
|
698
|
+
if (!(node instanceof HTMLElement)) return false
|
|
699
|
+
if (node.getAttribute('role') === 'tooltip') return true
|
|
700
|
+
if (node.style.position === 'fixed') return true
|
|
701
|
+
if (node.style.position === 'absolute' && node.parentElement?.getAttribute('role') === 'tooltip') return true
|
|
702
|
+
return false
|
|
703
|
+
}
|
|
704
|
+
|
|
625
705
|
/** Top-level message order, excluding this plugin's own summary rows. */
|
|
626
706
|
function flowItems(flow: HTMLElement): HTMLElement[] {
|
|
627
707
|
return [...flow.children].filter((el): el is HTMLElement => (
|
|
@@ -862,6 +942,21 @@ function hasBodyText(el: HTMLElement): boolean {
|
|
|
862
942
|
* tails only carry an end timestamp, which is diffed against the opening user
|
|
863
943
|
* row's timestamp.
|
|
864
944
|
*/
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Viewport-top of the last visible anchored message row, or null when none is
|
|
948
|
+
* rendered. Used to pin the reader's visual anchor across a fold commit.
|
|
949
|
+
*/
|
|
950
|
+
function lastVisibleAnchorTop(flow: HTMLElement): number | null {
|
|
951
|
+
const rows = flow.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')
|
|
952
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
953
|
+
const row = rows[index]!
|
|
954
|
+
if (row.getClientRects().length === 0) continue
|
|
955
|
+
return row.getBoundingClientRect().top
|
|
956
|
+
}
|
|
957
|
+
return null
|
|
958
|
+
}
|
|
959
|
+
|
|
865
960
|
function parseTurnDuration(boundary: HTMLElement): number | undefined {
|
|
866
961
|
const text = boundary.textContent ?? ''
|
|
867
962
|
const zh = text.match(/用时\s*(\d+)分(\d+)秒|用时\s*(\d+)秒/)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kernel compatibility for the client store engine.
|
|
3
|
+
*
|
|
4
|
+
* DSH 0.1.2 split the snapshot store out of `@deepseek-ai/dsh-client-runtime`
|
|
5
|
+
* into a bare `@deepseek-ai/dsh-client-store` module-table seed, while ≤ 0.1.1
|
|
6
|
+
* kernels keep it on the client-runtime client face. The client module table
|
|
7
|
+
* only answers what the booting kernel actually ships, so the face must be
|
|
8
|
+
* probed at runtime: a static require of either specifier kills the whole
|
|
9
|
+
* loader tree on the other generation (issue #17: a top-level
|
|
10
|
+
* `require("@deepseek-ai/dsh-client-runtime/client")` took down the web UI on
|
|
11
|
+
* DSH Desktop 2.0.4 / kernel 0.1.2-alpha.1).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type ClientRuntimeClient = typeof import('@deepseek-ai/dsh-client-runtime/client')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runtime require: the module-loader factory scopes its own resolver over the
|
|
18
|
+
* booting kernel's table; under Node (vitest) there is no global require, so
|
|
19
|
+
* fall back to `createRequire`.
|
|
20
|
+
*/
|
|
21
|
+
function pickRequire(): NodeRequire {
|
|
22
|
+
if (typeof require === 'function') return require
|
|
23
|
+
if (typeof process !== 'undefined' && typeof process.getBuiltinModule === 'function') {
|
|
24
|
+
const { createRequire } = process.getBuiltinModule('node:module')
|
|
25
|
+
return createRequire(process.cwd() + '/package.json')
|
|
26
|
+
}
|
|
27
|
+
throw new TypeError('dsh-smooth-stream: no client module resolver available')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let clientStore: ClientRuntimeClient
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// 0.1.2+ seeds the split store package directly into the module table.
|
|
34
|
+
clientStore = pickRequire()('@deepseek-ai/dsh-client-store') as ClientRuntimeClient
|
|
35
|
+
} catch {
|
|
36
|
+
// ≤ 0.1.1 owns the store engine on the client-runtime client face.
|
|
37
|
+
clientStore = pickRequire()('@deepseek-ai/dsh-client-runtime/client') as ClientRuntimeClient
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const createSnapshotStore = clientStore.createSnapshotStore
|
|
41
|
+
export type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* while panel edits are staged through the settings-card controller.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { createSnapshotStore, type SnapshotStore } from '
|
|
9
|
+
import { createSnapshotStore, type SnapshotStore } from './clientStore.ts'
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_STREAM_DEBUG_TUNING,
|
|
12
12
|
type StreamDebugTuning,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Staged form state for the plugin-owned smooth-stream settings RPC. */
|
|
2
2
|
|
|
3
|
-
import { createSnapshotStore, type SnapshotStore } from '
|
|
3
|
+
import { createSnapshotStore, type SnapshotStore } from './clientStore.ts'
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_STREAM_DEBUG_TUNING,
|
|
6
6
|
DEFAULT_STREAM_SETTINGS,
|