@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,878 @@
1
+ import type {} from '@deepseek-ai/dsh-session-turn-outline/types';
2
+ import { Fragment, memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
+ import type { ReactNode, RefObject } from 'react';
4
+ import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from '@deepseek-ai/dsh-client-ui-chat/client';
5
+ import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives';
6
+ import { BlockBoundary, Blocks, contentBlocks, CopyAnswer, UserMessageActions } from './Blocks.js';
7
+ import { ReasoningCard } from './ReasoningCard.js';
8
+ import { ToolActivity, ToolMedia } from './ToolActivity.js';
9
+ import { OfficialActions, OfficialNode, OfficialTail } from './OfficialContent.js';
10
+ import { preparingLabel, readerFlow } from './tool-activity.js';
11
+ import { Disclosure, ProcessFragment, RetiringContent, StatusText, useMotionAllowed, usePinnedSelection, useReadingScroll } from './motion.js';
12
+ import { StreamMotionContext } from './streaming.js';
13
+ import { assistantSegments, boundaryOf, forkAnchorSeq, groupNodes, hasProcessContent, hasVisibleBody, isEarlierNarration, processChoiceKey, processExpanded, terminalLabel } from './projection.js';
14
+ import { basename, createProducedFileMentions, dirname, getTurnDeliverables, showDeliverablesRow } from './deliverables.js';
15
+ import { deliverableOpenModeOf, type DeliverableOpenMode } from './open-file.js';
16
+ import { asReadonlyArray, pendingSubmissionImages, type PendingSubmissionEcho } from './pending-submission.js';
17
+ import { WaitingStatus } from './WaitingStatus.js';
18
+ import { handsBackToModel, waitingAnchor } from './waiting-clock.js';
19
+ import { ContextInjectionRow } from './native/ContextInjectionRow.js';
20
+ import { TimelineRail } from './TimelineRail.js';
21
+ import { landTurn, scrollerOf } from './conversation-scroll.js';
22
+ import { mergeTimelineItems, type TimelineItem } from './timeline.js';
23
+ import { presentLiveTurn, segmentLiveTurn } from './live-turn.js';
24
+ import type { LiveStep } from './live-turn.js';
25
+ import { bubblesOf, frostedGlassOf } from './fold-intensity.js';
26
+ import { ChoreographedFlow, useFlowChat } from './ChoreographedFlow.js';
27
+ import { ClosedProcessSummary } from './ClosedProcessSummary.js';
28
+ import { StickyLane } from './StickyLane.js';
29
+ import type { ReaderGroup, TurnBoundary } from './projection.js';
30
+ import type { BlockRenderProps, ReaderProps } from './types.js';
31
+ import css from './Reader.module.css';
32
+ import { markdownLabels, truncatedJsonLabel } from './primitive-labels.js';
33
+
34
+ function isNode<K extends ChatNodeKind>(node: ChatConversationViewNode, kind: K): node is ChatNode<K> {
35
+ return node.kind === kind;
36
+ }
37
+
38
+ function cleanErrorMessage(raw: string | undefined): string {
39
+ if (!raw) return '模型服务暂时无响应或连接中断,请稍后重试。';
40
+ let str = raw.trim();
41
+ if (str.includes('"error"') || str.startsWith('{')) {
42
+ try {
43
+ const idx = str.indexOf('{');
44
+ const parsed = JSON.parse(str.slice(idx));
45
+ const msg = parsed?.error?.message || parsed?.message || parsed?.error;
46
+ if (typeof msg === 'string') str = msg;
47
+ } catch {
48
+ // keep
49
+ }
50
+ }
51
+ return str;
52
+ }
53
+
54
+ type SeatProps = BlockRenderProps & Pick<ReaderProps, 'useChat'> & {
55
+ nodeKey: string; boundary: TurnBoundary; pinned?: boolean; processOpen?: boolean;
56
+ };
57
+
58
+ const CompactionDivider = memo(function CompactionDivider({ data }: {
59
+ data: { summary?: string | null; shadowedItemCount?: number | null; shadowedTokenCount?: number | null } | null;
60
+ }) {
61
+ const [open, setOpen] = useState(false);
62
+ if (!data) return null;
63
+
64
+ const hasSummary = typeof data.summary === 'string' && data.summary.trim().length > 0;
65
+ const items = data.shadowedItemCount;
66
+ const tokens = data.shadowedTokenCount;
67
+
68
+ let label = '已压缩历史上下文';
69
+ if (items && tokens) {
70
+ const kTokens = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
71
+ label = `已压缩 ${items} 条上下文 · 释放约 ${kTokens} tokens`;
72
+ } else if (items) {
73
+ label = `已压缩 ${items} 条上下文`;
74
+ } else if (tokens) {
75
+ const kTokens = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
76
+ label = `已压缩上下文 · 释放约 ${kTokens} tokens`;
77
+ }
78
+
79
+ return (
80
+ <div className={css.compactionRow} data-reader-compaction>
81
+ <div className={css.compactionLine}>
82
+ {hasSummary ? (
83
+ <button
84
+ type="button"
85
+ className={`${css.compactionPill} ${css.compactionButton}`}
86
+ onClick={() => setOpen(v => !v)}
87
+ aria-expanded={open}
88
+ title={open ? '收起历史记忆摘要' : '展开查看此节点提炼的记忆摘要'}
89
+ >
90
+ <svg className={css.compactionIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
91
+ <path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2z" strokeWidth="1.2" />
92
+ <path d="M8 5v3.2l2 1.8" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
93
+ </svg>
94
+ <span>{label}</span>
95
+ <span className={css.compactionToggle}>{open ? '收起备忘' : '查看备忘'}</span>
96
+ </button>
97
+ ) : (
98
+ <span className={css.compactionPill}>
99
+ <svg className={css.compactionIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
100
+ <path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2z" strokeWidth="1.2" />
101
+ <path d="M8 5v3.2l2 1.8" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
102
+ </svg>
103
+ <span>{label}</span>
104
+ </span>
105
+ )}
106
+ </div>
107
+ {open && hasSummary && (
108
+ <div className={css.compactionSummaryBox} data-reader-anchor>
109
+ <div className={css.compactionSummaryHeader}>前期对话要点备忘</div>
110
+ <MarkdownText text={data.summary!} labels={markdownLabels} />
111
+ </div>
112
+ )}
113
+ </div>
114
+ );
115
+ });
116
+
117
+ const ProcessNode = memo(function ProcessNode({ useChat, t, nodeKey, open, motion, onRead, returnFocusTo }: Pick<ReaderProps, 'useChat' | 't'> & {
118
+ nodeKey: string; open: boolean; motion: boolean; onRead: () => void; returnFocusTo: RefObject<HTMLButtonElement>;
119
+ }) {
120
+ const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
121
+ if (!node || node.visibility === 'hidden') return null;
122
+ let content: ReactNode = null;
123
+ if (isNode(node, 'context')) content = <ContextInjectionRow {...node.data} t={t} />;
124
+ else if (isNode(node, 'system-prompt')) content = <details className={css.detail}><summary>系统提示词</summary><pre className={css.systemPrompt}>{node.data.text}</pre></details>;
125
+ else if (isNode(node, 'model-retry')) content = <JsonBlock label="模型重试记录" payload={node.data.attempts} truncatedLabel={truncatedJsonLabel} />;
126
+ else if (isNode(node, 'manual-compaction')) {
127
+ if (node.data.command.outcome?.kind === 'error') {
128
+ content = <div className={css.error} role="alert">
129
+ <svg className={css.errorIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
130
+ <circle cx="8" cy="8" r="6.5" strokeWidth="1.2" />
131
+ <path d="M8 5v3.5M8 11.2h.01" strokeWidth="1.5" strokeLinecap="round" />
132
+ </svg>
133
+ <div className={css.errorCopy}>
134
+ <div className={css.errorTitle}><strong>上下文压缩未成功</strong></div>
135
+ <p className={css.errorMessage}>{node.data.command.outcome.text}</p>
136
+ </div>
137
+ </div>;
138
+ } else {
139
+ content = node.data.compaction ? <CompactionDivider data={node.data.compaction} /> : null;
140
+ }
141
+ }
142
+ else if (isNode(node, 'compaction')) content = <CompactionDivider data={node.data} />;
143
+ else if (isNode(node, 'command')) content = <JsonBlock label="命令记录" payload={node.data} truncatedLabel={truncatedJsonLabel} />;
144
+ return content && <ProcessFragment open={open} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>{content}</ProcessFragment>;
145
+ });
146
+
147
+ const AssistantNode = memo(function AssistantNode({ useChat, nodeKey, boundary, processOpen = false, pinned = false, folded = false, partStart, motion, onRead, returnFocusTo, ...render }: SeatProps & {
148
+ motion: boolean; onRead: () => void; returnFocusTo: RefObject<HTMLButtonElement>; partStart?: number; folded?: boolean;
149
+ }) {
150
+ const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
151
+ if (!node || node.visibility === 'hidden' || !isNode(node, 'assistant-step')) return null;
152
+ const data = node.data;
153
+ const parts = assistantSegments(data.blocks);
154
+ const earlier = isEarlierNarration(data, boundary);
155
+ const hasToolCalls = data.blocks.some(block => block.kind === 'tool-call');
156
+ const isProcessStep = earlier || folded || hasToolCalls || (boundary.latestStep > 0 && data.step < boundary.latestStep);
157
+ const body = data.blocks.filter(block => block.kind !== 'reasoning' && block.kind !== 'tool-call');
158
+ const visible = partStart === undefined ? parts : parts.filter(part => part.start === partStart);
159
+ return <>{visible.map(part => {
160
+ const index = parts.findIndex(item => item.start === part.start);
161
+ const last = index === parts.length - 1;
162
+ return part.kind === 'reasoning'
163
+ ? <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>
164
+ <ReasoningCard step={data.step} active={processOpen && boundary.status === 'open' && data.step === boundary.latestStep} motion={motion} selected={pinned} onRead={onRead}>
165
+ <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running' && last && data.blocks.at(-1)?.kind === 'reasoning'}
166
+ holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
167
+ </ReasoningCard>
168
+ </ProcessFragment>
169
+ : isProcessStep ? <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey}>
170
+ <article className={css.processCommentary}>
171
+ <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running'} holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
172
+ </article>
173
+ </ProcessFragment>
174
+ : hasVisibleBody(part.blocks) && <RetiringContent key={part.start} visible={pinned || processOpen || (!earlier && !folded)}>
175
+ <article className={css.answer} data-reader-answer data-reader-anchor data-reader-key={nodeKey} data-reader-source-start={part.start} data-answer-status={data.status} data-answer-phase={earlier || folded ? 'process' : 'body'}>
176
+ <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running'} holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
177
+ {last && data.status === 'interrupted' && <span className={css.stopped}>已停止</span>}
178
+ {last && !earlier && !folded && data.status !== 'running' && boundary.status === 'closed' && (
179
+ <CopyAnswer blocks={body} onFork={(() => {
180
+ // The fork anchor must be the durable closing message seq (same as
181
+ // the official turn-tail branch). AssistantChatData carries no seq
182
+ // of its own; passing it would fork the whole session instead.
183
+ const anchor = forkAnchorSeq([data.finalNode, { seq: render.forkSeq }]);
184
+ return render.forkAt && anchor !== undefined ? () => render.forkAt!(anchor) : undefined;
185
+ })()} metrics={render.metrics} extraActions={<OfficialActions official={render.official} messageId={data.finalNode?.messageId} />} />
186
+ )}
187
+ </article>
188
+ </RetiringContent>;
189
+ })}</>;
190
+ });
191
+
192
+ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, processOpen = false, ...render }: SeatProps) {
193
+ const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
194
+ if (!node || node.visibility === 'hidden') return null;
195
+ if (isNode(node, 'user') || isNode(node, 'steering')) {
196
+ const blocks = contentBlocks(node.data.content);
197
+ const imageBlocks = blocks.filter(b => b.kind === 'image');
198
+ const otherBlocks = blocks.filter(b => b.kind !== 'image');
199
+ const text = otherBlocks.filter((block): block is Extract<typeof block, { kind: 'text' }> => block.kind === 'text').map(block => block.text).join('\n\n');
200
+ const time = node.data.time;
201
+ return <div className={css.userCluster} data-reader-anchor data-reader-key={nodeKey}>
202
+ {node.kind === 'steering' && <p className={css.meta}>补充消息</p>}
203
+ {imageBlocks.length > 0 && <div className={css.userImages}>
204
+ <Blocks {...render} blocks={imageBlocks} source="user" />
205
+ </div>}
206
+ {otherBlocks.length > 0 && <div className={css.user}>
207
+ <Blocks {...render} blocks={otherBlocks} source="user" />
208
+ </div>}
209
+ <UserMessageActions text={text} time={time} />
210
+ </div>;
211
+ }
212
+ if (isNode(node, 'assistant-step')) return null;
213
+ if (isNode(node, 'tool-call')) return <ToolMedia {...render} block={node.data.root} />;
214
+ if (isNode(node, 'turn-error')) return <div className={css.error} role="alert" data-reader-anchor>
215
+ <svg className={css.errorIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
216
+ <circle cx="8" cy="8" r="6.5" strokeWidth="1.2" />
217
+ <path d="M8 5v3.5M8 11.2h.01" strokeWidth="1.5" strokeLinecap="round" />
218
+ </svg>
219
+ <div className={css.errorCopy}>
220
+ <div className={css.errorTitle}>
221
+ <strong>本轮运行失败</strong>
222
+ {node.data.code && <code className={css.errorCode}>{node.data.code}</code>}
223
+ </div>
224
+ <p className={css.errorMessage}>{cleanErrorMessage(node.data.message)}</p>
225
+ </div>
226
+ </div>;
227
+ if (isNode(node, 'turn-max-tokens')) return <div className={css.notice}>已到达输出长度限制,回答尚未完整。</div>;
228
+ if (isNode(node, 'model-retry')) return node.data.current.retryState === 'scheduled'
229
+ ? <div className={css.notice} role="status">模型请求未成功,正在等待重试。详情保留在执行过程中。</div> : null;
230
+ if (isNode(node, 'command') && render.official) return <OfficialNode {...render} node={node} fallback={<JsonBlock label="命令记录" payload={node.data} truncatedLabel={truncatedJsonLabel} />} />;
231
+ if (isNode(node, 'command')) {
232
+ if (node.data.outcome?.kind === 'error') return <div className={css.error} role="alert">
233
+ <svg className={css.errorIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
234
+ <circle cx="8" cy="8" r="6.5" strokeWidth="1.2" />
235
+ <path d="M8 5v3.5M8 11.2h.01" strokeWidth="1.5" strokeLinecap="round" />
236
+ </svg>
237
+ <div className={css.errorCopy}>
238
+ <div className={css.errorTitle}><strong>命令执行未成功</strong></div>
239
+ <p className={css.errorMessage}>{node.data.outcome.text ?? node.data.name ?? '查看原对话中的命令记录'}</p>
240
+ </div>
241
+ </div>;
242
+ return node.data.outcome?.text ? <MarkdownText text={node.data.outcome.text} labels={markdownLabels} /> : null;
243
+ }
244
+ if (isNode(node, 'manual-compaction') || isNode(node, 'compaction')) return null;
245
+ if (node.kind === 'context' || node.kind === 'turn-tail' || node.kind === 'system-prompt' || node.kind === 'turn-process') return null;
246
+ // No 'command-input' branch: that string is not a chat node kind in any released
247
+ // host (it appears nowhere in the installed packages), so a slash command arrives
248
+ // as 'command' above and is rendered there. Keeping the branch only made the file
249
+ // look like it handled a case that cannot occur.
250
+ return <OfficialNode {...render} node={node} fallback={<div className={css.unknown} data-reader-anchor>
251
+ <p>此记录类型暂未接入阅读页:{node.kind}</p>
252
+ {render.official && <button type="button" className={css.textButton} onClick={() => render.official!.openView('chat', node.key)}>在对话中查看</button>}
253
+ <JsonBlock label="查看原始记录" payload={node.data} truncatedLabel={truncatedJsonLabel} />
254
+ </div>} />;
255
+ });
256
+
257
+ /**
258
+ * Sub-agents this turn dispatched, read from the host's own Turn-process row.
259
+ *
260
+ * The host already derives this from the dispatch tree (`subagentCount` on
261
+ * `TurnProcessChatData`), so counting calls here would only risk disagreeing with
262
+ * it. The row is otherwise hidden by the reader, which is why this is the one place
263
+ * its counters are read.
264
+ */
265
+ function subagentCount(snapshot: { nodes: { get(key: string): ChatConversationViewNode | undefined } }, keys: readonly string[]): number {
266
+ for (const key of keys) {
267
+ const node = snapshot.nodes.get(key);
268
+ if (!node || node.kind !== 'turn-process') continue;
269
+ const value = (node.data as { subagentCount?: unknown }).subagentCount;
270
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
271
+ }
272
+ return 0;
273
+ }
274
+
275
+ function GroupStatus({ group, sessionId, useChat, pending, motion }: Pick<ReaderProps, 'sessionId' | 'useChat'> & { group: ReaderGroup; motion: boolean; pending: { kind?: string } | undefined }) {
276
+ const text = useChat(snapshot => {
277
+ const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
278
+ if (turn?.status === 'closed') {
279
+ if (turn.end?.data.reason.kind !== 'completed') return '执行过程';
280
+ const elapsed = turn.start && turn.end ? Math.max(0, Math.round((turn.end.time - turn.start.time) / 1000)) : null;
281
+ return elapsed === null ? '执行过程' : elapsed < 60 ? `用时 ${elapsed} 秒` : `用时 ${Math.floor(elapsed / 60)} 分 ${elapsed % 60} 秒`;
282
+ }
283
+ if (turn?.status !== 'open') return '执行过程';
284
+ if (pending !== undefined) return '等待你的操作';
285
+ const current = turn.steps.at(-1)?.data.get('assistant-step');
286
+ const last = current?.blocks.at(-1);
287
+ if (current?.status === 'running' && last?.kind === 'tool-call') {
288
+ const spawned = subagentCount(snapshot, group.keys);
289
+ return spawned ? `${preparingLabel(last.name)}·${spawned} 个子代理` : preparingLabel(last.name);
290
+ }
291
+ for (let index = group.keys.length - 1; index >= 0; index--) {
292
+ const node = snapshot.nodes.get(group.keys[index]);
293
+ if (!node) continue;
294
+ if (isNode(node, 'tool-call') && !('kind' in node.data.root)) return '正在使用工具';
295
+ if (isNode(node, 'assistant-step') && node.data.status === 'running') {
296
+ const last = node.data.blocks.at(-1);
297
+ return last?.kind === 'reasoning' ? '正在思考' : last?.kind === 'text' ? '正在输出' : '正在准备回复';
298
+ }
299
+ }
300
+ return '正在处理';
301
+ });
302
+ const busy = useChat(snapshot => group.turn !== null && snapshot.timeline.turns.get(group.turn)?.status === 'open' && pending === undefined);
303
+ return <StatusText text={text} motion={motion} shimmer={busy} />;
304
+ }
305
+
306
+ const DeliverableChip = memo(function DeliverableChip({ path, openFile, revealFile, openMode }: {
307
+ path: string;
308
+ openFile?: (path: string) => Promise<void> | void;
309
+ revealFile?: (path: string) => Promise<void> | void;
310
+ openMode: DeliverableOpenMode;
311
+ }) {
312
+ const [status, setStatus] = useState<'idle' | 'opened' | 'copied' | 'revealed'>('idle');
313
+ const timer = useRef<ReturnType<typeof setTimeout>>();
314
+
315
+ const flash = (next: 'opened' | 'copied' | 'revealed') => {
316
+ setStatus(next);
317
+ clearTimeout(timer.current);
318
+ timer.current = setTimeout(() => setStatus('idle'), 1600);
319
+ };
320
+
321
+ const onOpen = (event: React.MouseEvent) => {
322
+ event.stopPropagation();
323
+ try {
324
+ openFile?.(path);
325
+ flash('opened');
326
+ } catch {
327
+ // fallback
328
+ }
329
+ };
330
+
331
+ const onReveal = (event: React.MouseEvent) => {
332
+ event.stopPropagation();
333
+ try {
334
+ if (revealFile) {
335
+ revealFile(path);
336
+ } else {
337
+ openFile?.(dirname(path));
338
+ }
339
+ flash('revealed');
340
+ } catch {
341
+ // fallback
342
+ }
343
+ };
344
+
345
+ const onCopy = (event: React.MouseEvent) => {
346
+ event.stopPropagation();
347
+ try {
348
+ void navigator.clipboard?.writeText(path);
349
+ flash('copied');
350
+ } catch {
351
+ // fallback
352
+ }
353
+ };
354
+
355
+ const name = basename(path);
356
+ const folder = dirname(path);
357
+
358
+ return (
359
+ <div className={css.deliverableChip} data-status={status} title={path}>
360
+ <button
361
+ type="button"
362
+ className={css.chipMain}
363
+ onClick={onOpen}
364
+ onDoubleClick={onOpen}
365
+ aria-label={openMode === 'sidebar' ? `在内置面板中打开 ${path}` : `用系统应用打开 ${path}`}
366
+ >
367
+ {status === 'opened' ? (
368
+ <svg className={css.statusIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
369
+ <path d="M3.5 8.5l3 3 6-7" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
370
+ </svg>
371
+ ) : (
372
+ <svg className={css.deliverableIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
373
+ <path d="M4 2.5h5l3 3V13.5H4V2.5z" strokeWidth="1.2" strokeLinejoin="round" />
374
+ <path d="M9 2.5v3h3" strokeWidth="1.2" strokeLinejoin="round" />
375
+ </svg>
376
+ )}
377
+ <span className={css.deliverableName}>
378
+ {status === 'opened' ? (openMode === 'sidebar' ? '已在面板打开' : '已在外部打开') : name}
379
+ </span>
380
+ </button>
381
+
382
+ <div className={css.chipActions} aria-label="文件操作">
383
+ <button
384
+ type="button"
385
+ className={css.chipActionBtn}
386
+ title={`在访达中定位所在目录 (${folder})`}
387
+ aria-label="在访达中显示所在目录"
388
+ onClick={onReveal}
389
+ >
390
+ {status === 'revealed' ? (
391
+ <svg className={css.actionIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
392
+ <path d="M3.5 8.5l3 3 6-7" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
393
+ </svg>
394
+ ) : (
395
+ <svg className={css.actionIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
396
+ <path d="M2 4.5h4l1.5 2H14v6.5H2V4.5z" strokeWidth="1.2" strokeLinejoin="round" />
397
+ </svg>
398
+ )}
399
+ </button>
400
+ <button
401
+ type="button"
402
+ className={css.chipActionBtn}
403
+ title="复制相对路径"
404
+ aria-label="复制相对路径"
405
+ onClick={onCopy}
406
+ >
407
+ {status === 'copied' ? (
408
+ <svg className={css.actionIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
409
+ <path d="M3.5 8.5l3 3 6-7" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
410
+ </svg>
411
+ ) : (
412
+ <svg className={css.actionIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
413
+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" strokeWidth="1.2" />
414
+ <path d="M4 10.5H3a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v1" strokeWidth="1.2" strokeLinecap="round" />
415
+ </svg>
416
+ )}
417
+ </button>
418
+ </div>
419
+ </div>
420
+ );
421
+ });
422
+
423
+ function DeliverablesRow({ deliverables, openFile, revealFile, openMode }: {
424
+ deliverables: readonly string[];
425
+ openFile?: (path: string) => Promise<void> | void;
426
+ revealFile?: (path: string) => Promise<void> | void;
427
+ openMode: DeliverableOpenMode;
428
+ }) {
429
+ const [folderStatus, setFolderStatus] = useState<'idle' | 'opened'>('idle');
430
+ const onOpenWorkspace = () => {
431
+ try {
432
+ openFile?.('.');
433
+ setFolderStatus('opened');
434
+ setTimeout(() => setFolderStatus('idle'), 1600);
435
+ } catch {
436
+ // ignore
437
+ }
438
+ };
439
+
440
+ return (
441
+ <div className={css.deliverablesRoot} data-reader-deliverables>
442
+ <span className={css.deliverablesLabel}>产物</span>
443
+ <div className={css.deliverablesLane}>
444
+ <div className={css.deliverablesRow}>
445
+ {deliverables.slice(0, 8).map(path => (
446
+ <DeliverableChip key={path} path={path} openFile={openFile} revealFile={revealFile} openMode={openMode} />
447
+ ))}
448
+ {deliverables.length > 8 && (
449
+ <span className={css.deliverablesMore}>
450
+ + {deliverables.length - 8} 个文件
451
+ </span>
452
+ )}
453
+ {deliverables.length > 1 && (
454
+ <button
455
+ type="button"
456
+ className={css.deliverablesShowFolder}
457
+ data-status={folderStatus}
458
+ onClick={onOpenWorkspace}
459
+ title="在访达中打开整个工作区目录"
460
+ >
461
+ {folderStatus === 'opened' && (
462
+ <svg className={css.statusIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
463
+ <path d="M3.5 8.5l3 3 6-7" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
464
+ </svg>
465
+ )}
466
+ <span>{folderStatus === 'opened' ? '已打开访达' : '在文件夹中显示'}</span>
467
+ </button>
468
+ )}
469
+ </div>
470
+ </div>
471
+ </div>
472
+ );
473
+ }
474
+
475
+ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys, selectedProcessKeys, isAwaitingModel = false, ...props }: ReaderProps & { group: ReaderGroup; motion: boolean; autoFold: boolean; pinnedKeys: readonly string[]; selectedProcessKeys: readonly string[]; isAwaitingModel?: boolean }) {
476
+ const snapshot = props.useChat(snapshot => snapshot);
477
+ const nodes = snapshot.nodes;
478
+ const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
479
+ const interaction = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
480
+ const sessionRunning = props.useSession(snapshot => snapshot.running);
481
+ const boundary = useMemo(() => boundaryOf(turn), [turn]);
482
+ const choiceKey = processChoiceKey(group.key, boundary);
483
+ const expansionChoice = props.useStore(state => state.expanded[choiceKey]);
484
+ const flowId = useId();
485
+ const processButton = useRef<HTMLButtonElement>(null);
486
+ const setExpanded = useCallback((value: boolean) => props.actions.setExpanded(choiceKey, value), [props.actions, choiceKey]);
487
+ const pinProcess = useCallback(() => setExpanded(true), [setExpanded]);
488
+ const firstKind = props.useChat(snapshot => snapshot.nodes.get(group.keys[0])?.kind);
489
+ const startsWithUser = firstKind === 'user';
490
+ const mainKeys = startsWithUser ? group.keys.slice(1) : group.keys;
491
+ const flow = useMemo(() => readerFlow({ ...group, keys: mainKeys }, turn, key => nodes.get(key)), [nodes, group, mainKeys, turn]);
492
+ const steps = useMemo(() => segmentLiveTurn(flow, key => nodes.get(key)), [flow, nodes]);
493
+ const liveItems = useMemo(() => presentLiveTurn(steps, boundary, autoFold), [steps, boundary, autoFold]);
494
+ const openMode = deliverableOpenModeOf(useSyncExternalStore(
495
+ props.openPrefs?.subscribe ?? ((fn: () => void) => { void fn; return () => {}; }),
496
+ () => props.openPrefs?.getSnapshot()?.deliverableOpenMode,
497
+ () => 'external',
498
+ ));
499
+ const hasProcess = flow.some(item => item.kind === 'tool' || hasProcessContent(nodes.get(item.nodeKey), boundary));
500
+ // Only a real, still-active text selection delays folding. Merely clicking,
501
+ // focusing or scrolling the live card does not create a permanent override.
502
+ const holdingSelection = selectedProcessKeys.some(key =>
503
+ flow.some(item => item.key === key)
504
+ || liveItems.some(item => item.kind === 'fold'
505
+ ? item.key === key || item.steps.some(step => step.key === key || ('nodeKey' in step && step.nodeKey === key))
506
+ : item.key === key || ('nodeKey' in item.step && item.step.nodeKey === key)));
507
+ const expanded = !autoFold || holdingSelection || processExpanded(expansionChoice, boundary);
508
+ const [foldOpenByKey, setFoldOpenByKey] = useState<Record<string, boolean>>({});
509
+ useLayoutEffect(() => {
510
+ // Restoring automatic folding also releases live "prior steps" disclosures.
511
+ if (autoFold) setFoldOpenByKey(current => Object.keys(current).length ? {} : current);
512
+ }, [autoFold]);
513
+ const deliverables = useMemo(() => getTurnDeliverables(turn, flow), [turn, flow]);
514
+ const tailData = useMemo(() => {
515
+ for (const key of group.keys) {
516
+ const n = nodes.get(key);
517
+ if (n && isNode(n, 'turn-tail')) return n.data;
518
+ }
519
+ return undefined;
520
+ }, [group.keys, nodes]);
521
+ const tailOwner = useMemo(() => turn && tailData ? {
522
+ turn, seq: tailData.closing?.finalNode.seq ?? tailData.seq, openFile: props.openFile,
523
+ } : undefined, [turn, tailData, props.openFile]);
524
+ const fileMentions = useMemo(() => {
525
+ const official = tailOwner ? props.officialFileMentions(tailOwner) : undefined;
526
+ const reader = deliverables.length > 0 ? createProducedFileMentions(deliverables, props.openFile) : undefined;
527
+ return official || reader ? { resolve: (value: string) => official?.resolve(value) ?? reader?.resolve(value) } : undefined;
528
+ }, [tailOwner, props.officialFileMentions, deliverables, props.openFile]);
529
+ const official = useMemo(() => ({
530
+ renderSlot: props.renderSlot, renderSlotChain: props.renderSlotChain,
531
+ officialImageLoader: props.officialImageLoader, officialFileMentions: props.officialFileMentions,
532
+ officialPreviewFile: props.officialPreviewFile,
533
+ officialHost: props.officialHost, openView: props.openView,
534
+ }), [props.renderSlot, props.renderSlotChain, props.officialImageLoader, props.officialFileMentions, props.officialPreviewFile, props.officialHost, props.openView]);
535
+ const cwd = props.useSessions(state => state.byId[props.sessionId]?.cwd);
536
+ const runMs = turn?.start && turn?.end ? Math.max(0, turn.end.time - turn.start.time) : undefined;
537
+ const metrics = useMemo(() => ({
538
+ usage: tailData?.tokenUsage,
539
+ runMs,
540
+ tokensPerSecond: (tailData as { tokensPerSecond?: number } | undefined)?.tokensPerSecond,
541
+ ttftMs: (tailData as { ttftMs?: number } | undefined)?.ttftMs,
542
+ endedAt: tailData?.closing?.time ?? turn?.end?.time,
543
+ }), [tailData, runMs, turn?.end?.time]);
544
+ const forkSeq = forkAnchorSeq([tailData?.closing?.finalNode]);
545
+ const presentation = useMemo(() => {
546
+ // ChatNodeStore exposes live keyed readers. Materialize this turn instead of
547
+ // retaining a live get() function and mistakenly calling it a frozen frame.
548
+ const captured = new Map(group.keys.flatMap(key => {
549
+ const node = nodes.get(key); return node ? [[key, node] as const] : [];
550
+ }));
551
+ return { items: holdingSelection ? presentLiveTurn(steps, boundary, false) : liveItems,
552
+ snapshot: { ...snapshot, nodes: { ...nodes, get: (key: string) => captured.get(key), values: () => [...captured.values()] } } };
553
+ }, [snapshot, nodes, group, steps, boundary, liveItems, holdingSelection]);
554
+ const shared = {
555
+ useChat: useFlowChat,
556
+ renderSlotChain: props.renderSlotChain,
557
+ loadImage: props.loadImage,
558
+ fillComposer: props.fillComposer,
559
+ openFile: props.openFile,
560
+ revealFile: props.revealFile,
561
+ forkAt: props.forkAt,
562
+ forkSeq,
563
+ fileMentions,
564
+ metrics,
565
+ official,
566
+ cwd,
567
+ };
568
+ const terminal = terminalLabel(boundary.reason);
569
+ const hasTurnError = flow.some(item => item.kind === 'node' && nodes.get(item.nodeKey)?.kind === 'turn-error');
570
+ // A stopped turn normally stays quiet, but a stopped turn that produced no
571
+ // prose at all reads as if its answer vanished into the process fold. Say so.
572
+ const hasAnswerProse = steps.some(step => step.kind === 'body'
573
+ && step.blocks.some(block => block.kind === 'text' && block.text.trim() !== ''));
574
+ const stoppedWithoutAnswer = (boundary.reason === 'interrupted' || boundary.reason === 'aborted') && !hasAnswerProse;
575
+ const showTerminalNotice = terminal && !hasTurnError
576
+ && (stoppedWithoutAnswer || (boundary.reason !== 'interrupted' && boundary.reason !== 'aborted'));
577
+ const renderStep = (step: LiveStep, folded: boolean) => {
578
+ const processOpen = folded || expanded;
579
+ if (step.kind === 'reasoning' || step.kind === 'body') return <BlockBoundary>
580
+ <AssistantNode {...shared} boundary={boundary} nodeKey={step.nodeKey} partStart={step.start} pinned={pinnedKeys.includes(step.nodeKey)} processOpen={processOpen} folded={folded} motion={motion} onRead={pinProcess} returnFocusTo={processButton} />
581
+ </BlockBoundary>;
582
+ if (step.kind === 'tool') return <BlockBoundary><ProcessFragment open={processOpen} motion={motion} onRead={pinProcess} returnFocusTo={processButton} nodeKey={step.key} framed>
583
+ <ToolActivity {...shared} entry={step.entry} motion={motion} turnClosed={boundary.status === 'closed'} onRead={pinProcess} />
584
+ {step.entry.block && <ToolMedia {...shared} block={step.entry.block} />}
585
+ </ProcessFragment></BlockBoundary>;
586
+ if (step.kind === 'user') return <BlockBoundary><MainNode {...shared} boundary={boundary} nodeKey={step.nodeKey} /></BlockBoundary>;
587
+ return <Fragment>
588
+ <BlockBoundary><ProcessNode useChat={useFlowChat} t={props.t} nodeKey={step.nodeKey} open={processOpen} motion={motion} onRead={pinProcess} returnFocusTo={processButton} /></BlockBoundary>
589
+ <BlockBoundary><MainNode {...shared} boundary={boundary} nodeKey={step.nodeKey} pinned={pinnedKeys.includes(step.nodeKey)} processOpen={processOpen} /></BlockBoundary>
590
+ </Fragment>;
591
+ };
592
+ return <section className={css.turn} data-reader-turn={group.turn ?? 'unresolved'} data-reader-turn-state={boundary.status} data-reader-turn-result={boundary.reason ?? undefined}>
593
+ {startsWithUser && <BlockBoundary><MainNode {...shared} useChat={props.useChat} boundary={boundary} nodeKey={group.keys[0]} /></BlockBoundary>}
594
+ {hasProcess && !isAwaitingModel && <StickyLane kind="status" className={css.turnProcessSticky}>
595
+ <Disclosure open={expanded} onChange={setExpanded} controls={flowId} buttonRef={processButton}
596
+ label={<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />} />
597
+ </StickyLane>}
598
+ {boundary.status === 'closed' && hasProcess && autoFold && <ClosedProcessSummary open={expanded} onChange={setExpanded} controls={flowId}
599
+ steps={steps.filter(step => {
600
+ if (step.kind === 'user') return false;
601
+ if (step.kind !== 'body') return true;
602
+ const node = nodes.get(step.nodeKey);
603
+ return !!node && isNode(node, 'assistant-step') && (isEarlierNarration(node.data, boundary)
604
+ || node.data.blocks.some(block => block.kind === 'tool-call')
605
+ || (boundary.latestStep > 0 && node.data.step < boundary.latestStep));
606
+ })} />}
607
+ <ChoreographedFlow id={flowId} frame={presentation} motion={motion} enabled={autoFold && boundary.status === 'open' && !holdingSelection}
608
+ urgent={hasTurnError || interaction !== undefined || !sessionRunning} open={foldOpenByKey} processOpen={expanded}
609
+ onOpenChange={(key, value) => { pinProcess(); setFoldOpenByKey(current => ({ ...current, [key]: value })); }} renderStep={renderStep} />
610
+ {/* 状态指示永远排在流程之后:AI 的响应永远出现在最新消息(含补充消息)的下方 */}
611
+ {!hasProcess && boundary.status === 'open' && !isAwaitingModel && <div className={css.disclosure} data-reader-status-only>
612
+ <GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />
613
+ </div>}
614
+ {showDeliverablesRow(boundary.status, deliverables) && <DeliverablesRow deliverables={deliverables} openFile={props.openFile} revealFile={props.revealFile} openMode={openMode} />}
615
+ {boundary.status === 'closed' && <BlockBoundary><OfficialTail official={official} owner={tailOwner} produced={deliverables} /></BlockBoundary>}
616
+ {showTerminalNotice && <div className={css.notice} data-reader-terminal>{terminal}</div>}
617
+ </section>;
618
+ });
619
+
620
+ export function Reader(props: ReaderProps) {
621
+ const root = useRef<HTMLDivElement>(null);
622
+ const activatedAt = useRef(Date.now());
623
+ const order = props.useChat(snapshot => snapshot.order);
624
+ const nodes = props.useChat(snapshot => snapshot.nodes);
625
+ const timeline = props.useChat(snapshot => snapshot.timeline);
626
+ const running = props.useSession(snapshot => snapshot.running);
627
+ const pending = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
628
+ const openError = props.useSession(snapshot => snapshot.openError);
629
+ const loading = props.useSession(snapshot => snapshot.openState === 'loading');
630
+ const hasMore = props.useSession(snapshot => snapshot.hasMore);
631
+ const loadingOlder = props.useSession(snapshot => snapshot.loadingOlder);
632
+ const pendingSubmissions = props.useSession(snapshot => snapshot.pendingSubmissions);
633
+ const pendingList = asReadonlyArray<PendingSubmissionEcho>(pendingSubmissions);
634
+ const waitAnchor = waitingAnchor(order, key => nodes.get(key), pendingList);
635
+ const motionPreference = props.useStore(state => state.motion);
636
+ const motion = useMotionAllowed(motionPreference);
637
+ const prefsSnap = useSyncExternalStore(
638
+ props.openPrefs?.subscribe ?? ((fn: () => void) => { void fn; return () => {}; }),
639
+ () => props.openPrefs?.getSnapshot?.(),
640
+ () => undefined,
641
+ );
642
+ const storeAutoFold = props.useStore(state => state.autoFold);
643
+ const autoFold = prefsSnap?.autoFold ?? (prefsSnap?.foldIntensity !== undefined ? prefsSnap.foldIntensity !== 0 : undefined) ?? storeAutoFold ?? true;
644
+ const previousAutoFold = useRef(autoFold);
645
+ useLayoutEffect(() => {
646
+ const restored = autoFold && !previousAutoFold.current;
647
+ previousAutoFold.current = autoFold;
648
+ // Watch the effective preference so toolbar and Settings changes behave alike.
649
+ // Reading while folding is off must not pin completed process content forever.
650
+ if (restored) props.actions.resetExpanded();
651
+ }, [autoFold, props.actions]);
652
+ const frostedGlass = frostedGlassOf(prefsSnap);
653
+ const bubbles = bubblesOf(prefsSnap);
654
+ const streamMotion = useMemo(() => ({ enabled: motion, activatedAt: activatedAt.current }), [motion]);
655
+ const groups = useMemo(() => groupNodes(order, key => nodes.get(key)), [order, nodes, timeline]);
656
+ const isAwaitingModel = useMemo(() => {
657
+ // 状态机铁律:「深度求索中」与「正在处理/思考」永远互斥。
658
+ // 1. 仅空闲发送(!running)的本地回显窗口:用户按下回车第 0 毫秒立即反馈。
659
+ // 忙碌时的 queued/steering 回显不算等待——模型本来就在工作。
660
+ if (!running && pendingList.length > 0) return true;
661
+
662
+ const lastKey = order.at(-1);
663
+ const lastNode = lastKey ? nodes.get(lastKey) : undefined;
664
+ if (!lastKey || !lastNode) return false;
665
+
666
+ // 2. 模型已经产出内容:等待结束,计时器必须立刻消失。
667
+ // 「产出」= assistant-step 已带 block(正文/思维链/工具调用都算)。
668
+ if (lastNode.kind === 'assistant-step') {
669
+ const data = lastNode.data as { status?: string; blocks?: unknown[] };
670
+ const blocks = data.blocks ?? [];
671
+ // 空 block 且仍在跑 = 请求已发出、模型还没吐字,正是要计时的那一刻。
672
+ return blocks.length === 0 && data.status === 'running';
673
+ }
674
+
675
+ // 3. 球在模型脚下:用户刚发言,或工具已返回、上下文已注入、命令已执行。
676
+ // 这些时刻模型随时可能卡住,正是要计时的地方;工具执行期间不计时,
677
+ // 因为那时忙的是工具而不是模型。
678
+ // handsBackToModel 内部按 chat 层 kind 判定,并区分「工具已返回」与
679
+ // 「工具仍在跑」——后者不算等待。
680
+ const modelOwesResponse = handsBackToModel(lastNode);
681
+ if (!modelOwesResponse) return false;
682
+
683
+ // 轮次已结束就没什么可等的。
684
+ const lastGroup = groups.at(-1);
685
+ const turn = lastGroup?.turn === null || lastGroup?.turn === undefined ? undefined : timeline.turns.get(lastGroup.turn);
686
+ return turn?.status !== 'closed';
687
+ }, [running, pendingList, order, nodes, groups, timeline]);
688
+ const scroll = useReadingScroll(root, motion);
689
+ const pinnedKeys = usePinnedSelection(root);
690
+ const selectedProcessKeys = usePinnedSelection(root, '[data-reader-process]');
691
+ const [historyError, setHistoryError] = useState(false);
692
+
693
+ // 1. Navigation items from Chat snapshot
694
+ const turnNavigationItems = props.useChat(snapshot => snapshot.navigation?.items ? snapshot.navigation.items() : undefined);
695
+ // 2. Whole-log turn outline projection
696
+ const turnOutline = props.useProjection?.('turnOutline');
697
+ // 3. Track turns with deliverables
698
+ const turnsWithDeliverables = useMemo(() => {
699
+ const set = new Set<number>();
700
+ for (const [turnNum, loc] of timeline.turns) {
701
+ const deliv = (loc.data as { get(key: string): unknown } | undefined)?.get('deliverables') as { produced?: unknown[] } | undefined;
702
+ if (Array.isArray(deliv?.produced) && deliv.produced.length > 0) {
703
+ set.add(turnNum);
704
+ }
705
+ }
706
+ return set;
707
+ }, [timeline]);
708
+
709
+ // 4. Merged timeline items for the rail
710
+ const timelineItems = useMemo(
711
+ () => mergeTimelineItems(turnNavigationItems, turnOutline, turnsWithDeliverables),
712
+ [turnNavigationItems, turnOutline, turnsWithDeliverables],
713
+ );
714
+
715
+ // 5. Active & busy turn tracking
716
+ const [activeTurn, setActiveTurn] = useState<number | null>(null);
717
+ const [busyTurn, setBusyTurn] = useState<number | null>(null);
718
+
719
+ // Scroll spy to update activeTurn
720
+ useEffect(() => {
721
+ const el = root.current;
722
+ if (!el) return;
723
+ const scroller = el.closest('[data-conversation-scroll]') ?? el;
724
+
725
+ let ticking = false;
726
+ const updateActive = () => {
727
+ if (ticking) return;
728
+ ticking = true;
729
+ requestAnimationFrame(() => {
730
+ ticking = false;
731
+ const line = (scroller instanceof HTMLElement ? scroller.clientHeight : window.innerHeight) * 0.35;
732
+ const turnRows = el.querySelectorAll<HTMLElement>('[data-reader-turn]:not([data-reader-turn="unresolved"])');
733
+ let current: number | null = null;
734
+ for (const row of turnRows) {
735
+ const rect = row.getBoundingClientRect();
736
+ if (rect.top <= line) {
737
+ const num = Number(row.dataset.readerTurn);
738
+ if (Number.isSafeInteger(num)) current = num;
739
+ } else {
740
+ break;
741
+ }
742
+ }
743
+ if (current !== null) {
744
+ setActiveTurn(current);
745
+ } else if (turnRows.length > 0) {
746
+ const first = Number(turnRows[0].dataset.readerTurn);
747
+ if (Number.isSafeInteger(first)) setActiveTurn(first);
748
+ }
749
+ });
750
+ };
751
+
752
+ scroller.addEventListener('scroll', updateActive, { passive: true });
753
+ updateActive();
754
+ return () => scroller.removeEventListener('scroll', updateActive);
755
+ }, [groups]);
756
+
757
+ // Navigation handler (supports loaded jump & unloaded loadThrough).
758
+ // Land on the conversation scroller only — scrollIntoView also moves
759
+ // ancestor boxes and can lift the sticky composer after a top→bottom jump.
760
+ const onNavigateTurn = useCallback(async (item: TimelineItem) => {
761
+ const el = root.current;
762
+ if (!el) return;
763
+ const port = scrollerOf(el);
764
+ const reveal = (turn: number) => {
765
+ const targetRow = el.querySelector<HTMLElement>(`[data-reader-turn="${turn}"]`);
766
+ if (!targetRow) return;
767
+ const last = timelineItems.at(-1);
768
+ if (last !== undefined && last.turn === turn) {
769
+ scroll.jump();
770
+ } else {
771
+ scroll.release();
772
+ landTurn(targetRow, port);
773
+ }
774
+ setActiveTurn(turn);
775
+ };
776
+
777
+ if (item.anchor.kind === 'loaded') {
778
+ reveal(item.turn);
779
+ return;
780
+ }
781
+
782
+ setBusyTurn(item.turn);
783
+ try {
784
+ if (props.loadThrough) {
785
+ await props.loadThrough(item.anchor.seq);
786
+ } else {
787
+ await props.loadOlder();
788
+ }
789
+ setTimeout(() => { reveal(item.turn); }, 50);
790
+ } finally {
791
+ setBusyTurn(null);
792
+ }
793
+ }, [props.loadThrough, props.loadOlder, scroll.jump, scroll.release, timelineItems]);
794
+
795
+ const lastKey = order.at(-1);
796
+ const lastNode = lastKey ? nodes.get(lastKey) : undefined;
797
+ const lastSubmissionId = pendingList.length ? pendingList[pendingList.length - 1].requestId : null;
798
+ const lastOrderKeyRef = useRef<string | undefined>(lastKey);
799
+ const lastSubmissionRef = useRef<string | null>(lastSubmissionId);
800
+
801
+ useLayoutEffect(() => {
802
+ const appendedUser = lastKey !== lastOrderKeyRef.current && (lastNode?.kind === 'user' || lastNode?.kind === 'steering');
803
+ const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionRef.current;
804
+ lastOrderKeyRef.current = lastKey;
805
+ lastSubmissionRef.current = lastSubmissionId;
806
+
807
+ if (appendedUser || appendedSubmission) {
808
+ scroll.jump();
809
+ }
810
+ }, [lastKey, lastNode?.kind, lastSubmissionId, scroll]);
811
+
812
+ const visibleSubmissions = useMemo(() => {
813
+ if (pendingList.length === 0) return [];
814
+ return pendingList.filter(sub => sub.placement !== 'queued');
815
+ }, [pendingList]);
816
+
817
+ // ChatView publishes data-chat-flow="" on its column. Skins treat a
818
+ // scrollport without that hook as inspect-only and hide [data-composer-seat].
819
+ return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.3" data-dsh-better-display="0.3.3" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-bubbles={bubbles ? 'on' : 'off'} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
820
+ <TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
821
+ <div className={css.column} data-chat-flow="">
822
+ <StickyLane kind="toolbar" className={css.toolbar}>
823
+ <button
824
+ type="button"
825
+ className={css.textButton}
826
+ aria-pressed={autoFold}
827
+ onClick={() => {
828
+ props.actions.setAutoFold(!autoFold);
829
+ props.openPrefs?.actions?.setAutoFold?.(!autoFold);
830
+ }}
831
+ title="新思考产生时,是否自动将此前步骤收拢为一行汇总。关闭后完整保留原始过程与流式输出。"
832
+ >
833
+ {`自动折叠${autoFold ? '开' : '关'}`}
834
+ </button>
835
+ </StickyLane>
836
+ {hasMore && <button type="button" className={css.historyButton} disabled={loadingOlder} onClick={async () => {
837
+ setHistoryError(false);
838
+ try { await props.loadOlder(); } catch { setHistoryError(true); }
839
+ }}>{loadingOlder ? '正在加载更早记录' : '加载更早记录'}</button>}
840
+ {historyError && <div className={css.notice}>历史记录加载失败,可再次尝试;现有内容未改变。</div>}
841
+ {openError && <div className={css.error} role="alert">会话暂时无法读取:{openError.message}</div>}
842
+ {loading && groups.length === 0 && <p className={css.empty} role="status">正在读取会话…</p>}
843
+ {groups.map(group => <TurnGroup key={group.key} {...props} group={group} motion={motion} autoFold={autoFold} pinnedKeys={pinnedKeys} selectedProcessKeys={selectedProcessKeys} isAwaitingModel={isAwaitingModel && group.key === groups.at(-1)?.key} />)}
844
+ {visibleSubmissions.map(submission => {
845
+ const images = pendingSubmissionImages(submission);
846
+ return (
847
+ <div key={submission.requestId} className={css.userCluster} data-reader-pending-submission>
848
+ {images.length > 0 && (
849
+ <div className={css.userImages}>
850
+ {images.map((item, idx) => (
851
+ <figure key={idx} className={css.imageFigure}>
852
+ <div className={css.imageFrame} style={{ aspectRatio: `${item.width || 4} / ${item.height || 3}` }}>
853
+ <img src={item.previewUrl} alt={item.name ?? '发送的图片'} className={css.pendingImage} />
854
+ </div>
855
+ </figure>
856
+ ))}
857
+ </div>
858
+ )}
859
+ {submission.text ? (
860
+ <div className={css.user}>
861
+ <div className={css.blocks}>{submission.text}</div>
862
+ </div>
863
+ ) : null}
864
+ <UserMessageActions text={submission.text ?? ''} time={submission.time} />
865
+ </div>
866
+ );
867
+ })}
868
+ {isAwaitingModel && <WaitingStatus anchor={waitAnchor} label={props.t ? props.t('chat.deepDiving') : '深度求索中...'} />}
869
+ {pending !== undefined && <div className={css.attention} role="alert" data-reader-attention>
870
+ <strong>{pending.kind === 'question' ? '需要你回答一个问题' : '需要你的确认'}</strong>
871
+ <span>请在下方原生操作区处理。此提示不会收进执行过程。</span>
872
+ </div>}
873
+ {scroll.detached && <div className={css.jumpDock}><button type="button" className={css.jump} aria-label="回到底部" title="回到底部" onClick={scroll.jump}>
874
+ <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true"><path d="M3 5.5 7 9.5 11 5.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
875
+ </button></div>}
876
+ </div>
877
+ </div></StreamMotionContext.Provider>;
878
+ }