dsh-smooth-stream 0.4.0 → 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/lib/index.js CHANGED
@@ -174,7 +174,7 @@ const DEFAULT_STREAM_SETTINGS = {
174
174
  springStiffness: 130,
175
175
  springDamping: 24,
176
176
  springMass: 1,
177
- runwayPx: 48,
177
+ runwayPx: 72,
178
178
  reserveResponseMs: 180,
179
179
  backpressureMinScale: .55
180
180
  }
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.4.0",
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
- "dependencies": {
81
- "@deepseek-ai/dsh-settings": "0.1.0-rc.6"
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 className={css.disclosureContent} data-disclosure-content data-collapsed={open ? undefined : ''}>
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 nextHeight = entries[0]?.contentRect.height ?? root.getBoundingClientRect().height
56
- if (!Number.isFinite(nextHeight)) return
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
- onGrowth(nextHeight - previousHeight)
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 () => { observer.disconnect() }
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
- .root :is(p, h1, h2, h3, h4, h5, h6, li, blockquote) {
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) setExpanded(running)
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={() => { setExpanded(value => !value) }}
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,10 +27,12 @@
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
- /** Class of the injected per-turn summary row; also consumed by the follow
32
- * engine so the row rides the same lag transform as native message rows. */
33
- export const PROCESSED_CLASS = 'dshss-processed'
33
+ /** Class of the injected per-turn summary row; the follow engine's generalized
34
+ * surface walk picks it up as an ordinary foreign flow sibling. */
35
+ const PROCESSED_CLASS = 'dshss-processed'
34
36
  /** Inline custom property marking elements whose display this plugin owns. */
35
37
  const DISPLAY_OWNED_PROP = '--dshss-display-owned'
36
38
  /** Unobservable external style writes are reconciled by this audit cadence. */
@@ -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
- /** Body-level observations only need to wake us up near the active flow. */
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
- return records.some(record => (
277
- nodeWithin(record.target, this.flow as HTMLElement)
278
- || nodeWithin(this.flow as HTMLElement, record.target)
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
- // Every layout-affecting decision below lands synchronously: one pass,
393
- // one reflow, no deferred or animated geometry.
394
- for (const [key, state] of [...this.segmentStates]) {
395
- if (completedKeys.has(key)) continue
396
- state.row?.remove()
397
- this.segmentStates.delete(key)
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
- const desiredHidden = new Set<HTMLElement>()
401
- for (const segment of segments) {
402
- const state = this.segmentStates.get(segment.key)
403
- const collapse = state !== undefined && !state.expanded
404
- if (collapse) {
405
- for (const seat of segment.hideSeats) this.hideElement(seat, desiredHidden)
406
- for (const row of segment.finalThinkRows) this.hideElement(row, desiredHidden)
407
- // The final answer itself always shows; its thinking rows were hidden
408
- // above, everything else about it is restored defensively.
409
- if (segment.finalStep !== null) this.restoreElement(segment.finalStep)
410
- } else {
411
- for (const seat of segment.hideSeats) this.restoreElement(seat)
412
- for (const row of segment.finalThinkRows) this.restoreElement(row)
413
- if (segment.finalStep !== null) this.restoreElement(segment.finalStep)
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
- // Segments whose work is already invisible (hidden by the host or another
418
- // extension) withdraw their summary row instead of pointing at nothing,
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
- if (state !== undefined && state.row !== null) {
424
- state.row.remove()
425
- state.row = null
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
- for (const seat of segment.hideSeats) this.retainDisplayControl(seat, desiredHidden)
429
- for (const row of segment.finalThinkRows) this.retainDisplayControl(row, desiredHidden)
430
- if (segment.finalStep !== null) this.retainDisplayControl(segment.finalStep, desiredHidden)
484
+
485
+ this.restoreUnusedDisplays(desiredHidden)
486
+ for (const state of this.segmentStates.values()) this.placeProcessedRow(flow, state)
431
487
  }
488
+ applyMutations()
432
489
 
433
- this.restoreUnusedDisplays(desiredHidden)
434
- for (const state of this.segmentStates.values()) this.placeProcessedRow(flow, state)
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 '@deepseek-ai/dsh-client-runtime/client'
9
+ import { createSnapshotStore, type SnapshotStore } from './clientStore.ts'
10
10
  import {
11
11
  DEFAULT_STREAM_DEBUG_TUNING,
12
12
  type StreamDebugTuning,