@drscrewdriver/dsh-better-display 0.3.3-fork.1

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.
Files changed (390) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/DESIGN.md +72 -0
  3. package/LICENSE +24 -0
  4. package/README.en.md +55 -0
  5. package/README.md +55 -0
  6. package/THIRD_PARTY_NOTICES.md +65 -0
  7. package/compat/harness-rc2.json +99 -0
  8. package/cordis.patch.yml +5 -0
  9. package/cordis.yml +4 -0
  10. package/docs/official-rendering-bridge.md +65 -0
  11. package/dshx.yml +5 -0
  12. package/lib/client.js +49292 -0
  13. package/lib/client.js.map +1 -0
  14. package/lib/dsh-better-display.js +101 -0
  15. package/lib/types/client/Blocks.d.ts +49 -0
  16. package/lib/types/client/Blocks.d.ts.map +1 -0
  17. package/lib/types/client/Blocks.js +143 -0
  18. package/lib/types/client/Blocks.js.map +1 -0
  19. package/lib/types/client/ChoreographedFlow.d.ts +22 -0
  20. package/lib/types/client/ChoreographedFlow.d.ts.map +1 -0
  21. package/lib/types/client/ChoreographedFlow.js +218 -0
  22. package/lib/types/client/ChoreographedFlow.js.map +1 -0
  23. package/lib/types/client/ClosedProcessSummary.d.ts +9 -0
  24. package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -0
  25. package/lib/types/client/ClosedProcessSummary.js +16 -0
  26. package/lib/types/client/ClosedProcessSummary.js.map +1 -0
  27. package/lib/types/client/DiffPanel.d.ts +13 -0
  28. package/lib/types/client/DiffPanel.d.ts.map +1 -0
  29. package/lib/types/client/DiffPanel.js +115 -0
  30. package/lib/types/client/DiffPanel.js.map +1 -0
  31. package/lib/types/client/LiveFold.d.ts +10 -0
  32. package/lib/types/client/LiveFold.d.ts.map +1 -0
  33. package/lib/types/client/LiveFold.js +39 -0
  34. package/lib/types/client/LiveFold.js.map +1 -0
  35. package/lib/types/client/McpAppFrame.d.ts +32 -0
  36. package/lib/types/client/McpAppFrame.d.ts.map +1 -0
  37. package/lib/types/client/McpAppFrame.js +193 -0
  38. package/lib/types/client/McpAppFrame.js.map +1 -0
  39. package/lib/types/client/OfficialContent.d.ts +25 -0
  40. package/lib/types/client/OfficialContent.d.ts.map +1 -0
  41. package/lib/types/client/OfficialContent.js +53 -0
  42. package/lib/types/client/OfficialContent.js.map +1 -0
  43. package/lib/types/client/Reader.d.ts +3 -0
  44. package/lib/types/client/Reader.d.ts.map +1 -0
  45. package/lib/types/client/Reader.js +603 -0
  46. package/lib/types/client/Reader.js.map +1 -0
  47. package/lib/types/client/ReasoningCard.d.ts +11 -0
  48. package/lib/types/client/ReasoningCard.d.ts.map +1 -0
  49. package/lib/types/client/ReasoningCard.js +322 -0
  50. package/lib/types/client/ReasoningCard.js.map +1 -0
  51. package/lib/types/client/SettingsSection.d.ts +37 -0
  52. package/lib/types/client/SettingsSection.d.ts.map +1 -0
  53. package/lib/types/client/SettingsSection.js +63 -0
  54. package/lib/types/client/SettingsSection.js.map +1 -0
  55. package/lib/types/client/StickyLane.d.ts +8 -0
  56. package/lib/types/client/StickyLane.d.ts.map +1 -0
  57. package/lib/types/client/StickyLane.js +39 -0
  58. package/lib/types/client/StickyLane.js.map +1 -0
  59. package/lib/types/client/TimelineRail.d.ts +10 -0
  60. package/lib/types/client/TimelineRail.d.ts.map +1 -0
  61. package/lib/types/client/TimelineRail.js +168 -0
  62. package/lib/types/client/TimelineRail.js.map +1 -0
  63. package/lib/types/client/ToolActivity.d.ts +17 -0
  64. package/lib/types/client/ToolActivity.d.ts.map +1 -0
  65. package/lib/types/client/ToolActivity.js +244 -0
  66. package/lib/types/client/ToolActivity.js.map +1 -0
  67. package/lib/types/client/TurnMetrics.d.ts +11 -0
  68. package/lib/types/client/TurnMetrics.d.ts.map +1 -0
  69. package/lib/types/client/TurnMetrics.js +44 -0
  70. package/lib/types/client/TurnMetrics.js.map +1 -0
  71. package/lib/types/client/WaitClock.d.ts +16 -0
  72. package/lib/types/client/WaitClock.d.ts.map +1 -0
  73. package/lib/types/client/WaitClock.js +29 -0
  74. package/lib/types/client/WaitClock.js.map +1 -0
  75. package/lib/types/client/WaitingClock.d.ts +15 -0
  76. package/lib/types/client/WaitingClock.d.ts.map +1 -0
  77. package/lib/types/client/WaitingClock.js +30 -0
  78. package/lib/types/client/WaitingClock.js.map +1 -0
  79. package/lib/types/client/WaitingStatus.d.ts +6 -0
  80. package/lib/types/client/WaitingStatus.d.ts.map +1 -0
  81. package/lib/types/client/WaitingStatus.js +9 -0
  82. package/lib/types/client/WaitingStatus.js.map +1 -0
  83. package/lib/types/client/conversation-scroll.d.ts +19 -0
  84. package/lib/types/client/conversation-scroll.d.ts.map +1 -0
  85. package/lib/types/client/conversation-scroll.js +25 -0
  86. package/lib/types/client/conversation-scroll.js.map +1 -0
  87. package/lib/types/client/deliverables.d.ts +24 -0
  88. package/lib/types/client/deliverables.d.ts.map +1 -0
  89. package/lib/types/client/deliverables.js +104 -0
  90. package/lib/types/client/deliverables.js.map +1 -0
  91. package/lib/types/client/entry-policy.d.ts +10 -0
  92. package/lib/types/client/entry-policy.d.ts.map +1 -0
  93. package/lib/types/client/entry-policy.js +24 -0
  94. package/lib/types/client/entry-policy.js.map +1 -0
  95. package/lib/types/client/entry.d.ts +4 -0
  96. package/lib/types/client/entry.d.ts.map +1 -0
  97. package/lib/types/client/entry.js +34 -0
  98. package/lib/types/client/entry.js.map +1 -0
  99. package/lib/types/client/fold-choreography.d.ts +40 -0
  100. package/lib/types/client/fold-choreography.d.ts.map +1 -0
  101. package/lib/types/client/fold-choreography.js +48 -0
  102. package/lib/types/client/fold-choreography.js.map +1 -0
  103. package/lib/types/client/fold-intensity.d.ts +11 -0
  104. package/lib/types/client/fold-intensity.d.ts.map +1 -0
  105. package/lib/types/client/fold-intensity.js +32 -0
  106. package/lib/types/client/fold-intensity.js.map +1 -0
  107. package/lib/types/client/index.d.ts +7 -0
  108. package/lib/types/client/index.d.ts.map +1 -0
  109. package/lib/types/client/index.js +195 -0
  110. package/lib/types/client/index.js.map +1 -0
  111. package/lib/types/client/live-turn.d.ts +58 -0
  112. package/lib/types/client/live-turn.d.ts.map +1 -0
  113. package/lib/types/client/live-turn.js +190 -0
  114. package/lib/types/client/live-turn.js.map +1 -0
  115. package/lib/types/client/markdown/MarkdownText.d.ts +41 -0
  116. package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -0
  117. package/lib/types/client/markdown/MarkdownText.js +178 -0
  118. package/lib/types/client/markdown/MarkdownText.js.map +1 -0
  119. package/lib/types/client/markdown/cjkFriendlyStrong.d.ts +9 -0
  120. package/lib/types/client/markdown/cjkFriendlyStrong.d.ts.map +1 -0
  121. package/lib/types/client/markdown/cjkFriendlyStrong.js +73 -0
  122. package/lib/types/client/markdown/cjkFriendlyStrong.js.map +1 -0
  123. package/lib/types/client/markdown/incremental.d.ts +69 -0
  124. package/lib/types/client/markdown/incremental.d.ts.map +1 -0
  125. package/lib/types/client/markdown/incremental.js +108 -0
  126. package/lib/types/client/markdown/incremental.js.map +1 -0
  127. package/lib/types/client/markdown/katex.d.ts +27 -0
  128. package/lib/types/client/markdown/katex.d.ts.map +1 -0
  129. package/lib/types/client/markdown/katex.js +88 -0
  130. package/lib/types/client/markdown/katex.js.map +1 -0
  131. package/lib/types/client/markdown/mathCompatibility.d.ts +11 -0
  132. package/lib/types/client/markdown/mathCompatibility.d.ts.map +1 -0
  133. package/lib/types/client/markdown/mathCompatibility.js +293 -0
  134. package/lib/types/client/markdown/mathCompatibility.js.map +1 -0
  135. package/lib/types/client/markdown/parse.d.ts +25 -0
  136. package/lib/types/client/markdown/parse.d.ts.map +1 -0
  137. package/lib/types/client/markdown/parse.js +42 -0
  138. package/lib/types/client/markdown/parse.js.map +1 -0
  139. package/lib/types/client/markdown/render.d.ts +121 -0
  140. package/lib/types/client/markdown/render.d.ts.map +1 -0
  141. package/lib/types/client/markdown/render.js +452 -0
  142. package/lib/types/client/markdown/render.js.map +1 -0
  143. package/lib/types/client/mcp-app.d.ts +50 -0
  144. package/lib/types/client/mcp-app.d.ts.map +1 -0
  145. package/lib/types/client/mcp-app.js +328 -0
  146. package/lib/types/client/mcp-app.js.map +1 -0
  147. package/lib/types/client/message-chrome.d.ts +19 -0
  148. package/lib/types/client/message-chrome.d.ts.map +1 -0
  149. package/lib/types/client/message-chrome.js +42 -0
  150. package/lib/types/client/message-chrome.js.map +1 -0
  151. package/lib/types/client/motion.d.ts +38 -0
  152. package/lib/types/client/motion.d.ts.map +1 -0
  153. package/lib/types/client/motion.js +453 -0
  154. package/lib/types/client/motion.js.map +1 -0
  155. package/lib/types/client/native/ContextBody.d.ts +130 -0
  156. package/lib/types/client/native/ContextBody.d.ts.map +1 -0
  157. package/lib/types/client/native/ContextBody.js +398 -0
  158. package/lib/types/client/native/ContextBody.js.map +1 -0
  159. package/lib/types/client/native/ContextInjectionRow.d.ts +35 -0
  160. package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -0
  161. package/lib/types/client/native/ContextInjectionRow.js +42 -0
  162. package/lib/types/client/native/ContextInjectionRow.js.map +1 -0
  163. package/lib/types/client/native/ReferenceIcon.d.ts +16 -0
  164. package/lib/types/client/native/ReferenceIcon.d.ts.map +1 -0
  165. package/lib/types/client/native/ReferenceIcon.js +16 -0
  166. package/lib/types/client/native/ReferenceIcon.js.map +1 -0
  167. package/lib/types/client/native/tool-call-model.d.ts +58 -0
  168. package/lib/types/client/native/tool-call-model.d.ts.map +1 -0
  169. package/lib/types/client/native/tool-call-model.js +219 -0
  170. package/lib/types/client/native/tool-call-model.js.map +1 -0
  171. package/lib/types/client/native/tool-update.d.ts +23 -0
  172. package/lib/types/client/native/tool-update.d.ts.map +1 -0
  173. package/lib/types/client/native/tool-update.js +22 -0
  174. package/lib/types/client/native/tool-update.js.map +1 -0
  175. package/lib/types/client/official-slots.d.ts +71 -0
  176. package/lib/types/client/official-slots.d.ts.map +1 -0
  177. package/lib/types/client/official-slots.js +183 -0
  178. package/lib/types/client/official-slots.js.map +1 -0
  179. package/lib/types/client/open-file.d.ts +29 -0
  180. package/lib/types/client/open-file.d.ts.map +1 -0
  181. package/lib/types/client/open-file.js +71 -0
  182. package/lib/types/client/open-file.js.map +1 -0
  183. package/lib/types/client/pending-submission.d.ts +28 -0
  184. package/lib/types/client/pending-submission.d.ts.map +1 -0
  185. package/lib/types/client/pending-submission.js +28 -0
  186. package/lib/types/client/pending-submission.js.map +1 -0
  187. package/lib/types/client/platform-media.d.ts +6 -0
  188. package/lib/types/client/platform-media.d.ts.map +1 -0
  189. package/lib/types/client/platform-media.js +18 -0
  190. package/lib/types/client/platform-media.js.map +1 -0
  191. package/lib/types/client/primitive-labels.d.ts +10 -0
  192. package/lib/types/client/primitive-labels.d.ts.map +1 -0
  193. package/lib/types/client/primitive-labels.js +38 -0
  194. package/lib/types/client/primitive-labels.js.map +1 -0
  195. package/lib/types/client/projection.d.ts +41 -0
  196. package/lib/types/client/projection.d.ts.map +1 -0
  197. package/lib/types/client/projection.js +112 -0
  198. package/lib/types/client/projection.js.map +1 -0
  199. package/lib/types/client/reasoning-follow.d.ts +10 -0
  200. package/lib/types/client/reasoning-follow.d.ts.map +1 -0
  201. package/lib/types/client/reasoning-follow.js +21 -0
  202. package/lib/types/client/reasoning-follow.js.map +1 -0
  203. package/lib/types/client/resource-address.d.ts +3 -0
  204. package/lib/types/client/resource-address.d.ts.map +1 -0
  205. package/lib/types/client/resource-address.js +33 -0
  206. package/lib/types/client/resource-address.js.map +1 -0
  207. package/lib/types/client/settings-copy.d.ts +7 -0
  208. package/lib/types/client/settings-copy.d.ts.map +1 -0
  209. package/lib/types/client/settings-copy.js +53 -0
  210. package/lib/types/client/settings-copy.js.map +1 -0
  211. package/lib/types/client/settings-slots.d.ts +23 -0
  212. package/lib/types/client/settings-slots.d.ts.map +1 -0
  213. package/lib/types/client/settings-slots.js +2 -0
  214. package/lib/types/client/settings-slots.js.map +1 -0
  215. package/lib/types/client/settings.d.ts +4 -0
  216. package/lib/types/client/settings.d.ts.map +1 -0
  217. package/lib/types/client/settings.js +60 -0
  218. package/lib/types/client/settings.js.map +1 -0
  219. package/lib/types/client/skill-status.d.ts +25 -0
  220. package/lib/types/client/skill-status.d.ts.map +1 -0
  221. package/lib/types/client/skill-status.js +44 -0
  222. package/lib/types/client/skill-status.js.map +1 -0
  223. package/lib/types/client/store.d.ts +35 -0
  224. package/lib/types/client/store.d.ts.map +1 -0
  225. package/lib/types/client/store.js +37 -0
  226. package/lib/types/client/store.js.map +1 -0
  227. package/lib/types/client/stream-buffer.d.ts +37 -0
  228. package/lib/types/client/stream-buffer.d.ts.map +1 -0
  229. package/lib/types/client/stream-buffer.js +150 -0
  230. package/lib/types/client/stream-buffer.js.map +1 -0
  231. package/lib/types/client/streaming.d.ts +18 -0
  232. package/lib/types/client/streaming.d.ts.map +1 -0
  233. package/lib/types/client/streaming.js +84 -0
  234. package/lib/types/client/streaming.js.map +1 -0
  235. package/lib/types/client/timeline.d.ts +26 -0
  236. package/lib/types/client/timeline.d.ts.map +1 -0
  237. package/lib/types/client/timeline.js +48 -0
  238. package/lib/types/client/timeline.js.map +1 -0
  239. package/lib/types/client/tool-activity.d.ts +98 -0
  240. package/lib/types/client/tool-activity.d.ts.map +1 -0
  241. package/lib/types/client/tool-activity.js +301 -0
  242. package/lib/types/client/tool-activity.js.map +1 -0
  243. package/lib/types/client/types.d.ts +91 -0
  244. package/lib/types/client/types.d.ts.map +1 -0
  245. package/lib/types/client/types.js +2 -0
  246. package/lib/types/client/types.js.map +1 -0
  247. package/lib/types/client/waiting-clock.d.ts +49 -0
  248. package/lib/types/client/waiting-clock.d.ts.map +1 -0
  249. package/lib/types/client/waiting-clock.js +80 -0
  250. package/lib/types/client/waiting-clock.js.map +1 -0
  251. package/lib/types/client/word-motion.d.ts +16 -0
  252. package/lib/types/client/word-motion.d.ts.map +1 -0
  253. package/lib/types/client/word-motion.js +90 -0
  254. package/lib/types/client/word-motion.js.map +1 -0
  255. package/lib/types/client/word-timeline.d.ts +32 -0
  256. package/lib/types/client/word-timeline.d.ts.map +1 -0
  257. package/lib/types/client/word-timeline.js +119 -0
  258. package/lib/types/client/word-timeline.js.map +1 -0
  259. package/lib/types/dsh-better-display.d.ts +17 -0
  260. package/lib/types/dsh-better-display.d.ts.map +1 -0
  261. package/lib/types/dsh-better-display.js +93 -0
  262. package/lib/types/dsh-better-display.js.map +1 -0
  263. package/lib/types/skill-roots.d.ts +22 -0
  264. package/lib/types/skill-roots.d.ts.map +1 -0
  265. package/lib/types/skill-roots.js +126 -0
  266. package/lib/types/skill-roots.js.map +1 -0
  267. package/lib/types/skill-status.d.ts +36 -0
  268. package/lib/types/skill-status.d.ts.map +1 -0
  269. package/lib/types/skill-status.js +51 -0
  270. package/lib/types/skill-status.js.map +1 -0
  271. package/package.json +133 -0
  272. package/scripts/build-motion-fixture.mjs +26 -0
  273. package/scripts/check-harness-compat.mjs +71 -0
  274. package/scripts/link-harness-dependencies.mjs +54 -0
  275. package/scripts/verify-auto-fold.mjs +114 -0
  276. package/scripts/verify-official-bridge.mjs +121 -0
  277. package/scripts/verify-tool-shimmer.mjs +62 -0
  278. package/skills/generative-mcpapps/SKILL.md +47 -0
  279. package/skills/generative-mcpapps/examples/interactive_quiz.html +153 -0
  280. package/skills/generative-mcpapps/references/html_boilerplate.md +122 -0
  281. package/skills/generative-mcpapps/references/sep1865_protocol.md +54 -0
  282. package/src/client/Blocks.tsx +213 -0
  283. package/src/client/ChoreographedFlow.tsx +232 -0
  284. package/src/client/ClosedProcessSummary.tsx +23 -0
  285. package/src/client/DiffPanel.tsx +124 -0
  286. package/src/client/LiveFold.tsx +43 -0
  287. package/src/client/McpAppFrame.module.css +210 -0
  288. package/src/client/McpAppFrame.tsx +317 -0
  289. package/src/client/OfficialContent.tsx +69 -0
  290. package/src/client/Reader.module.css +692 -0
  291. package/src/client/Reader.tsx +878 -0
  292. package/src/client/ReasoningCard.tsx +306 -0
  293. package/src/client/SettingsSection.module.css +184 -0
  294. package/src/client/SettingsSection.tsx +194 -0
  295. package/src/client/StickyLane.tsx +34 -0
  296. package/src/client/TimelineRail.module.css +170 -0
  297. package/src/client/TimelineRail.tsx +262 -0
  298. package/src/client/ToolActivity.tsx +274 -0
  299. package/src/client/TurnMetrics.module.css +139 -0
  300. package/src/client/TurnMetrics.tsx +160 -0
  301. package/src/client/WaitClock.tsx +33 -0
  302. package/src/client/WaitingStatus.tsx +13 -0
  303. package/src/client/conversation-scroll.ts +28 -0
  304. package/src/client/deliverables.ts +122 -0
  305. package/src/client/entry-policy.ts +20 -0
  306. package/src/client/entry.tsx +40 -0
  307. package/src/client/fold-choreography.ts +55 -0
  308. package/src/client/fold-intensity.ts +36 -0
  309. package/src/client/index.tsx +222 -0
  310. package/src/client/live-turn.ts +198 -0
  311. package/src/client/markdown/MarkdownText.module.css +304 -0
  312. package/src/client/markdown/MarkdownText.tsx +195 -0
  313. package/src/client/markdown/cjkFriendlyStrong.ts +84 -0
  314. package/src/client/markdown/incremental.ts +131 -0
  315. package/src/client/markdown/katex.tsx +91 -0
  316. package/src/client/markdown/mathCompatibility.ts +350 -0
  317. package/src/client/markdown/parse.ts +45 -0
  318. package/src/client/markdown/render.tsx +647 -0
  319. package/src/client/markdown/upstream.json +14 -0
  320. package/src/client/mcp-app.ts +323 -0
  321. package/src/client/message-chrome.ts +47 -0
  322. package/src/client/motion.tsx +410 -0
  323. package/src/client/native/ContextBody.module.css +161 -0
  324. package/src/client/native/ContextBody.tsx +591 -0
  325. package/src/client/native/ContextInjectionRow.module.css +66 -0
  326. package/src/client/native/ContextInjectionRow.tsx +106 -0
  327. package/src/client/native/ReferenceIcon.tsx +35 -0
  328. package/src/client/native/tool-call-model.ts +248 -0
  329. package/src/client/native/tool-update.ts +32 -0
  330. package/src/client/native/upstream.json +41 -0
  331. package/src/client/official-slots.tsx +211 -0
  332. package/src/client/open-file.ts +94 -0
  333. package/src/client/pending-submission.ts +49 -0
  334. package/src/client/platform-media.ts +17 -0
  335. package/src/client/primitive-labels.ts +54 -0
  336. package/src/client/projection.ts +118 -0
  337. package/src/client/reasoning-follow.ts +28 -0
  338. package/src/client/settings-copy.ts +80 -0
  339. package/src/client/settings-slots.ts +20 -0
  340. package/src/client/settings.tsx +73 -0
  341. package/src/client/skill-status.ts +78 -0
  342. package/src/client/store.ts +75 -0
  343. package/src/client/stream-buffer.ts +136 -0
  344. package/src/client/streaming.tsx +70 -0
  345. package/src/client/style.d.ts +4 -0
  346. package/src/client/timeline.ts +77 -0
  347. package/src/client/tool-activity.ts +298 -0
  348. package/src/client/types.ts +89 -0
  349. package/src/client/waiting-clock.ts +83 -0
  350. package/src/client/word-motion.tsx +92 -0
  351. package/src/client/word-timeline.ts +113 -0
  352. package/src/dsh-better-display.ts +108 -0
  353. package/src/skill-roots.ts +138 -0
  354. package/src/skill-status.ts +81 -0
  355. package/tests/browser-auto-fold.tsx +64 -0
  356. package/tests/browser-closed-gap.tsx +20 -0
  357. package/tests/browser-motion.tsx +65 -0
  358. package/tests/browser-official-bridge.tsx +152 -0
  359. package/tests/browser-sticky-lanes.tsx +22 -0
  360. package/tests/browser-waiting-clock.tsx +14 -0
  361. package/tests/chat-flow-hook.test.ts +66 -0
  362. package/tests/cloud-gate.test.ts +34 -0
  363. package/tests/context-tool-update.test.ts +33 -0
  364. package/tests/conversation-scroll.test.ts +47 -0
  365. package/tests/deliverables.test.ts +128 -0
  366. package/tests/entry.test.ts +42 -0
  367. package/tests/fixtures/maid-composer-hide.html +31 -0
  368. package/tests/fold-choreography.test.ts +54 -0
  369. package/tests/fold-intensity.test.ts +94 -0
  370. package/tests/footer-precedence.test.ts +129 -0
  371. package/tests/history-word-regression.test.ts +16 -0
  372. package/tests/live-turn.test.ts +218 -0
  373. package/tests/mcp-apps.test.ts +157 -0
  374. package/tests/message-chrome.test.ts +29 -0
  375. package/tests/official-slots.test.ts +112 -0
  376. package/tests/open-file.test.ts +114 -0
  377. package/tests/pending-submission.test.ts +45 -0
  378. package/tests/platform-media.test.ts +23 -0
  379. package/tests/projection.test.ts +184 -0
  380. package/tests/reasoning-follow.test.ts +34 -0
  381. package/tests/settings-copy.test.ts +63 -0
  382. package/tests/skill-status.test.ts +137 -0
  383. package/tests/stock-install.test.ts +58 -0
  384. package/tests/stream-buffer.test.ts +102 -0
  385. package/tests/timeline.test.ts +59 -0
  386. package/tests/tool-diff.test.ts +57 -0
  387. package/tests/waiting-clock.test.ts +83 -0
  388. package/tests/word-timeline.test.ts +146 -0
  389. package/tsconfig.json +11 -0
  390. package/tsdown.config.ts +51 -0
