dsh-smooth-stream 0.3.3 → 0.3.4

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 CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  English | [中文](README.md)
4
4
 
5
- [![featured on dsh-suite](https://img.shields.io/badge/featured%20on-dsh--suite-4d6bfe)](https://whyihaveyou.github.io/dsh-suite/)
6
-
7
- **dsh-smooth-stream** is a [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) community plugin for **silky streaming** in the Web UI: arrival-tracking typewriter reveal, glide-in wraps, no flicker. It is not part of the official DeepSeek distribution.
5
+ **dsh-smooth-stream** brings fluid streaming rendering and silky scrolling to the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) Web UI. Text, Markdown, code blocks, tables, and tool results appear as the reply arrives; as the content grows, the page follows along in one continuous visual rhythm.
8
6
 
9
7
  Project homepage: <https://laplace-bit.github.io/dsh-smooth-stream/>
10
8
 
@@ -14,17 +12,12 @@ Left: default Web UI. Right: dsh-smooth-stream.
14
12
 
15
13
  ![Left: without the plugin. Right: with dsh-smooth-stream.](docs/compare.gif)
16
14
 
17
- ## Current behavior
15
+ ## Core experience
18
16
 
19
- - **The whole Agent turn uses one extensible pipeline.** Assistant text, Think, Context, Retry, Command, Bash, Glob, Read, tool calls, and newly registered renderers all enter progressive reveal and bottom-follow through the same boundary, without a tool-name allowlist.
20
- - **Reveal speed adapts to queue pressure.** Small updates keep a soft cadence while large or fast bursts catch up promptly. Once the producer completes, remaining source is committed immediately instead of continuing to type long after the Agent has stopped.
21
- - **Markdown remains mounted throughout streaming.** Code blocks, tables, emphasis, and other formatting do not begin as plain text and later swap trees. Historical messages also do not replay their reveal animation when remounted.
22
- - **Scroll room opens only when a wrap is actually likely.** The predictor combines buffered source with the current line's remaining width before opening its runway. Long replies still absorb line wraps smoothly, while a short same-line answer after Think does not pre-scroll and rebound.
23
- - **Conversation chrome stays fixed.** `Deep diving...`, the composer, and the to-bottom button never ride the message transform. Fast output and low-frame-rate catch-up cannot paint through the status row or disappear behind the composer.
24
- - **One continuous spring owns motion.** The engine carries velocity and displacement between frames instead of repeatedly starting native smooth-scroll calls. Line wraps, code, tables, and growing tool rows converge along the same trajectory; completion lands on the natural floor and retires temporary state without a flash or overshoot rebound.
25
- - **Reader input wins immediately.** A small upward wheel, touch, or keyboard gesture releases automatic follow. Ownership returns only after the reader actually reaches the bottom again.
26
- - **Think respects the user's preference.** With auto-expand enabled it keeps the Harness disclosure interaction and collapses when thinking ends. With it disabled, collapsed reasoning can keep updating without fake height motion, and a manual toggle is not wrestled back by stream state.
27
- - **Performance guards preserve the final position.** `prefers-reduced-motion` shows complete content without taking follow. Off-screen DOM commits pause under low FPS, then catch up under control and still finish exactly at the bottom.
17
+ - **Fluid rendering.** Text appears as it arrives while Markdown structure stays active, keeping headings, lists, code blocks, and tables readable throughout the stream.
18
+ - **Silky scrolling.** As the content grows, the page follows along one continuous scroll path and keeps the reader close to the generated output.
19
+ - **Consistent transitions.** Line wraps, code blocks, tables, and tool results use the same motion treatment, so text, reasoning, and tools flow together.
20
+ - **Adaptive cadence.** Reveal speed responds to arrival rate and pending content, staying measured for slow output and catching up with fast output.
28
21
 
29
22
  ## Install
30
23
 
@@ -89,7 +82,7 @@ dsh plugin --profile web update dsh-smooth-stream
89
82
  ## FAQ
90
83
 
91
84
  **Is this an official DeepSeek plugin?**
92
- No. It is a community plugin for the DeepSeek Harness (`dsh`) Web UI, MIT-licensed, and not part of the official DeepSeek distribution.
85
+ No. It is independently maintained, MIT-licensed software for the DeepSeek Harness (`dsh`) Web UI and is not affiliated with DeepSeek.
93
86
 
94
87
  **How do I install a DeepSeek Harness plugin?**
95
88
  Use the built-in plugin command: `dsh plugin --profile web add dsh-smooth-stream` from a dsh source checkout (see [Install](#install)).
@@ -100,6 +93,8 @@ Yes — `dsh-smooth-stream` is published to [npm](https://www.npmjs.com/package/
100
93
  **Does it respect `prefers-reduced-motion`?**
101
94
  Yes. With reduced motion enabled the finished text is shown at once and the plugin does not take over follow. If the frame rate drops below 30 fps while the reply is off-screen, reveal pauses and catches up later.
102
95
 
96
+ [![featured on dsh-suite](https://img.shields.io/badge/featured%20on-dsh--suite-4d6bfe)](https://whyihaveyou.github.io/dsh-suite/)
97
+
103
98
  ## License
104
99
 
105
100
  [MIT](LICENSE)
package/README.md CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  [English](README.en.md) | 中文
4
4
 
5
- [![featured on dsh-suite](https://img.shields.io/badge/featured%20on-dsh--suite-4d6bfe)](https://whyihaveyou.github.io/dsh-suite/)
6
-
7
- **dsh-smooth-stream** 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)的社区插件,给 Web 对话做**丝滑流式渲染**:字跟着模型走、换行滑入、不闪。不是官方发行的一部分。
5
+ **dsh-smooth-stream** 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)Web UI 带来流畅的流式渲染和丝滑滚动。文字、Markdown、代码块、表格以及工具结果会随着输出自然呈现;内容逐步增长时,页面平稳跟随,整轮回复保持连贯的视觉节奏。
8
6
 
9
7
  项目主页:<https://laplace-bit.github.io/dsh-smooth-stream/>
10
8
 
@@ -14,17 +12,12 @@
14
12
 
15
13
  ![左:未使用插件。右:使用 dsh-smooth-stream。](docs/compare.gif)
16
14
 
17
- ## 当前实现
15
+ ## 核心体验
18
16
 
19
- - **整轮 Agent 输出统一接管。** 助手正文、Think、Context、Retry、Command、Bash、Glob、Read、工具调用以及后续注册的新渲染器,都通过同一个可扩展入口进入渐进揭示和底部跟随,不依赖工具名称白名单。
20
- - **吐字速度会跟随积压自动变速。** 小批内容保持柔和节拍,大批或高速到达时及时追赶;完成信号到达后立即提交剩余正文,不让 Agent 已结束而文字还长时间继续输出。
21
- - **流式过程始终使用 Markdown 渲染。** 代码块、表格、强调等不会先显示成纯文本再整体换树,历史消息也不会在重新挂载时重播动画。
22
- - **换行前才准备滚动空间。** 引擎结合待揭示内容和当前行剩余宽度判断是否可能增高,只在真实换行风险出现时打开预测 runway。长回复仍能平滑吸收换行,Think 后的短同一行正文不会多滚再回弹。
23
- - **状态区域保持稳定。** `Deep diving...`、输入框和「滚动到底部」按钮不参与消息 transform;高速输出或低帧率下,正文也不会越过状态区域或藏到输入框后面。
24
- - **滚动使用持续弹簧而不是反复启动原生平滑滚动。** 每帧保留速度和位移状态,换行、代码块、表格及工具卡片增高都沿同一轨迹收敛;结束时落在自然底部并安静撤销临时状态,不闪烁、不越位回弹。
25
- - **用户输入拥有最高优先级。** 向上滚轮、触控拖动或键盘滚动会在轻微手势时立即解除自动跟随;只有用户真正回到底部后才重新接管。
26
- - **Think 尊重用户设置。** 自动展开开启时沿用 Harness 的 disclosure 交互并在思考结束后收起;关闭时折叠内容持续更新但不引发虚假高度和上下闪动,手动展开也不会被流状态抢回。
27
- - **性能保护不会破坏最终状态。** `prefers-reduced-motion` 直接显示完整内容且不接管跟随;低帧率且回复在屏外时暂停 DOM 提交,恢复后受控追赶,最终仍准确停在底部。
17
+ - **流畅渲染。** 文字边到边呈现,Markdown 结构持续更新,标题、列表、代码块和表格在流式过程中保持自然的阅读状态。
18
+ - **丝滑滚动。** 内容逐步变高时,页面沿连续的滚动轨迹平稳跟随,视线始终贴着正在生成的内容。
19
+ - **统一过渡。** 换行、代码块、表格和工具结果使用一致的过渡方式,正文、思考过程与工具输出衔接自然。
20
+ - **自适应节奏。** 渲染速度会根据输出速度和待显示内容调整,让慢速输出从容呈现,快速输出及时跟上。
28
21
 
29
22
  ## 安装
30
23
 
@@ -89,7 +82,7 @@ dsh plugin --profile web update dsh-smooth-stream
89
82
  ## 常见问题
90
83
 
91
84
  **这是 DeepSeek 官方插件吗?**
92
- 不是。它是 DeepSeek Harness(`dsh`)Web UI 的社区插件,MIT 协议开源,不属于 DeepSeek 官方发行。
85
+ 不是。它是面向 DeepSeek Harness(`dsh`)Web UI 的独立维护项目,采用 MIT 许可证,和 DeepSeek 没有从属关系。
93
86
 
94
87
  **dsh 插件怎么安装?**
95
88
  用内置插件命令:在 dsh 源码目录运行 `dsh plugin --profile web add dsh-smooth-stream`(见[安装](#安装))。
@@ -100,6 +93,8 @@ dsh plugin --profile web update dsh-smooth-stream
100
93
  **支持 `prefers-reduced-motion` 吗?**
101
94
  支持。系统开启减少动态效果时直接显示完整文本、不接管跟随;帧率低于 30 fps 且回复在屏外时,揭示自动暂停、恢复后再补上。
102
95
 
96
+ [![featured on dsh-suite](https://img.shields.io/badge/featured%20on-dsh--suite-4d6bfe)](https://whyihaveyou.github.io/dsh-suite/)
97
+
103
98
  ## 许可证
104
99
 
105
100
  [MIT](LICENSE)
package/lib/client.js CHANGED
@@ -23,27 +23,27 @@ window.__ModuleLoader__.load({
23
23
  tag.textContent = css$1;
24
24
  }
25
25
  var TypewriterAssistantNodeView_module_css_default = {
26
- "disclosureRoot": "I17U7q_disclosureRoot",
27
- "root": "I17U7q_root",
26
+ "thinkBody": "I17U7q_thinkBody",
27
+ "body": "I17U7q_body",
28
28
  "thinkRow": "I17U7q_thinkRow",
29
- "thinkChevron": "I17U7q_thinkChevron",
30
- "disclosureRow": "I17U7q_disclosureRow",
29
+ "thinkLeading": "I17U7q_thinkLeading",
31
30
  "disclosureLeading": "I17U7q_disclosureLeading",
32
- "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
33
31
  "disclosureTitle": "I17U7q_disclosureTitle",
34
- "disclosureIconIdle": "I17U7q_disclosureIconIdle",
35
- "thinkSeparator": "I17U7q_thinkSeparator",
36
- "thinkSummary": "I17U7q_thinkSummary",
37
32
  "follow": "I17U7q_follow",
33
+ "disclosureRoot": "I17U7q_disclosureRoot",
34
+ "disclosureIconIdle": "I17U7q_disclosureIconIdle",
38
35
  "thinkTitle": "I17U7q_thinkTitle",
39
- "disclosureContent": "I17U7q_disclosureContent",
40
- "thinkBody": "I17U7q_thinkBody",
41
- "thinkLeading": "I17U7q_thinkLeading",
36
+ "disclosureRow": "I17U7q_disclosureRow",
42
37
  "disclosureChevronHover": "I17U7q_disclosureChevronHover",
43
- "stopped": "I17U7q_stopped",
44
38
  "visuallyHidden": "I17U7q_visuallyHidden",
45
- "body": "I17U7q_body",
46
- "think": "I17U7q_think"
39
+ "thinkSeparator": "I17U7q_thinkSeparator",
40
+ "root": "I17U7q_root",
41
+ "think": "I17U7q_think",
42
+ "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
43
+ "thinkChevron": "I17U7q_thinkChevron",
44
+ "stopped": "I17U7q_stopped",
45
+ "thinkSummary": "I17U7q_thinkSummary",
46
+ "disclosureContent": "I17U7q_disclosureContent"
47
47
  };
48
48
  //#endregion
49
49
  //#region src/client/AnimatedDisclosure.tsx
@@ -667,7 +667,15 @@ window.__ModuleLoader__.load({
667
667
  }
668
668
  return height;
669
669
  }
670
- const followRunways = /* @__PURE__ */ new WeakMap();
670
+ /**
671
+ * The plugin can be reinjected without replacing the conversation DOM. Keep
672
+ * runway ownership in the page realm so a fresh bundle adopts the existing
673
+ * margin instead of treating it as host layout and adding another 48px.
674
+ */
675
+ const FOLLOW_RUNWAYS_SYMBOL = Symbol.for("dsh-smooth-stream.follow-runways");
676
+ const followRunwayRegistry = globalThis;
677
+ const followRunways = followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL] ?? /* @__PURE__ */ new WeakMap();
678
+ followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL] = followRunways;
671
679
  const followPaintLimits = /* @__PURE__ */ new WeakMap();
672
680
  function invalidatePaintLimit(port) {
673
681
  followPaintLimits.delete(port);
@@ -681,9 +689,39 @@ window.__ModuleLoader__.load({
681
689
  followRunways.delete(port);
682
690
  invalidatePaintLimit(port);
683
691
  }
692
+ function isLegacyRunway(value) {
693
+ if (value === "") return false;
694
+ const terms = [...value.matchAll(/([\d.]+)px/g)];
695
+ if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, "") !== "") return false;
696
+ return terms.every(([, raw]) => {
697
+ const px = Number(raw);
698
+ return Number.isFinite(px) && px >= 48 && Math.abs(px % 48) <= Number.EPSILON;
699
+ });
700
+ }
701
+ /** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
702
+ function migrateLegacyRunway(port, surfaces, status, composer) {
703
+ if (followRunways.has(port)) return;
704
+ let migrated = false;
705
+ if (status !== null && isLegacyRunway(status.style.marginTop)) {
706
+ status.style.marginTop = "";
707
+ migrated = true;
708
+ }
709
+ const last = surfaces.at(-1);
710
+ if (status === null && composer !== null && last !== void 0 && isLegacyRunway(last.style.marginBottom)) {
711
+ last.style.marginBottom = "";
712
+ migrated = true;
713
+ }
714
+ if (migrated) invalidatePaintLimit(port);
715
+ }
684
716
  function ensureRunway(port, surfaces) {
685
717
  const status = turnStatusOf(port);
686
718
  const composer = port.querySelector("[data-composer-seat]");
719
+ migrateLegacyRunway(port, surfaces, status, composer);
720
+ const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port));
721
+ if (port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
722
+ restoreRunway(port);
723
+ return;
724
+ }
687
725
  const target = status === null ? {
688
726
  element: composer === null ? void 0 : surfaces.at(-1),
689
727
  property: "marginBottom"
@@ -1258,6 +1296,8 @@ window.__ModuleLoader__.load({
1258
1296
  }
1259
1297
  /** Conservative fallback before the streaming Markdown tail has geometry. */
1260
1298
  const PREDICTIVE_WRAP_FALLBACK_CHARS = 32;
1299
+ const STREAM_ANNOUNCEMENT_INTERVAL_MS = 800;
1300
+ const STREAM_ANNOUNCEMENT_MAX_CHARS = 320;
1261
1301
  function approximateInlineWidth(text, emPx) {
1262
1302
  let width = 0;
1263
1303
  for (const char of text) if (/\s/u.test(char)) width += emPx * .33;
@@ -1265,36 +1305,189 @@ window.__ModuleLoader__.load({
1265
1305
  else width += emPx;
1266
1306
  return width;
1267
1307
  }
1268
- /** Whether buffered source can reach a new visual line before it drains. */
1269
- function pendingTextCanGrow(root, pending) {
1270
- if (pending === "") return false;
1271
- if (/[\r\n]/u.test(pending)) return true;
1272
- const pendingChars = [...pending];
1273
- if (root === null || typeof document.createTreeWalker !== "function" || typeof NodeFilter === "undefined") return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS;
1308
+ function measurePendingTextGeometry(root, visibleText) {
1309
+ if (typeof document.createTreeWalker !== "function" || typeof NodeFilter === "undefined") return {
1310
+ root,
1311
+ visibleText,
1312
+ fontSize: 14,
1313
+ wrapThresholdWidth: null
1314
+ };
1274
1315
  const rootRect = root.getBoundingClientRect();
1275
1316
  const rootWidth = Math.max(0, rootRect.width, rootRect.right - rootRect.left, root.clientWidth);
1276
- if (rootWidth <= 0) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS;
1317
+ if (rootWidth <= 0) return {
1318
+ root,
1319
+ visibleText,
1320
+ fontSize: 14,
1321
+ wrapThresholdWidth: null
1322
+ };
1277
1323
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1278
1324
  let tail = null;
1279
1325
  for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) if ((node.textContent ?? "").length > 0) tail = node;
1280
1326
  const parent = tail?.parentElement ?? root;
1281
1327
  const fontSize = Number.parseFloat(getComputedStyle(parent).fontSize) || 14;
1282
- const pendingWidth = approximateInlineWidth(pending, fontSize);
1283
- if (tail === null || typeof document.createRange !== "function") return pendingWidth >= rootWidth;
1328
+ if (tail === null || typeof document.createRange !== "function") return {
1329
+ root,
1330
+ visibleText,
1331
+ fontSize,
1332
+ wrapThresholdWidth: rootWidth
1333
+ };
1284
1334
  try {
1285
1335
  const length = tail.textContent?.length ?? 0;
1286
- if (length <= 0) return pendingWidth >= rootWidth;
1336
+ if (length <= 0) return {
1337
+ root,
1338
+ visibleText,
1339
+ fontSize,
1340
+ wrapThresholdWidth: rootWidth
1341
+ };
1287
1342
  const range = document.createRange();
1288
1343
  range.setStart(tail, Math.max(0, length - 1));
1289
1344
  range.setEnd(tail, length);
1290
1345
  const tailRect = range.getBoundingClientRect();
1291
1346
  const contentRight = rootRect.right;
1292
- if (!Number.isFinite(tailRect.right) || tailRect.right <= rootRect.left || contentRight <= rootRect.left) return pendingWidth >= rootWidth;
1293
- return pendingWidth >= Math.max(0, contentRight - tailRect.right) + fontSize * .35;
1347
+ if (!Number.isFinite(tailRect.right) || tailRect.right <= rootRect.left || contentRight <= rootRect.left) return {
1348
+ root,
1349
+ visibleText,
1350
+ fontSize,
1351
+ wrapThresholdWidth: rootWidth
1352
+ };
1353
+ return {
1354
+ root,
1355
+ visibleText,
1356
+ fontSize,
1357
+ wrapThresholdWidth: Math.max(0, contentRight - tailRect.right) + fontSize * .35
1358
+ };
1294
1359
  } catch {
1295
- return pendingWidth >= rootWidth;
1360
+ return {
1361
+ root,
1362
+ visibleText,
1363
+ fontSize,
1364
+ wrapThresholdWidth: rootWidth
1365
+ };
1296
1366
  }
1297
1367
  }
1368
+ /** Whether buffered source can reach a new visual line before it drains. */
1369
+ function pendingTextCanGrow(root, visibleText, pending, geometryRef) {
1370
+ if (pending === "") return false;
1371
+ if (/[\r\n]/u.test(pending)) return true;
1372
+ const pendingChars = [...pending];
1373
+ if (root === null) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS;
1374
+ let geometry = geometryRef.current;
1375
+ if (geometry?.root !== root || geometry.visibleText !== visibleText) {
1376
+ geometry = measurePendingTextGeometry(root, visibleText);
1377
+ geometryRef.current = geometry;
1378
+ }
1379
+ if (geometry.wrapThresholdWidth === null) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS;
1380
+ return approximateInlineWidth(pending, geometry.fontSize) >= geometry.wrapThresholdWidth;
1381
+ }
1382
+ function announcementChunkEnd(source, start) {
1383
+ const hardEnd = Math.min(source.length, start + STREAM_ANNOUNCEMENT_MAX_CHARS);
1384
+ if (hardEnd === source.length) return hardEnd;
1385
+ const softStart = start + Math.floor(STREAM_ANNOUNCEMENT_MAX_CHARS * .6);
1386
+ for (let index = hardEnd - 1; index >= softStart; index -= 1) if (/[\s.,;:!?]/u.test(source[index] ?? "")) return index + 1;
1387
+ return hardEnd;
1388
+ }
1389
+ /** A commit-driven live region isolated from the visible Markdown subtree. */
1390
+ const StreamAnnouncement = (0, react.memo)(function StreamAnnouncement({ text, active }) {
1391
+ const [announcement, setAnnouncement] = (0, react.useState)({
1392
+ text: "",
1393
+ revision: 0,
1394
+ present: active
1395
+ });
1396
+ const sourceRef = (0, react.useRef)(text);
1397
+ const activeRef = (0, react.useRef)(active);
1398
+ const announcedOffsetRef = (0, react.useRef)(active ? 0 : text.length);
1399
+ const drainSourceRef = (0, react.useRef)(null);
1400
+ const timerRef = (0, react.useRef)(null);
1401
+ (0, react.useEffect)(() => {
1402
+ const clearTimer = () => {
1403
+ if (timerRef.current === null) return;
1404
+ clearTimeout(timerRef.current);
1405
+ timerRef.current = null;
1406
+ };
1407
+ const publishNext = (source) => {
1408
+ const start = Math.min(announcedOffsetRef.current, source.length);
1409
+ const end = announcementChunkEnd(source, start);
1410
+ if (end <= start) return false;
1411
+ announcedOffsetRef.current = end;
1412
+ setAnnouncement((previous) => ({
1413
+ text: source.slice(start, end),
1414
+ revision: previous.revision + 1,
1415
+ present: true
1416
+ }));
1417
+ return end < source.length;
1418
+ };
1419
+ const hideAfterLinger = () => {
1420
+ timerRef.current = setTimeout(() => {
1421
+ timerRef.current = null;
1422
+ if (activeRef.current) return;
1423
+ drainSourceRef.current = null;
1424
+ setAnnouncement((previous) => ({
1425
+ ...previous,
1426
+ present: false
1427
+ }));
1428
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS);
1429
+ };
1430
+ const drainNext = () => {
1431
+ const source = drainSourceRef.current;
1432
+ if (source === null) return;
1433
+ if (!publishNext(source)) {
1434
+ hideAfterLinger();
1435
+ return;
1436
+ }
1437
+ timerRef.current = setTimeout(() => {
1438
+ timerRef.current = null;
1439
+ if (activeRef.current) return;
1440
+ drainNext();
1441
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS);
1442
+ };
1443
+ const scheduleLive = () => {
1444
+ if (timerRef.current !== null || announcedOffsetRef.current >= sourceRef.current.length) return;
1445
+ timerRef.current = setTimeout(() => {
1446
+ timerRef.current = null;
1447
+ if (!activeRef.current) return;
1448
+ const source = sourceRef.current;
1449
+ if (publishNext(source)) scheduleLive();
1450
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS);
1451
+ };
1452
+ const wasActive = activeRef.current;
1453
+ const previousSource = sourceRef.current;
1454
+ if (!(!active && !wasActive && drainSourceRef.current !== null) && (!text.startsWith(previousSource) || announcedOffsetRef.current > text.length)) announcedOffsetRef.current = 0;
1455
+ sourceRef.current = text;
1456
+ activeRef.current = active;
1457
+ if (active) {
1458
+ if (!wasActive) {
1459
+ clearTimer();
1460
+ drainSourceRef.current = null;
1461
+ setAnnouncement((previous) => ({
1462
+ text: "",
1463
+ revision: previous.revision + 1,
1464
+ present: true
1465
+ }));
1466
+ }
1467
+ scheduleLive();
1468
+ return;
1469
+ }
1470
+ if (!wasActive) {
1471
+ if (drainSourceRef.current === null) announcedOffsetRef.current = text.length;
1472
+ return;
1473
+ }
1474
+ clearTimer();
1475
+ drainSourceRef.current = text;
1476
+ drainNext();
1477
+ }, [active, text]);
1478
+ (0, react.useEffect)(() => () => {
1479
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
1480
+ timerRef.current = null;
1481
+ }, []);
1482
+ if (!active && !announcement.present) return null;
1483
+ const activating = active && !activeRef.current;
1484
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1485
+ className: TypewriterAssistantNodeView_module_css_default.visuallyHidden,
1486
+ "aria-live": "polite",
1487
+ "aria-atomic": "true",
1488
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activating ? "" : announcement.text }, announcement.revision)
1489
+ });
1490
+ });
1298
1491
  /**
1299
1492
  * Smooth streaming text arm. While the reply runs, the accumulated source is
1300
1493
  * revealed through the smoother at a rate that tracks the model's arrival
@@ -1306,13 +1499,14 @@ window.__ModuleLoader__.load({
1306
1499
  * drains, the settled full parse (KaTeX math, fence highlighting, file
1307
1500
  * mentions) swaps in exactly once.
1308
1501
  */
1309
- function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, announce, ownFollow, followSpeedCpsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack }) {
1502
+ function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, ownFollow, followSpeedCpsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack }) {
1310
1503
  const reduced = usePrefersReducedMotion();
1311
1504
  const [typing, setTyping] = (0, react.useState)(streaming);
1312
1505
  const localSpeedCpsRef = (0, react.useRef)(35);
1313
1506
  const followRootRef = (0, react.useRef)(null);
1314
1507
  const predictionSourceRef = (0, react.useRef)(null);
1315
1508
  const predictionStateRef = (0, react.useRef)(false);
1509
+ const predictionGeometryRef = (0, react.useRef)(null);
1316
1510
  const speedCpsRef = followSpeedCpsRef ?? localSpeedCpsRef;
1317
1511
  const displayed = useSmoothStreamContent(text, {
1318
1512
  enabled: typing && !reduced,
@@ -1324,11 +1518,22 @@ window.__ModuleLoader__.load({
1324
1518
  });
1325
1519
  const shown = reduced ? text : displayed;
1326
1520
  const live = typing && !reduced;
1521
+ (0, react.useEffect)(() => {
1522
+ const root = followRootRef.current;
1523
+ if (root === null || typeof ResizeObserver === "undefined") return;
1524
+ const observer = new ResizeObserver(() => {
1525
+ if (predictionGeometryRef.current?.root === root) predictionGeometryRef.current = null;
1526
+ });
1527
+ observer.observe(root);
1528
+ return () => {
1529
+ observer.disconnect();
1530
+ };
1531
+ }, []);
1327
1532
  (0, react.useLayoutEffect)(() => {
1328
1533
  if (onPredictiveChange === void 0) return;
1329
1534
  const pending = text.slice(shown.length);
1330
1535
  const sourceChanged = predictionSourceRef.current !== text;
1331
- const next = !live || !streaming || pending === "" ? false : sourceChanged ? pendingTextCanGrow(followRootRef.current, pending) : predictionStateRef.current;
1536
+ const next = !live || !streaming || pending === "" ? false : sourceChanged ? pendingTextCanGrow(followRootRef.current, shown, pending, predictionGeometryRef) : predictionStateRef.current;
1332
1537
  predictionSourceRef.current = text;
1333
1538
  predictionStateRef.current = next;
1334
1539
  onPredictiveChange(next);
@@ -1347,11 +1552,7 @@ window.__ModuleLoader__.load({
1347
1552
  text,
1348
1553
  typing
1349
1554
  ]);
1350
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [live && announce && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1351
- className: TypewriterAssistantNodeView_module_css_default.visuallyHidden,
1352
- "aria-live": "polite",
1353
- children: text
1354
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1555
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1355
1556
  active: live && ownFollow,
1356
1557
  speedCpsRef,
1357
1558
  revealScaleRef: followRevealScaleRef,
@@ -1363,7 +1564,7 @@ window.__ModuleLoader__.load({
1363
1564
  codeLabels,
1364
1565
  fileMentions: live ? void 0 : fileMentions
1365
1566
  })
1366
- })] });
1567
+ });
1367
1568
  }
1368
1569
  function imageLabels(t) {
1369
1570
  return {
@@ -1396,7 +1597,7 @@ window.__ModuleLoader__.load({
1396
1597
  * or the assistant node settling — not when the rest of the reply is
1397
1598
  * still streaming.
1398
1599
  */
1399
- function AnimatedReasoning({ text, running, preset, thinkAutoExpand, shouldHoldBack, followSpeedCpsRef, followRevealScaleRef, onExpandedChange, t }) {
1600
+ function AnimatedReasoning({ text, running, preset, thinkAutoExpand, shouldHoldBack, followSpeedCpsRef, followRevealScaleRef, t }) {
1400
1601
  const reduced = usePrefersReducedMotion();
1401
1602
  const [expanded, setExpanded] = (0, react.useState)(running && thinkAutoExpand);
1402
1603
  const summaryRef = (0, react.useRef)(null);
@@ -1412,9 +1613,6 @@ window.__ModuleLoader__.load({
1412
1613
  (0, react.useLayoutEffect)(() => {
1413
1614
  if (thinkAutoExpand) setExpanded(running);
1414
1615
  }, [running, thinkAutoExpand]);
1415
- (0, react.useLayoutEffect)(() => {
1416
- onExpandedChange?.(expanded);
1417
- }, [expanded, onExpandedChange]);
1418
1616
  (0, react.useEffect)(() => {
1419
1617
  const element = summaryRef.current;
1420
1618
  if (element === null) return;
@@ -1479,13 +1677,10 @@ window.__ModuleLoader__.load({
1479
1677
  const rootPredictiveRef = (0, react.useRef)(false);
1480
1678
  const previousReasoningTailRef = (0, react.useRef)(-1);
1481
1679
  if (reasoningTailIndex !== previousReasoningTailRef.current) {
1482
- rootPredictiveRef.current = reasoningOwnsSpeed ? thinkAutoExpand : false;
1680
+ rootPredictiveRef.current = false;
1483
1681
  if (!reasoningOwnsSpeed) rootSpeedRef.current = 35;
1484
1682
  previousReasoningTailRef.current = reasoningTailIndex;
1485
1683
  }
1486
- const updateReasoningExpanded = (0, react.useMemo)(() => (expanded) => {
1487
- rootPredictiveRef.current = expanded;
1488
- }, []);
1489
1684
  const updateTextPrediction = (0, react.useMemo)(() => (predictive) => {
1490
1685
  rootPredictiveRef.current = predictive;
1491
1686
  }, []);
@@ -1514,6 +1709,7 @@ window.__ModuleLoader__.load({
1514
1709
  throw new Error(t("image.serviceUnavailable"));
1515
1710
  });
1516
1711
  if (!(streaming || data.status === "interrupted" || data.blocks.some((block) => block.kind !== "tool-call"))) return null;
1712
+ const announcementText = data.blocks.filter((block) => block.kind === "text").map((block) => block.text).join("\n");
1517
1713
  const rendered = [];
1518
1714
  const last = data.blocks.length - 1;
1519
1715
  let lastFollow = -1;
@@ -1531,7 +1727,6 @@ window.__ModuleLoader__.load({
1531
1727
  codeLabels,
1532
1728
  fileMentions: mentions,
1533
1729
  streaming,
1534
- announce: index === last,
1535
1730
  ownFollow: !streaming && index === lastFollow,
1536
1731
  followSpeedCpsRef: index === lastFollow ? rootSpeedRef : void 0,
1537
1732
  followRevealScaleRef: index === lastFollow ? rootRevealScaleRef : void 0,
@@ -1549,7 +1744,6 @@ window.__ModuleLoader__.load({
1549
1744
  shouldHoldBack,
1550
1745
  followSpeedCpsRef: reasoningOwnsSpeed && index === last ? rootSpeedRef : void 0,
1551
1746
  followRevealScaleRef: reasoningOwnsSpeed && index === last ? rootRevealScaleRef : void 0,
1552
- onExpandedChange: index === reasoningTailIndex ? updateReasoningExpanded : void 0,
1553
1747
  t
1554
1748
  }, index));
1555
1749
  break;
@@ -1578,11 +1772,14 @@ window.__ModuleLoader__.load({
1578
1772
  }, index));
1579
1773
  }
1580
1774
  }
1581
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1775
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1582
1776
  ref: guardRef,
1583
1777
  className: TypewriterAssistantNodeView_module_css_default.root,
1584
1778
  "data-streaming": streaming || void 0,
1585
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1779
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(StreamAnnouncement, {
1780
+ text: announcementText,
1781
+ active: streaming && !reduced
1782
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1586
1783
  active: streaming && !reduced,
1587
1784
  speedCpsRef: rootSpeedRef,
1588
1785
  revealScaleRef: rootRevealScaleRef,
@@ -1594,7 +1791,7 @@ window.__ModuleLoader__.load({
1594
1791
  children: t("message.stopped")
1595
1792
  })]
1596
1793
  })
1597
- })
1794
+ })]
1598
1795
  });
1599
1796
  });
1600
1797
  //#endregion
@@ -1967,31 +2164,31 @@ window.__ModuleLoader__.load({
1967
2164
  tag.textContent = css;
1968
2165
  }
1969
2166
  var SmoothStreamCard_module_css_default = {
1970
- "card": "XNGeRG_card",
1971
- "hint": "XNGeRG_hint",
1972
- "failed": "XNGeRG_failed",
1973
- "field": "XNGeRG_field",
1974
- "chevron": "XNGeRG_chevron",
1975
- "headText": "XNGeRG_headText",
2167
+ "header": "XNGeRG_header",
1976
2168
  "version": "XNGeRG_version",
1977
- "body": "XNGeRG_body",
1978
- "fieldHead": "XNGeRG_fieldHead",
1979
- "toggle": "XNGeRG_toggle",
1980
2169
  "readOnly": "XNGeRG_readOnly",
1981
- "header": "XNGeRG_header",
1982
- "pending": "XNGeRG_pending",
1983
- "label": "XNGeRG_label",
1984
- "update": "XNGeRG_update",
1985
- "discard": "XNGeRG_discard",
1986
2170
  "footer": "XNGeRG_footer",
2171
+ "label": "XNGeRG_label",
2172
+ "hint": "XNGeRG_hint",
2173
+ "fieldHead": "XNGeRG_fieldHead",
1987
2174
  "failure": "XNGeRG_failure",
1988
- "name": "XNGeRG_name",
1989
- "description": "XNGeRG_description",
2175
+ "update": "XNGeRG_update",
2176
+ "field": "XNGeRG_field",
2177
+ "save": "XNGeRG_save",
2178
+ "pending": "XNGeRG_pending",
2179
+ "card": "XNGeRG_card",
1990
2180
  "cardOpen": "XNGeRG_cardOpen",
1991
- "chevronOpen": "XNGeRG_chevronOpen",
2181
+ "chevron": "XNGeRG_chevron",
2182
+ "toggle": "XNGeRG_toggle",
2183
+ "body": "XNGeRG_body",
2184
+ "name": "XNGeRG_name",
2185
+ "failed": "XNGeRG_failed",
1992
2186
  "updateRow": "XNGeRG_updateRow",
1993
- "save": "XNGeRG_save",
1994
- "updateCopy": "XNGeRG_updateCopy"
2187
+ "discard": "XNGeRG_discard",
2188
+ "description": "XNGeRG_description",
2189
+ "headText": "XNGeRG_headText",
2190
+ "updateCopy": "XNGeRG_updateCopy",
2191
+ "chevronOpen": "XNGeRG_chevronOpen"
1995
2192
  };
1996
2193
  //#endregion
1997
2194
  //#region src/client/SmoothStreamCard.tsx