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