@@ -0,0 +1,213 @@
1
+ import { Component, Fragment, memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
4
+ import type { AssistantBlock, UserMessageNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
5
+ import { JsonBlock, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives';
6
+ import { ComposerFillContext, McpAppFrame } from './McpAppFrame.js';
7
+ import { formatMessageClock } from './message-chrome.js';
8
+ import { markdownLabels, truncatedJsonLabel } from './primitive-labels.js';
9
+ import type { BlockRenderProps, ReaderBlockOwner } from './types.js';
10
+ import { useStreamingText } from './streaming.js';
11
+ import { MotionMarkdown, MotionPlainText } from './word-motion.js';
12
+ import css from './Reader.module.css';
13
+
14
+ const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
15
+
16
+ export class BlockBoundary extends Component<{ children: ReactNode }, { failed: boolean; detail: string }> {
17
+ state = { failed: false, detail: '' };
18
+ // Temporary diagnostic: surface the reason inline so a recurrence is reportable
19
+ // without a console. Remove once the reported block crash is identified.
20
+ static getDerivedStateFromError(error: unknown) {
21
+ const err = error as { message?: unknown } | undefined;
22
+ return { failed: true, detail: String(err?.message ?? error).slice(0, 160) };
23
+ }
24
+ render() {
25
+ return this.state.failed
26
+ ? <div className={css.notice}>此内容暂时无法在阅读页显示;原对话中的记录未受影响。<span data-reader-block-error style={{ opacity: .65 }}>({this.state.detail})</span></div>
27
+ : this.props.children;
28
+ }
29
+ }
30
+
31
+ export const ImageBlock = memo(function ImageBlock({ attachment, loadImage }: {
32
+ attachment: ImageAttachmentRef; loadImage: BlockRenderProps['loadImage'];
33
+ }) {
34
+ const [attempt, setAttempt] = useState(0);
35
+ const [url, setUrl] = useState<string | null>(null);
36
+ const [error, setError] = useState(false);
37
+ const [decoded, setDecoded] = useState(false);
38
+ const dialog = useRef<HTMLDialogElement>(null);
39
+ const opener = useRef<HTMLButtonElement>(null);
40
+ useEffect(() => {
41
+ let active = true;
42
+ let owned: string | null = null;
43
+ const timeout = setTimeout(() => { if (active) { setError(true); active = false; } }, 20000);
44
+ setError(false); setDecoded(false); setUrl(null);
45
+ void loadImage(attachment).then(result => {
46
+ if (!active) return;
47
+ clearTimeout(timeout);
48
+ if (!IMAGE_TYPES.has(result.mediaType)) throw new Error('Unsupported image media type');
49
+ const bytes = Uint8Array.from(result.data);
50
+ owned = URL.createObjectURL(new Blob([bytes.buffer], { type: result.mediaType }));
51
+ setUrl(owned);
52
+ }).catch(() => { clearTimeout(timeout); if (active) setError(true); });
53
+ return () => { active = false; clearTimeout(timeout); if (owned) URL.revokeObjectURL(owned); };
54
+ }, [attachment.attachmentId, attempt, loadImage]);
55
+ const width = Number.isFinite(attachment.width) && attachment.width > 0 ? attachment.width : 4;
56
+ const height = Number.isFinite(attachment.height) && attachment.height > 0 ? attachment.height : 3;
57
+ return <figure className={css.imageFigure} data-reader-image data-image-state={error ? 'error' : decoded ? 'ready' : 'loading'}>
58
+ <div className={css.imageFrame} style={{ aspectRatio: `${width} / ${height}` }}>
59
+ {!error && url && <button ref={opener} type="button" className={css.imageOpen} aria-label={`放大图片${attachment.name ? `:${attachment.name}` : ''}`} onClick={() => dialog.current?.showModal()}>
60
+ <img src={url} alt={attachment.name ?? '会话图片'} width={width} height={height} onLoad={() => setDecoded(true)} onError={() => setError(true)} data-ready={decoded} />
61
+ </button>}
62
+ {(!decoded || error) && <div className={css.imagePlaceholder}>
63
+ <span>{error ? '图片未能加载' : '正在加载图片'}</span>
64
+ {error && <button type="button" className={css.textButton} onClick={() => setAttempt(value => value + 1)}>重试</button>}
65
+ </div>}
66
+ </div>
67
+ {attachment.name && <figcaption>{attachment.name}</figcaption>}
68
+ <dialog ref={dialog} className={css.imageDialog} aria-label="图片预览" onClose={() => opener.current?.focus()} onClick={event => { if (event.target === dialog.current) dialog.current?.close(); }}>
69
+ <button type="button" autoFocus className={css.dialogClose} aria-label="关闭图片预览" onClick={() => dialog.current?.close()}>×</button>
70
+ {url && <img src={url} alt={attachment.name ?? '会话图片'} />}
71
+ </dialog>
72
+ </figure>;
73
+ });
74
+
75
+ export function contentBlocks(content: UserMessageNode['content']): AssistantBlock[] {
76
+ return content.map(block => {
77
+ if (block.type === 'text') return { kind: 'text', text: block.text };
78
+ if (block.type === 'image') return { kind: 'image', attachment: block.attachment };
79
+ return { kind: 'other', block };
80
+ });
81
+ }
82
+
83
+ type TextPresentation = { startedAt?: number; interrupted?: boolean; liveText?: boolean };
84
+
85
+ function ReadingMarkdown({ text, streaming, holdFormatting, startedAt, interrupted = false, liveText = false, kind = 'body', fileMentions }: { text: string; streaming: boolean; holdFormatting: boolean; kind?: 'body' | 'reasoning'; fileMentions?: BlockRenderProps['fileMentions'] } & TextPresentation) {
86
+ const root = useRef<HTMLDivElement>(null);
87
+ const presentation = useStreamingText(text, streaming, { startedAt, interrupted: interrupted || !liveText, selected: holdFormatting });
88
+ // Native Markdown changes block keys for its full final parse. Keep the last
89
+ // committed mode while this answer is selected, then finish formatting on
90
+ // deselection. Business status and the source text still update normally.
91
+ const committedMode = useRef(streaming);
92
+ const effectiveMode = holdFormatting ? committedMode.current : presentation.formatStreaming;
93
+ useLayoutEffect(() => { committedMode.current = effectiveMode; }, [effectiveMode]);
94
+ return <div ref={root} className={css.readingText} data-reader-text data-reader-text-kind={kind} data-received-length={text.length} data-shown-length={presentation.text.length}
95
+ data-presentation-pending={presentation.pending || undefined} data-motion-style="opacity-blur" data-ud-motion="reader-text-arrival" data-ud-motion-type="reveal" data-ud-motion-no-flash="true">
96
+ <MotionMarkdown text={presentation.text} streaming={effectiveMode} enabled={liveText && presentation.reveal && effectiveMode} revision={presentation.revision} fileMentions={fileMentions} />
97
+ </div>;
98
+ }
99
+
100
+ function ReadingReasoning({ text, streaming, holdFormatting, startedAt, interrupted = false, liveText = false }: { text: string; streaming: boolean; holdFormatting: boolean } & TextPresentation) {
101
+ const presentation = useStreamingText(text, streaming, { startedAt, interrupted: interrupted || !liveText, selected: holdFormatting });
102
+ return <div className={css.readingText} data-reader-text data-reader-text-kind="reasoning" data-received-length={text.length} data-shown-length={presentation.text.length}
103
+ data-presentation-pending={presentation.pending || undefined} data-motion-style="opacity-blur" data-ud-motion="reader-text-arrival" data-ud-motion-type="reveal" data-ud-motion-no-flash="true">
104
+ <MotionPlainText text={presentation.text} enabled={liveText && presentation.reveal} revision={presentation.revision} />
105
+ </div>;
106
+ }
107
+
108
+ function fallback(block: AssistantBlock, streaming: boolean, source: ReaderBlockOwner['source'], loadImage: BlockRenderProps['loadImage'], fillComposer: BlockRenderProps['fillComposer'], holdFormatting: boolean, presentation: TextPresentation, fileMentions?: BlockRenderProps['fileMentions']): ReactNode {
109
+ switch (block.kind) {
110
+ case 'text': return source === 'user' ? <MarkdownText text={block.text} labels={markdownLabels} /> : <ReadingMarkdown text={block.text} streaming={streaming} holdFormatting={holdFormatting} {...presentation} fileMentions={fileMentions} />;
111
+ case 'image': return <ImageBlock attachment={block.attachment} loadImage={loadImage} />;
112
+ case 'reasoning': return <ReadingReasoning text={block.text} streaming={streaming} holdFormatting={holdFormatting} {...presentation} />;
113
+ case 'tool-call': return <JsonBlock label={`工具参数 · ${block.name}`} payload={block.argsRaw} truncatedLabel={truncatedJsonLabel} />;
114
+ case 'other': {
115
+ const raw = block.block;
116
+ if (raw && typeof raw === 'object') {
117
+ const item = raw as Record<string, unknown>;
118
+ if ((item.type === 'mcp-app' || item.type === 'mcpapp' || item.type === 'ui') && typeof item.html === 'string') {
119
+ return <McpAppFrame html={item.html} title={typeof item.title === 'string' ? item.title : undefined} fillComposer={fillComposer} />;
120
+ }
121
+ }
122
+ return <div className={css.unknown}>
123
+ <p>此内容类型尚未接入阅读页,原始内容已保留。</p>
124
+ <JsonBlock label="查看原始内容" payload={block.block} truncatedLabel={truncatedJsonLabel} />
125
+ </div>;
126
+ }
127
+ }
128
+ }
129
+
130
+ export const Blocks = memo(function Blocks({ blocks, streaming = false, source = 'assistant', holdFormatting = false, startedAt, interrupted, liveText, renderSlotChain, loadImage, fillComposer, fileMentions }: BlockRenderProps & TextPresentation & {
131
+ blocks: readonly AssistantBlock[]; streaming?: boolean; source?: ReaderBlockOwner['source']; holdFormatting?: boolean;
132
+ }) {
133
+ return <ComposerFillContext.Provider value={fillComposer}>
134
+ <div className={css.blocks} data-streaming={streaming || undefined}>
135
+ {blocks.map((block, index) => <BlockBoundary key={block.kind === 'image' ? `image:${block.attachment.attachmentId}:${index}` : `${index}:${block.kind}`}>
136
+ <Fragment>{renderSlotChain('dsh-better-display.block', { block, streaming, source }, { fallback: fallback(block, streaming, source, loadImage, fillComposer, holdFormatting, { startedAt, interrupted, liveText }, fileMentions) })}</Fragment>
137
+ </BlockBoundary>)}
138
+ </div>
139
+ </ComposerFillContext.Provider>;
140
+ });
141
+
142
+ import { TurnMetrics } from './TurnMetrics.js';
143
+
144
+ function CopyGlyph() {
145
+ return <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><rect x="5" y="5" width="8" height="8" rx="1.5" /><path d="M3 10H2.8A.8.8 0 0 1 2 9.2V2.8a.8.8 0 0 1 .8-.8h6.4a.8.8 0 0 1 .8.8V3" /></svg>;
146
+ }
147
+
148
+ function useCopyReceipt() {
149
+ const [receipt, setReceipt] = useState('');
150
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
151
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
152
+ const copy = async (text: string) => {
153
+ const accepted = await writeClipboard(text);
154
+ setReceipt(accepted ? '已复制' : '未能复制,请手动选择文字');
155
+ if (timer.current) clearTimeout(timer.current);
156
+ timer.current = setTimeout(() => setReceipt(''), 2000);
157
+ };
158
+ return { receipt, copy };
159
+ }
160
+
161
+ function MessageClock({ time }: { time: number }) {
162
+ return <time className={css.messageClock} dateTime={new Date(time).toISOString()}>{formatMessageClock(time)}</time>;
163
+ }
164
+
165
+ export function CopyAnswer({ blocks, onFork, metrics, extraActions }: { blocks: readonly AssistantBlock[]; onFork?: () => void; metrics?: BlockRenderProps['metrics']; extraActions?: ReactNode }) {
166
+ const { receipt, copy } = useCopyReceipt();
167
+ const text = blocks.filter((block): block is Extract<AssistantBlock, { kind: 'text' }> => block.kind === 'text').map(block => block.text).join('\n\n');
168
+ const endedAt = metrics?.endedAt;
169
+ const hasMetrics = metrics !== undefined && (metrics.usage !== undefined || metrics.runMs !== undefined || endedAt !== undefined);
170
+ if (!text.trim() && !onFork && !hasMetrics) return null;
171
+ return <div className={css.answerActions}>
172
+ {text.trim() !== '' && (
173
+ <button type="button" className={css.iconButton} aria-label="复制回答" title="复制回答" onClick={() => { void copy(text); }}>
174
+ <CopyGlyph />
175
+ </button>
176
+ )}
177
+ {extraActions}
178
+ {onFork && (
179
+ <button
180
+ type="button"
181
+ className={css.iconButton}
182
+ aria-label="以此处为基础创建分叉会话"
183
+ title="以此处为基础创建分叉会话 (Fork)"
184
+ onClick={onFork}
185
+ >
186
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.2">
187
+ <circle cx="4.5" cy="3.5" r="1.8" />
188
+ <circle cx="4.5" cy="12.5" r="1.8" />
189
+ <circle cx="11.5" cy="5.5" r="1.8" />
190
+ <path d="M4.5 5.5v5M4.5 8c2.5 0 4.5-1 7-2.5" strokeLinecap="round" />
191
+ </svg>
192
+ </button>
193
+ )}
194
+ {metrics && <TurnMetrics usage={metrics.usage} runMs={metrics.runMs} tokensPerSecond={metrics.tokensPerSecond} ttftMs={metrics.ttftMs} />}
195
+ {endedAt !== undefined && <MessageClock time={endedAt} />}
196
+ <span role="status" className={css.meta}>{receipt}</span>
197
+ </div>;
198
+ }
199
+
200
+ export function UserMessageActions({ text, time }: { text: string; time?: number }) {
201
+ const { receipt, copy } = useCopyReceipt();
202
+ const hasText = text.trim() !== '';
203
+ if (!hasText && time === undefined) return null;
204
+ return <div className={css.userActions}>
205
+ {time !== undefined && <MessageClock time={time} />}
206
+ {hasText && (
207
+ <button type="button" className={css.iconButton} aria-label="复制消息" title="复制消息" onClick={() => { void copy(text); }}>
208
+ <CopyGlyph />
209
+ </button>
210
+ )}
211
+ <span role="status" className={css.meta}>{receipt}</span>
212
+ </div>;
213
+ }
@@ -0,0 +1,232 @@
1
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { ReactNode, RefObject } from 'react';
3
+ import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client';
4
+ import type { ReaderProps } from './types.js';
5
+ import type { LiveStep, LiveTurnItem } from './live-turn.js';
6
+ import { collapseRows, containsNewUser, flowRows, FOLD_TIMING, retiringKeys } from './fold-choreography.js';
7
+ import type { FoldPhase } from './fold-choreography.js';
8
+ import { Disclosure } from './motion.js';
9
+ import { FoldSummaryText } from './LiveFold.js';
10
+ import { DiffStat } from './DiffPanel.js';
11
+ import { StreamMotionContext } from './streaming.js';
12
+ import css from './Reader.module.css';
13
+
14
+ const FlowSnapshot = createContext<ChatSnapshot | null>(null);
15
+ /** Public projection only. Descendants cannot leak live data through a frozen frame. */
16
+ export const useFlowChat: ReaderProps['useChat'] = selector => {
17
+ const snapshot = useContext(FlowSnapshot);
18
+ if (!snapshot) throw new Error('Flow snapshot provider missing');
19
+ return selector(snapshot);
20
+ };
21
+
22
+ export type PresentationFrame = { items: readonly LiveTurnItem[]; snapshot: ChatSnapshot };
23
+ type Transaction = {
24
+ phase: FoldPhase; shown: PresentationFrame; target: PresentationFrame;
25
+ retiring: readonly string[]; beforeKeys: ReadonlySet<string>;
26
+ };
27
+ const idle = (frame: PresentationFrame): Transaction => ({ phase: 'idle', shown: frame, target: frame, retiring: [], beforeKeys: new Set() });
28
+
29
+ function Summary({ item, open, onChange, motion }: {
30
+ item: Extract<LiveTurnItem, { kind: 'fold' }>; open: boolean; onChange: (open: boolean) => void; motion: boolean;
31
+ }) {
32
+ const button = useRef<HTMLButtonElement>(null);
33
+ return <div data-reader-live-fold data-expanded={open} data-reader-live-fold-summary={item.summary}>
34
+ <div className={css.summaryRow}>
35
+ <Disclosure open={open} onChange={onChange} buttonRef={button} ariaLabel="此前步骤"
36
+ label={<FoldSummaryText summary={item.summary} motion={motion} />} />
37
+ <DiffStat steps={item.steps} label={item.summary} />
38
+ </div>
39
+ </div>;
40
+ }
41
+
42
+ /** Manual disclosure shares the measured-size path; children survive until finish. */
43
+ function FlowCell({ hidden, instant, motion, rowKey, children, summary = false }: {
44
+ hidden: boolean; instant: boolean; motion: boolean; rowKey: string; children: ReactNode; summary?: boolean;
45
+ }) {
46
+ const ref = useRef<HTMLDivElement>(null);
47
+ const [present, setPresent] = useState(!hidden);
48
+ const previous = useRef(hidden);
49
+ if (!hidden && !present) setPresent(true);
50
+ useLayoutEffect(() => {
51
+ const el = ref.current;
52
+ const changed = previous.current !== hidden;
53
+ previous.current = hidden;
54
+ if (!el) return;
55
+ if (!changed || instant || !motion) { setPresent(!hidden); return; }
56
+ const height = el.scrollHeight;
57
+ const animation = el.animate([
58
+ { height: `${hidden ? height : 0}px`, opacity: hidden ? 1 : 0 },
59
+ { height: `${hidden ? 0 : height}px`, opacity: hidden ? 0 : 1 },
60
+ ], { duration: 220, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'both' });
61
+ el.dispatchEvent(new CustomEvent('reader-layout-start', { bubbles: true }));
62
+ let released = false;
63
+ const release = () => { if (!released) { released = true; el.dispatchEvent(new CustomEvent('reader-layout-end', { bubbles: true })); } };
64
+ // Do NOT cancel here. `fill: 'both'` is what currently holds the cell at its
65
+ // collapsed size; cancelling on finish drops that fill before React has
66
+ // committed the removal, so the cell snaps back to full height for one frame
67
+ // — the flash before it disappears. The state change alone retires it; the
68
+ // cleanup below cancels the animation once that has actually rendered.
69
+ let settled = false;
70
+ const settle = () => {
71
+ if (settled) return;
72
+ settled = true;
73
+ setPresent(!hidden);
74
+ release();
75
+ };
76
+ animation.onfinish = settle;
77
+ // \`fill: 'both'\` pins the opening keyframe for the whole run, so if this
78
+ // animation never advances the cell stays at height 0 and opacity 0: the row is
79
+ // in the DOM but reads as "clicked it and nothing happened". A cancelled
80
+ // animation, or one inside a subtree the compositor skips, never fires
81
+ // onfinish. Fall back to the state change on a deadline; the cleanup then
82
+ // cancels the animation and drops the fill.
83
+ const deadline = window.setTimeout(settle, 220 + 240);
84
+ return () => { window.clearTimeout(deadline); animation.cancel(); release(); };
85
+ // `present` is a dependency so that committing it re-runs this effect: the
86
+ // cleanup then cancels the finished animation *after* the removal has been
87
+ // applied, which is the only moment the fill can be dropped safely. Without
88
+ // it the animation was never cancelled at all and every folded cell kept one.
89
+ }, [hidden, instant, motion, present]);
90
+ return <div ref={ref} className={css.flowCell} data-flow-key={rowKey} data-flow-summary={summary || undefined}
91
+ hidden={hidden && !present} aria-hidden={hidden || undefined} {...(hidden ? { inert: '' } : {})}>
92
+ {present && <div className={css.flowCellInner}>{children}</div>}
93
+ </div>;
94
+ }
95
+
96
+ export function ChoreographedFlow({ frame, motion, enabled, urgent, open, onOpenChange, processOpen, renderStep, id }: {
97
+ frame: PresentationFrame; motion: boolean; enabled: boolean; urgent: boolean;
98
+ open: Readonly<Record<string, boolean>>; onOpenChange: (key: string, value: boolean) => void;
99
+ processOpen: boolean; renderStep: (step: LiveStep, folded: boolean) => ReactNode; id: string;
100
+ }) {
101
+ const root = useRef<HTMLDivElement>(null);
102
+ const stream = useContext(StreamMotionContext);
103
+ const latest = useRef(frame); latest.current = frame;
104
+ const [visible, setVisible] = useState(() => !document.hidden);
105
+ const [state, setState] = useState<Transaction>(() => idle(frame));
106
+ const bypass = !motion || !enabled || urgent || !visible || !processOpen || (state.phase !== 'idle' && Object.values(open).some(Boolean));
107
+ // Render-time adjustment prevents even one paint of the new authoritative layout.
108
+ if ((bypass || containsNewUser(state.shown.items, frame.items)) && (state.phase !== 'idle' || state.shown !== frame)) {
109
+ setState(idle(frame));
110
+ } else if (state.phase === 'idle' && state.shown !== frame) {
111
+ const retiring = retiringKeys(state.shown.items, frame.items, open);
112
+ setState(retiring.length ? { ...state, phase: 'collapse', target: frame, retiring,
113
+ beforeKeys: new Set(flowRows(state.shown.items).map(row => row.key)) } : idle(frame));
114
+ }
115
+ useEffect(() => {
116
+ const update = () => setVisible(!document.hidden);
117
+ document.addEventListener('visibilitychange', update);
118
+ return () => document.removeEventListener('visibilitychange', update);
119
+ }, []);
120
+ useLayoutEffect(() => {
121
+ if (state.phase !== 'collapse') return;
122
+ const el = root.current;
123
+ if (!el) return;
124
+ const retiring = new Set(state.retiring);
125
+ const animations: Animation[] = [];
126
+ el.dispatchEvent(new CustomEvent('reader-layout-start', { bubbles: true }));
127
+ for (const cell of el.querySelectorAll<HTMLElement>(':scope > [data-flow-key]')) {
128
+ const key = cell.dataset.flowKey!;
129
+ const isHeader = cell.dataset.flowSummary && !state.beforeKeys.has(key);
130
+ if (!retiring.has(key) && !isHeader) continue;
131
+ const height = cell.getBoundingClientRect().height;
132
+ animations.push(cell.animate(isHeader ? [
133
+ { height: '0px', opacity: 0 }, { height: `${height}px`, opacity: 1 },
134
+ ] : [
135
+ { height: `${height}px`, opacity: 1 },
136
+ { height: `${height * .5}px`, opacity: .85, offset: .5 },
137
+ { height: '0px', opacity: 0 },
138
+ ], { duration: FOLD_TIMING.collapse, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'both' }));
139
+ }
140
+ let cancelled = false;
141
+ // Coalesce everything received during shrink, once. No animation restarts.
142
+ // Idempotent and phase-checked: the promise and the deadline race, and
143
+ // whichever arrives first wins while the other becomes a no-op.
144
+ let advanced = false;
145
+ const advance = () => {
146
+ if (cancelled || advanced) return;
147
+ advanced = true;
148
+ setState(current => current.phase === 'collapse' ? { ...current, phase: 'count', target: latest.current } : current);
149
+ };
150
+ // `finished` rejects when an animation is cancelled and can stay pending when a
151
+ // compositor never runs it to completion, so the promise alone cannot be the only
152
+ // way out of this phase. Without a deadline a stalled phase would hold the frame
153
+ // source at `shown` and keep the reveal paused, which reads as a frozen turn
154
+ // until the view remounts.
155
+ const deadline = window.setTimeout(advance, FOLD_TIMING.collapse + FOLD_TIMING.watchdogSlack);
156
+ Promise.all(animations.map(animation => animation.finished)).then(advance, advance);
157
+ return () => {
158
+ cancelled = true;
159
+ window.clearTimeout(deadline);
160
+ animations.forEach(animation => animation.cancel());
161
+ el.dispatchEvent(new CustomEvent('reader-layout-end', { bubbles: true }));
162
+ };
163
+ }, [state.phase]);
164
+ useEffect(() => {
165
+ if (state.phase !== 'count' && state.phase !== 'settle' && state.phase !== 'reveal') return;
166
+ const phase = state.phase;
167
+ let done = false;
168
+ const finish = () => {
169
+ if (done) return;
170
+ done = true;
171
+ setState(current => {
172
+ if (current.phase !== phase) return current;
173
+ if (phase === 'count') return { ...current, phase: 'settle' };
174
+ if (phase === 'settle') return { ...current, phase: 'reveal' };
175
+ return idle(current.target);
176
+ });
177
+ };
178
+ // Count visible presentation time, not a timer that can expire before a
179
+ // busy browser paints. Each attention phase must get actual rendered frames.
180
+ let elapsed = 0;
181
+ let previous: number | undefined;
182
+ let raf = 0;
183
+ const tick = (now: number) => {
184
+ if (previous !== undefined) elapsed += Math.min(40, now - previous);
185
+ previous = now;
186
+ if (elapsed < FOLD_TIMING[phase]) { raf = requestAnimationFrame(tick); return; }
187
+ finish();
188
+ };
189
+ raf = requestAnimationFrame(tick);
190
+ // A hidden tab gets no frames at all, so the frame counter above would never
191
+ // reach its budget and the phase would hold forever. The wall-clock deadline
192
+ // advances it anyway; a non-idle phase is what freezes the turn.
193
+ const deadline = window.setTimeout(finish, FOLD_TIMING[phase] + FOLD_TIMING.watchdogSlack);
194
+ return () => { window.clearTimeout(deadline); cancelAnimationFrame(raf); };
195
+ }, [state.phase]);
196
+ useLayoutEffect(() => {
197
+ if (state.phase !== 'reveal' || !root.current) return;
198
+ const el = root.current;
199
+ el.dispatchEvent(new CustomEvent('reader-layout-start', { bubbles: true }));
200
+ // Opacity alone still inserts the new layout in one frame. Open real space
201
+ // first; text stays paused until this entry finishes, then catches up.
202
+ const animations = Array.from(el.children).filter(child => !(child as HTMLElement).hidden && !state.beforeKeys.has((child as HTMLElement).dataset.flowKey!))
203
+ .map(child => child.animate([
204
+ { height: '0px', opacity: 0 },
205
+ { height: `${child.getBoundingClientRect().height}px`, opacity: 1 },
206
+ ], { duration: FOLD_TIMING.reveal, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'both' }));
207
+ return () => {
208
+ animations.forEach(animation => animation.cancel());
209
+ el.dispatchEvent(new CustomEvent('reader-layout-end', { bubbles: true }));
210
+ };
211
+ }, [state.phase]);
212
+
213
+ const blocked = state.phase === 'collapse' || state.phase === 'count' || state.phase === 'settle';
214
+ const source = state.phase === 'idle' || state.phase === 'collapse' ? state.shown : state.target;
215
+ const rows = state.phase === 'collapse' ? collapseRows(state.shown.items, state.target.items) : flowRows(source.items);
216
+ return <FlowSnapshot.Provider value={source.snapshot}>
217
+ <StreamMotionContext.Provider value={{ ...stream, paused: state.phase !== 'idle', resumed: state.phase === 'reveal' }}>
218
+ <div id={id} ref={root} className={css.choreographedFlow} data-reader-flow data-reader-transition={state.phase} data-ud-motion="fold-choreography">
219
+ {rows.map(row => {
220
+ if (row.kind === 'summary') return <FlowCell key={row.key} rowKey={row.key} hidden={false} instant motion={motion} summary>
221
+ <Summary item={row.item} open={processOpen && !!open[row.key]} onChange={value => onOpenChange(row.key, value)} motion={motion && state.phase !== 'collapse'} />
222
+ </FlowCell>;
223
+ const hidden = !!row.foldKey && (!processOpen || !open[row.foldKey]) || (blocked && state.phase !== 'collapse' && !state.beforeKeys.has(row.key));
224
+ // Keep retiring child props unchanged until it has actually shrunk.
225
+ return <FlowCell key={row.key} rowKey={row.key} hidden={hidden} instant={state.phase !== 'idle'} motion={motion}>
226
+ {renderStep(row.step, !!row.foldKey)}
227
+ </FlowCell>;
228
+ })}
229
+ </div>
230
+ </StreamMotionContext.Provider>
231
+ </FlowSnapshot.Provider>;
232
+ }
@@ -0,0 +1,23 @@
1
+ import { useRef } from 'react';
2
+ import css from './Reader.module.css';
3
+ import { Disclosure } from './motion.js';
4
+ import { FoldSummaryText } from './LiveFold.js';
5
+ import { foldSummary } from './live-turn.js';
6
+ import { DiffStat } from './DiffPanel.js';
7
+ import type { LiveStep } from './live-turn.js';
8
+
9
+ /** Keep process counts discoverable after the live-fold presentation retires. */
10
+ export function ClosedProcessSummary({ steps, open, onChange, controls }: {
11
+ steps: readonly LiveStep[]; open: boolean; onChange: (value: boolean) => void; controls: string;
12
+ }) {
13
+ const button = useRef<HTMLButtonElement>(null);
14
+ if (!steps.length) return null;
15
+ const summary = foldSummary(steps);
16
+ return <div className={css.closedProcessSummary} data-reader-closed-summary={summary}>
17
+ <div className={css.summaryRow}>
18
+ <Disclosure open={open} onChange={onChange} controls={controls} buttonRef={button} ariaLabel="本轮过程详情"
19
+ label={<FoldSummaryText summary={summary} motion={false} />} />
20
+ <DiffStat steps={steps} label={summary} />
21
+ </div>
22
+ </div>;
23
+ }
@@ -0,0 +1,124 @@
1
+ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { DiffBlock, diffTotals } from '@deepseek-ai/dsh-client-ui-primitives';
4
+ import css from './Reader.module.css';
5
+ import { diffBlockLabels } from './primitive-labels.js';
6
+ import { FOLD_TIMING } from './fold-choreography.js';
7
+ import { foldDiffHunks } from './tool-activity.js';
8
+ import type { LiveStep } from './live-turn.js';
9
+
10
+ /**
11
+ * Reveals and withdraws its child by animating real height, in the plugin's own
12
+ * motion language. Height is the animated property rather than transform because
13
+ * the panel pushes the transcript down and the reading scroll measures that
14
+ * growth; the curve and duration are the fold choreography's, so this reads as
15
+ * the same gesture as opening a digest.
16
+ *
17
+ * Closing animates too: the child stays mounted until the shrink finishes, which
18
+ * is why the owner keeps it alive on an `open` flag instead of unmounting it.
19
+ */
20
+ function MorphPanel({ open, children, onSettled, onClosed }: {
21
+ open: boolean; children: ReactNode; onSettled?: (element: HTMLElement) => void; onClosed: () => void;
22
+ }) {
23
+ const ref = useRef<HTMLDivElement>(null);
24
+ useLayoutEffect(() => {
25
+ const element = ref.current;
26
+ if (!element) return;
27
+ element.dispatchEvent(new CustomEvent('reader-layout-start', { bubbles: true }));
28
+ let released = false;
29
+ const release = () => { if (!released) { released = true; element.dispatchEvent(new CustomEvent('reader-layout-end', { bubbles: true })); } };
30
+ if (typeof element.animate !== 'function') { release(); if (!open) onClosed(); return; }
31
+ const height = element.scrollHeight;
32
+ const frames = open
33
+ ? [{ height: '0px', opacity: 0, transform: 'translateY(-6px)' },
34
+ { height: `${height}px`, opacity: 1, transform: 'translateY(0)' }]
35
+ : [{ height: `${height}px`, opacity: 1 },
36
+ { height: '0px', opacity: 0 }];
37
+ const animation = element.animate(frames, {
38
+ duration: FOLD_TIMING.reveal + FOLD_TIMING.settle,
39
+ easing: 'cubic-bezier(.4,0,.2,1)',
40
+ });
41
+ let settled = false;
42
+ const settle = () => {
43
+ if (settled) return;
44
+ settled = true;
45
+ release();
46
+ if (open) {
47
+ element.style.height = 'auto';
48
+ element.style.overflow = 'visible';
49
+ onSettled?.(element);
50
+ } else {
51
+ onClosed();
52
+ }
53
+ };
54
+ animation.onfinish = settle;
55
+ const deadline = window.setTimeout(settle, FOLD_TIMING.reveal + FOLD_TIMING.settle + 240);
56
+ return () => { window.clearTimeout(deadline); animation.cancel(); release(); };
57
+ }, [open]);
58
+ return <div ref={ref} className={css.diffOverlay}>{children}</div>;
59
+ }
60
+
61
+ /**
62
+ * Keeps a newly opened panel on screen.
63
+ *
64
+ * Opening grows the row downward, so a badge near the fold reveals its card below
65
+ * the viewport and the reader has to chase it. The scroll container is not known
66
+ * here, so the nearest scrollable ancestor is found and the card's head is brought
67
+ * to the top of it — the same thing a jump-to-anchor does.
68
+ */
69
+ function revealPanel(element: HTMLElement): void {
70
+ let port: HTMLElement | null = element.parentElement;
71
+ while (port && port.scrollHeight <= port.clientHeight + 1) port = port.parentElement;
72
+ if (!port) { element.scrollIntoView({ block: 'nearest' }); return; }
73
+ const gap = element.getBoundingClientRect().top - port.getBoundingClientRect().top;
74
+ // Already fully visible: leave the scroll alone rather than jumping on every open.
75
+ if (gap >= 0 && element.getBoundingClientRect().bottom <= port.getBoundingClientRect().bottom) return;
76
+ port.scrollTop += gap - 8;
77
+ }
78
+
79
+ /**
80
+ * Line counts for everything a folded run of steps changed.
81
+ *
82
+ * A run that touched no files renders nothing, so an ordinary process digest is
83
+ * unchanged. Clicking the counts opens the same diff surface the official tool
84
+ * row uses — one file at a time, red removed / green added, expandable.
85
+ */
86
+ export function DiffStat({ steps, label }: { steps: readonly LiveStep[]; label: string }) {
87
+ const hunks = useMemo(() => foldDiffHunks(steps), [steps]);
88
+ const [open, setOpen] = useState(false);
89
+ const [visible, setVisible] = useState(false);
90
+ const totals = useMemo(() => diffTotals(hunks), [hunks]);
91
+ const [active, setActive] = useState(0);
92
+ if (!hunks.length) return null;
93
+ const current = hunks[Math.min(active, hunks.length - 1)]!;
94
+ return <>
95
+ <span className={css.diffStatRoot}>
96
+ <button type="button" className={css.diffStatButton} aria-expanded={open}
97
+ aria-label={`${label} · 改动 ${totals.added} 行,删除 ${totals.removed} 行`}
98
+ onClick={event => {
99
+ event.stopPropagation();
100
+ if (open) { setOpen(false); return; }
101
+ setVisible(true);
102
+ setOpen(true);
103
+ }}>
104
+ <span className={css.diffCaret} aria-hidden>{open ? '\u25be' : '\u25b8'}</span>
105
+ {totals.added > 0 && <span className={css.diffAdded}>+{totals.added}</span>}
106
+ {totals.removed > 0 && <span className={css.diffRemoved}>-{totals.removed}</span>}
107
+ </button>
108
+ </span>
109
+ {visible && <div className={css.diffOverlayRow}>
110
+ <MorphPanel open={open} onSettled={revealPanel} onClosed={() => setVisible(false)}>
111
+ {hunks.length > 1 && <div className={css.diffTabs}>
112
+ {hunks.map((hunk, index) => <button key={`${hunk.path}:${index}`} type="button"
113
+ className={css.diffTab} data-active={index === active || undefined}
114
+ onClick={() => setActive(index)} title={hunk.path}>
115
+ {hunk.path.split(/[/\\]+/).filter(Boolean).slice(-1)[0] ?? hunk.path}
116
+ </button>)}
117
+ </div>}
118
+ <div className={css.diffScrollArea}>
119
+ <DiffBlock diffs={[current]} maxLines={36} labels={diffBlockLabels} />
120
+ </div>
121
+ </MorphPanel>
122
+ </div>}
123
+ </>;
124
+ }
@@ -0,0 +1,43 @@
1
+ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import { FOLD_TIMING } from './fold-choreography.js';
3
+ import css from './Reader.module.css';
4
+
5
+ /** Values are presentation commits, never live network counters. */
6
+ export function SubtleNumberRoll({ value, motion }: { value: number; motion: boolean }) {
7
+ const [outgoing, setOutgoing] = useState<number | null>(null);
8
+ const previous = useRef(value);
9
+ useLayoutEffect(() => {
10
+ if (value === previous.current) return;
11
+ const old = previous.current;
12
+ previous.current = value;
13
+ if (!motion) { setOutgoing(null); return; }
14
+ setOutgoing(old);
15
+ const timer = window.setTimeout(() => setOutgoing(null), FOLD_TIMING.count);
16
+ return () => window.clearTimeout(timer);
17
+ }, [value, motion]);
18
+ return <span className={css.numberRollRoot} aria-hidden="true">
19
+ <span className={css.numberRollSizer}>{value}</span>
20
+ {outgoing !== null && <span key={`out-${outgoing}`} className={`${css.numberRollDigit} ${css.numberRollExit}`}>{outgoing}</span>}
21
+ <span key={`cur-${value}`} className={`${css.numberRollDigit} ${outgoing !== null ? css.numberRollEnter : ''}`}>{value}</span>
22
+ </span>;
23
+ }
24
+
25
+ export function FoldSummaryText({ summary, motion }: { summary: string; motion: boolean }) {
26
+ const parts = useMemo(() => {
27
+ const regex = /([^\d]+)(\d+)/g;
28
+ const result: { text: string; number?: number }[] = [];
29
+ let end = 0;
30
+ for (const match of summary.matchAll(regex)) {
31
+ result.push({ text: match[1]!, number: Number(match[2]) });
32
+ end = match.index! + match[0].length;
33
+ }
34
+ if (end < summary.length) result.push({ text: summary.slice(end) });
35
+ return result;
36
+ }, [summary]);
37
+ return <span className={css.foldSummary} data-reader-fold-summary={summary} aria-label={summary}>
38
+ {parts.map(part => <span key={part.text} className={css.foldSummaryPart}>
39
+ <span>{part.text}</span>
40
+ {part.number !== undefined && <SubtleNumberRoll value={part.number} motion={motion} />}
41
+ </span>)}
42
+ </span>;
43
+ }