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,118 @@
1
+ import type { AssistantBlock, ToolCallBlock, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client';
2
+ import type { AssistantChatData, ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
3
+ import { activityPhase } from './tool-activity.js';
4
+
5
+ export interface ReaderGroup { key: string; turn: number | null; keys: readonly string[] }
6
+ export interface TurnBoundary { status: 'open' | 'closed' | 'unknown'; reason: string | null; latestStep: number; closingStep: number | null }
7
+
8
+ // Run on structural publication, not on each text delta. Node seats subscribe by key.
9
+ export function groupNodes(order: readonly string[], get: (key: string) => ChatConversationViewNode | undefined): ReaderGroup[] {
10
+ const groups: ReaderGroup[] = [];
11
+ const seenTurns = new Set<number>();
12
+ for (const key of order) {
13
+ const node = get(key);
14
+ if (!node || node.visibility === 'hidden') continue;
15
+ const turn = node.location.kind === 'step' || node.location.kind === 'turn' ? node.location.turn.turn : null;
16
+ const previous = groups.at(-1);
17
+ if (turn !== null && previous?.turn === turn) {
18
+ (previous.keys as string[]).push(key);
19
+ } else {
20
+ groups.push({ key: turn === null ? `node:${key}` : seenTurns.has(turn) ? `turn:${turn}:${key}` : `turn:${turn}`, turn, keys: [key] });
21
+ if (turn !== null) seenTurns.add(turn);
22
+ }
23
+ }
24
+ return groups;
25
+ }
26
+
27
+ export function boundaryOf(turn: TurnLocation | undefined): TurnBoundary {
28
+ return {
29
+ status: turn?.status ?? 'unknown',
30
+ reason: turn?.end?.data.reason.kind ?? null,
31
+ latestStep: turn?.steps.at(-1)?.step ?? -1,
32
+ closingStep: turn?.data.get('turn-tail')?.closing?.step ?? null,
33
+ };
34
+ }
35
+
36
+ export function isEarlierNarration(data: AssistantChatData, boundary: TurnBoundary): boolean {
37
+ // New body text, a tool call, or a settled step is not a completed turn.
38
+ if (data.status !== 'settled' || boundary.status !== 'closed' || boundary.reason !== 'completed') return false;
39
+ if (data.blocks.some(block => block.kind === 'image' || block.kind === 'other')) return false;
40
+ return boundary.closingStep !== null && data.step < boundary.closingStep;
41
+ }
42
+
43
+ export function processExpanded(choice: boolean | undefined, boundary: TurnBoundary): boolean {
44
+ return choice ?? !(boundary.status === 'closed' && boundary.reason === 'completed');
45
+ }
46
+
47
+ /** Reading while running must not pin the process open after completion. */
48
+ export function processChoiceKey(groupKey: string, boundary: TurnBoundary): string {
49
+ return `${groupKey}:${boundary.status}:${boundary.reason ?? 'pending'}`;
50
+ }
51
+
52
+ /** A body-only assistant step is not a thinking/process disclosure. */
53
+ export function hasProcessContent(node: ChatConversationViewNode | undefined, boundary: TurnBoundary): boolean {
54
+ if (!node || node.visibility === 'hidden') return false;
55
+ if (node.kind === 'assistant-step') {
56
+ const data = node.data as AssistantChatData;
57
+ return data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')
58
+ || (isEarlierNarration(data, boundary) && hasVisibleBody(data.blocks));
59
+ }
60
+ return node.kind === 'context' || node.kind === 'model-retry' || node.kind === 'system-prompt' || node.kind === 'turn-process'
61
+ || node.kind === 'command' || node.kind === 'manual-compaction' || node.kind === 'compaction';
62
+ }
63
+
64
+ export function hasVisibleBody(blocks: readonly AssistantBlock[]): boolean {
65
+ return blocks.some(block => block.kind === 'image' || block.kind === 'other' || (block.kind === 'text' && block.text.trim() !== ''));
66
+ }
67
+
68
+ /** Keep native block order. In particular, never lift a later Think above text. */
69
+ export function assistantSegments(blocks: readonly AssistantBlock[]): { kind: 'reasoning' | 'body'; start: number; blocks: AssistantBlock[] }[] {
70
+ const segments: ReturnType<typeof assistantSegments> = [];
71
+ let previous: ReturnType<typeof assistantSegments>[number] | undefined;
72
+ blocks.forEach((block, index) => {
73
+ if (block.kind === 'tool-call') { previous = undefined; return; }
74
+ const kind = block.kind === 'reasoning' ? 'reasoning' : 'body';
75
+ if (previous?.kind === kind) previous.blocks.push(block);
76
+ else {
77
+ previous = { kind, start: index, blocks: [block] };
78
+ segments.push(previous);
79
+ }
80
+ });
81
+ return segments;
82
+ }
83
+
84
+ export function toolFailed(block: ToolCallBlock): boolean {
85
+ return activityPhase({ block }) === 'failed';
86
+ }
87
+
88
+ export function toolName(block: ToolCallBlock): string {
89
+ return 'kind' in block ? block.call?.name ?? '工具调用' : block.name;
90
+ }
91
+
92
+ export function terminalLabel(reason: string | null): string | null {
93
+ switch (reason) {
94
+ case 'completed': return null;
95
+ case 'aborted': case 'interrupted': return '本轮已停止,已生成的内容仍保留。';
96
+ case 'blocked': return '本轮需要处理阻塞事项;请查看原对话与下方操作区。';
97
+ case 'max-tokens': return '输出达到本轮长度限制,内容可能尚未完整。';
98
+ case 'error': return '本轮未完成;错误信息保留在下方。';
99
+ case null: return null;
100
+ default: return `本轮结束状态:${reason}。请在原对话中核对完整记录。`;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Extract the first usable fork anchor seq from candidate message nodes.
106
+ * Only the durable closing message seq cuts the intended turn prefix: an
107
+ * absent anchor must surface as undefined (never as 0 or NaN), because the
108
+ * host treats a missing atSeq as a whole-session fork.
109
+ */
110
+ export function forkAnchorSeq(
111
+ candidates: ReadonlyArray<{ seq?: unknown } | null | undefined>,
112
+ ): number | undefined {
113
+ for (const candidate of candidates) {
114
+ const seq = candidate?.seq;
115
+ if (typeof seq === 'number' && Number.isFinite(seq)) return seq;
116
+ }
117
+ return undefined;
118
+ }
@@ -0,0 +1,12 @@
1
+ /** Transitions.dev Reasoning stream, adapted to appended content, never a loop. */
2
+ export const REASON_HOLD = 840;
3
+ export const REASON_STEP = 500;
4
+ export const REASON_LINES = 2;
5
+
6
+ export function reasoningTarget(top: number, contentHeight: number, viewportHeight: number, lineHeight: number): number {
7
+ const end = Math.max(0, contentHeight - viewportHeight);
8
+ const current = Math.min(end, Math.max(0, top));
9
+ // Receiving a burst changes the available transcript, never the step size.
10
+ // Only the final, partial step may be shorter than the reference's two lines.
11
+ return Math.min(end, current + Math.max(1, lineHeight) * REASON_LINES);
12
+ }
@@ -0,0 +1,53 @@
1
+ export type SettingsCopyKey =
2
+ | 'nav'
3
+ | 'openTitle'
4
+ | 'openDescription'
5
+ | 'skillTitle'
6
+ | 'skillInstalled'
7
+ | 'skillMissing'
8
+ | 'skillPurpose'
9
+ | 'skillPluginNote'
10
+ | 'skillInstall'
11
+ | 'skillRecheck'
12
+ | 'skillChecking'
13
+ | 'skillUnavailable';
14
+
15
+ export type SettingsCopy = Record<SettingsCopyKey, string>;
16
+
17
+ export const en: SettingsCopy = {
18
+ nav: 'Better Display',
19
+ openTitle: 'Open deliverables in built-in panel',
20
+ openDescription: 'Off by default: chips and inline file mentions open in the system app. Turn this on to preview them in the right Sidebar, matching official chat. Reveal and folder actions still use the system file manager.',
21
+ skillTitle: 'generative-mcpapps skill',
22
+ skillInstalled: 'Installed',
23
+ skillMissing: 'Not detected',
24
+ skillPurpose: 'This install is for model auto-selection of MCP Apps. Reader already renders mcp-app fences when the model emits them.',
25
+ skillPluginNote: 'Shipping the pack inside this plugin repository does not install it into the harness skill catalog.',
26
+ skillInstall: 'Copy the whole generative-mcpapps folder (including references and examples) into .dsh/skills or .agents/skills — in your home or the project, you choose — then Re-check. There is no one-click install that works on every host.',
27
+ skillRecheck: 'Re-check',
28
+ skillChecking: 'Checking…',
29
+ skillUnavailable: 'Could not query the host skill catalog. Copy into .dsh/skills or .agents/skills (home or project), then Re-check.',
30
+ };
31
+
32
+ export const zh: SettingsCopy = {
33
+ nav: 'Better Display',
34
+ openTitle: '在内置面板中打开产物',
35
+ openDescription: '默认关闭:产物芯片和正文中的文件提及会用系统应用打开。打开后与官方对话一致,在右侧栏预览。访达 / 资源管理器中的显示与打开所在文件夹不受此开关控制。',
36
+ skillTitle: 'generative-mcpapps 技能',
37
+ skillInstalled: '已安装',
38
+ skillMissing: '未检测到',
39
+ skillPurpose: '这项安装是为了让模型自动选用 MCP Apps。阅读页在模型写出 mcp-app 代码块时已经会渲染,不依赖该技能是否装进宿主。',
40
+ skillPluginNote: '技能包只出现在本插件仓库里,并不等于当前 Agent 已经加载它。',
41
+ skillInstall: '把完整的 generative-mcpapps 文件夹(含 references 与 examples)复制到 .dsh/skills 或 .agents/skills(家目录或项目目录,由你选),然后重新检测。没有在所有宿主上都可用的一键安装。',
42
+ skillRecheck: '重新检测',
43
+ skillChecking: '正在检测…',
44
+ skillUnavailable: '无法查询宿主技能目录。请复制到 .dsh/skills 或 .agents/skills(家目录或项目),再重新检测。',
45
+ };
46
+
47
+ export function settingsLanguage(tag: string | undefined): 'zh' | 'en' {
48
+ return (tag ?? '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
49
+ }
50
+
51
+ export function settingsCopyFor(tag: string | undefined): SettingsCopy {
52
+ return settingsLanguage(tag) === 'zh' ? zh : en;
53
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Community-plugin SlotMap merge for Settings pages.
3
+ * Official plugins import `@deepseek-ai/dsh-client-ui-settings/client` instead.
4
+ * Shape matches that package's `settings.section` (list / root / close).
5
+ */
6
+ import type { SettingsCopyKey } from './settings-copy.js';
7
+
8
+ export interface SettingsSectionOwnerProps {
9
+ /** Close the settings panel (the shell owns the open state). */
10
+ close: () => void;
11
+ }
12
+
13
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
14
+ interface SlotMap {
15
+ 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps };
16
+ }
17
+ interface LocaleNamespaceMap {
18
+ 'better-display': SettingsCopyKey;
19
+ }
20
+ }
@@ -0,0 +1,73 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type {} from './settings-slots.js';
3
+ import { settingsCopyFor, zh, en } from './settings-copy.js';
4
+ import { SettingsSection, type BetterDisplaySettingsInjected, type OpenPrefs } from './SettingsSection.js';
5
+ import { firstSessionId, skillsFromListResult, type SkillStatusProbe } from './skill-status.js';
6
+ import type { HostSkillStatus } from '../skill-status.js';
7
+
8
+ interface LocaleFace {
9
+ register?: (ns: string, dicts: { zh: unknown; en: unknown }) => () => void;
10
+ bind?: (ns: string) => (key: string) => string;
11
+ getSnapshot?: () => { locale?: string; language?: string };
12
+ }
13
+
14
+ interface RemoteSkillsFace {
15
+ list?: (arg: { sessionId: string }, signal?: AbortSignal) => Promise<unknown>;
16
+ }
17
+
18
+ function languageTag(ctx: Context): string | undefined {
19
+ const locale = (ctx.get?.('locale') ?? (ctx as unknown as { locale?: LocaleFace }).locale) as LocaleFace | undefined;
20
+ const snap = locale?.getSnapshot?.();
21
+ return snap?.locale ?? snap?.language
22
+ ?? (typeof document !== 'undefined' ? document.documentElement.lang : undefined)
23
+ ?? (typeof navigator !== 'undefined' ? navigator.language : undefined);
24
+ }
25
+
26
+ function createSkillProbe(ctx: Context): SkillStatusProbe {
27
+ return {
28
+ fetchHostStatus: async () => {
29
+ const sessions = ctx.sessions as { list?: { getSnapshot?: () => { byId?: Record<string, { cwd?: string }> } } } | undefined;
30
+ const snap = sessions?.list?.getSnapshot?.();
31
+ const id = firstSessionId(snap);
32
+ const cwd = id && snap?.byId ? snap.byId[id]?.cwd : undefined;
33
+ const url = cwd
34
+ ? `/better-display/skill-status?cwd=${encodeURIComponent(cwd)}`
35
+ : '/better-display/skill-status';
36
+ const res = await fetch(url);
37
+ if (!res.ok) return undefined;
38
+ return await res.json() as HostSkillStatus;
39
+ },
40
+ listRemoteSkills: async () => {
41
+ const remote = ctx.remote as { skills?: RemoteSkillsFace } | undefined;
42
+ const skills = remote?.skills
43
+ ?? (ctx.get?.('remote.skills') as RemoteSkillsFace | undefined)
44
+ ?? ((ctx.get?.('remote') as { skills?: RemoteSkillsFace } | undefined)?.skills);
45
+ const sessions = ctx.sessions as { list?: { getSnapshot?: () => { ids?: string[]; byId?: Record<string, unknown> } } } | undefined;
46
+ const sessionId = firstSessionId(sessions?.list?.getSnapshot?.());
47
+ if (!skills?.list || !sessionId) return undefined;
48
+ return skillsFromListResult(await skills.list({ sessionId }));
49
+ },
50
+ };
51
+ }
52
+
53
+ export function installBetterDisplaySettings(ctx: Context, prefs: OpenPrefs): void {
54
+ const locale = (ctx.get?.('locale') ?? (ctx as unknown as { locale?: LocaleFace }).locale) as LocaleFace | undefined;
55
+ if (locale?.register) {
56
+ ctx.effect(() => locale.register!('better-display', { zh, en }), 'dsh-better-display: settings copy');
57
+ }
58
+ const checkSkill = createSkillProbe(ctx);
59
+ const injected = (): BetterDisplaySettingsInjected => ({
60
+ prefs,
61
+ copy: settingsCopyFor(languageTag(ctx)),
62
+ languageTag: languageTag(ctx),
63
+ checkSkill,
64
+ });
65
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
66
+ name: 'settings.section',
67
+ id: 'better-display',
68
+ order: 40,
69
+ label: () => locale?.bind?.('better-display')?.('nav') || settingsCopyFor(languageTag(ctx)).nav,
70
+ locale: locale?.bind ? 'better-display' : undefined,
71
+ inject: injected,
72
+ }, SettingsSection));
73
+ }
@@ -0,0 +1,78 @@
1
+ import {
2
+ CONVENTIONAL_SKILL_ROOTS,
3
+ GENERATIVE_MCPAPPS_SKILL,
4
+ SKILL_PACK_RELATIVE,
5
+ publicSkillRoots,
6
+ skillListIncludes,
7
+ skillsFromListResult,
8
+ toPublicSkillStatus,
9
+ type HostSkillStatus,
10
+ } from '../skill-status.js';
11
+
12
+ export {
13
+ CONVENTIONAL_SKILL_ROOTS,
14
+ GENERATIVE_MCPAPPS_SKILL,
15
+ SKILL_PACK_RELATIVE,
16
+ publicSkillRoots,
17
+ skillListIncludes,
18
+ skillsFromListResult,
19
+ toPublicSkillStatus,
20
+ };
21
+ export type { HostSkillStatus };
22
+
23
+ export interface SkillStatusSnapshot {
24
+ readonly name: typeof GENERATIVE_MCPAPPS_SKILL;
25
+ readonly installed: boolean;
26
+ readonly via: 'skills.list' | 'skill-root' | null;
27
+ readonly roots: HostSkillStatus['roots'];
28
+ readonly packPath?: string;
29
+ readonly hostReached: boolean;
30
+ }
31
+
32
+ export interface SkillStatusProbe {
33
+ listRemoteSkills?: () => Promise<readonly { readonly name?: string }[] | undefined>;
34
+ fetchHostStatus?: () => Promise<HostSkillStatus | undefined>;
35
+ }
36
+
37
+ export async function detectGenerativeMcpappsSkill(probe: SkillStatusProbe): Promise<SkillStatusSnapshot> {
38
+ let host: HostSkillStatus | undefined;
39
+ if (probe.fetchHostStatus) {
40
+ try {
41
+ host = await probe.fetchHostStatus();
42
+ } catch {
43
+ host = undefined;
44
+ }
45
+ }
46
+
47
+ let remoteHit = false;
48
+ if (probe.listRemoteSkills) {
49
+ try {
50
+ remoteHit = skillListIncludes(await probe.listRemoteSkills());
51
+ } catch {
52
+ remoteHit = false;
53
+ }
54
+ }
55
+
56
+ const installed = Boolean(host?.installed || remoteHit);
57
+ const via = remoteHit ? 'skills.list' : (host?.via ?? null);
58
+ const publicHost = host === undefined ? undefined : toPublicSkillStatus(host);
59
+ return {
60
+ name: GENERATIVE_MCPAPPS_SKILL,
61
+ installed,
62
+ via,
63
+ roots: publicHost?.roots ?? publicSkillRoots(),
64
+ hostReached: host !== undefined,
65
+ };
66
+ }
67
+
68
+ /** mkdir/cp using conventional relative roots only. Host paths are ignored. */
69
+ export function shortestInstallCommand(_status?: Pick<SkillStatusSnapshot, 'packPath' | 'roots'>): string {
70
+ const dest = CONVENTIONAL_SKILL_ROOTS[0];
71
+ return `mkdir -p ${dest} && cp -R ${SKILL_PACK_RELATIVE} ${dest}/`;
72
+ }
73
+
74
+ export function firstSessionId(list: { ids?: readonly string[]; byId?: Record<string, unknown> } | undefined): string | undefined {
75
+ if (list?.ids && list.ids.length > 0) return list.ids[0];
76
+ const keys = list?.byId ? Object.keys(list.byId) : [];
77
+ return keys[0];
78
+ }
@@ -0,0 +1,30 @@
1
+ import { defineStore } from '@deepseek-ai/dsh-client-store';
2
+ import type { EngineStoreHandle } from '@deepseek-ai/dsh-client-store';
3
+ import type { DeliverableOpenMode } from './open-file.js';
4
+
5
+ export interface ReaderState {
6
+ expanded: Record<string, boolean>;
7
+ motion: boolean;
8
+ autoFold: boolean;
9
+ /** Default stays the system app. `sidebar` is opt-in. */
10
+ deliverableOpenMode: DeliverableOpenMode;
11
+ }
12
+ type ReaderActions = {
13
+ setExpanded: (draft: ReaderState, key: string, value: boolean) => void;
14
+ setMotion: (draft: ReaderState, value: boolean) => void;
15
+ setAutoFold: (draft: ReaderState, value: boolean) => void;
16
+ setDeliverableOpenMode: (draft: ReaderState, value: DeliverableOpenMode) => void;
17
+ };
18
+
19
+ export function createReaderStore(): EngineStoreHandle<ReaderState, ReaderActions> {
20
+ return defineStore({
21
+ init: (): ReaderState => ({ expanded: {}, motion: true, autoFold: true, deliverableOpenMode: 'external' }),
22
+ persist: 'dsh.reader.v1',
23
+ actions: {
24
+ setExpanded: (draft, key: string, value: boolean) => { draft.expanded[key] = value; },
25
+ setMotion: (draft, value: boolean) => { draft.motion = value; },
26
+ setAutoFold: (draft, value: boolean) => { draft.autoFold = value; },
27
+ setDeliverableOpenMode: (draft, value: DeliverableOpenMode) => { draft.deliverableOpenMode = value; },
28
+ },
29
+ });
30
+ }
@@ -0,0 +1,100 @@
1
+ /** Presentation only. The original session string remains the source of truth. */
2
+ export const STREAM_TIMING = { catchUpMs: 180, maxQueuedMs: 240, finishMs: 96, revealMs: 350, minimumRate: 100 } as const;
3
+ const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
4
+
5
+ function atOrAfter(values: readonly number[], target: number): number {
6
+ let low = 0;
7
+ let high = values.length - 1;
8
+ while (low < high) {
9
+ const middle = (low + high) >>> 1;
10
+ if (values[middle]! < target) low = middle + 1;
11
+ else high = middle;
12
+ }
13
+ return low;
14
+ }
15
+
16
+ export class StreamBuffer {
17
+ target: string;
18
+ visible: string;
19
+ revision = 0;
20
+ private boundaries = [0];
21
+ private arrivals: { end: number; at: number }[] = [];
22
+ private lastAt: number | null = null;
23
+ private credit = 0;
24
+ private finishAt: number | null = null;
25
+
26
+ constructor(initial = '') {
27
+ this.target = initial;
28
+ this.visible = initial;
29
+ this.segment(0);
30
+ }
31
+
32
+ get pending(): boolean { return this.visible.length < this.target.length; }
33
+
34
+ private segment(from: number): void {
35
+ const index = atOrAfter(this.boundaries, from);
36
+ this.boundaries.length = index;
37
+ for (const part of graphemes.segment(this.target.slice(from))) this.boundaries.push(from + part.index);
38
+ if (this.boundaries.at(-1) !== this.target.length) this.boundaries.push(this.target.length);
39
+ }
40
+
41
+ /** Non-append updates, cancellations and hidden/reduced views never replay. */
42
+ update(text: string, now: number, options: { immediate?: boolean; finished?: boolean } = {}): void {
43
+ if (options.immediate || !text.startsWith(this.target)) {
44
+ if (text !== this.target) this.revision++;
45
+ this.target = text;
46
+ this.flush();
47
+ this.boundaries = [0];
48
+ this.segment(0);
49
+ return;
50
+ }
51
+ if (text !== this.target) {
52
+ const wasPending = this.pending;
53
+ const from = this.boundaries.at(-2) ?? 0;
54
+ this.target = text;
55
+ // Re-segment the previous last cluster: an emoji/combining sequence can
56
+ // itself span transport chunks. Frozen source prefixes are not scanned.
57
+ this.segment(from);
58
+ this.arrivals.push({ end: text.length, at: now });
59
+ if (!wasPending) { this.lastAt = now; this.credit = 0; }
60
+ }
61
+ if (options.finished && this.finishAt === null) this.finishAt = now + STREAM_TIMING.finishMs;
62
+ if (!options.finished) this.finishAt = null;
63
+ if (this.target.length - this.visible.length > 8192) { this.revision++; this.flush(); }
64
+ }
65
+
66
+ flush(): void {
67
+ this.visible = this.target;
68
+ this.arrivals = [];
69
+ this.credit = 0;
70
+ this.lastAt = null;
71
+ this.finishAt = null;
72
+ }
73
+
74
+ advance(now: number): boolean {
75
+ if (!this.pending) return false;
76
+ const before = this.visible.length;
77
+ const index = atOrAfter(this.boundaries, before);
78
+ const remaining = this.boundaries.length - index - 1;
79
+ const delta = Math.max(0, now - (this.lastAt ?? now));
80
+ this.lastAt = now;
81
+ const windowMs = this.finishAt === null ? STREAM_TIMING.catchUpMs : Math.max(16, Math.min(STREAM_TIMING.catchUpMs, this.finishAt - now));
82
+ const rate = Math.max(STREAM_TIMING.minimumRate, remaining * 1000 / windowMs);
83
+ this.credit += rate * delta / 1000;
84
+ const count = Math.floor(this.credit);
85
+ this.credit -= count;
86
+ let end = this.boundaries[Math.min(this.boundaries.length - 1, index + count)]!;
87
+ for (const item of this.arrivals) {
88
+ if (now - item.at < STREAM_TIMING.maxQueuedMs) break;
89
+ end = Math.max(end, this.boundaries[atOrAfter(this.boundaries, item.end)]!);
90
+ }
91
+ if (this.finishAt !== null && now >= this.finishAt) end = this.target.length;
92
+ // Never expose half of a surrogate pair while the source can still grow.
93
+ if (end && /[\uD800-\uDBFF]/.test(this.target[end - 1]!) && this.finishAt === null) end--;
94
+ end = Math.max(before, end);
95
+ this.visible = this.target.slice(0, end);
96
+ this.arrivals = this.arrivals.filter(item => item.end > end);
97
+ if (!this.pending) { this.credit = 0; this.lastAt = null; }
98
+ return end !== before;
99
+ }
100
+ }
@@ -0,0 +1,62 @@
1
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { StreamBuffer } from './stream-buffer.js';
3
+ import { WORD_MOTION } from './word-timeline.js';
4
+
5
+ export const StreamMotionContext = createContext<{ enabled: boolean; activatedAt: number; paused?: boolean; resumed?: boolean }>({ enabled: false, activatedAt: 0 });
6
+
7
+ export function useStreamingText(source: string, streaming: boolean, options: { startedAt?: number; interrupted: boolean; selected: boolean }) {
8
+ const { enabled, activatedAt, paused = false, resumed = false } = useContext(StreamMotionContext);
9
+ const fresh = resumed || (options.startedAt ?? 0) >= activatedAt;
10
+ const buffer = useRef<StreamBuffer>();
11
+ // Only newly mounted content after a fold uses the catch-up path. History and
12
+ // user-opened drawers stay immediate; already mounted prefixes never replay.
13
+ if (!buffer.current) buffer.current = new StreamBuffer(enabled && (streaming || resumed) && fresh ? '' : source);
14
+ const [display, setDisplay] = useState(() => ({ text: buffer.current!.visible, revision: 0 }));
15
+ const [finalizing, setFinalizing] = useState(streaming);
16
+ const frame = useRef(0);
17
+ const immediate = !enabled || options.interrupted || options.selected;
18
+ const publish = () => {
19
+ const current = buffer.current!;
20
+ setDisplay(previous => previous.text === current.visible && previous.revision === current.revision ? previous : { text: current.visible, revision: current.revision });
21
+ };
22
+ const handoff = useRef(resumed);
23
+ const presentationClock = useRef(performance.now());
24
+ useLayoutEffect(() => {
25
+ cancelAnimationFrame(frame.current);
26
+ if (paused && !immediate && !document.hidden) return;
27
+ const current = buffer.current!;
28
+ let previous = performance.now();
29
+ current.update(source, handoff.current ? presentationClock.current : previous, { immediate: immediate || document.hidden, finished: !streaming && !resumed });
30
+ publish();
31
+ const tick = (now: number) => {
32
+ // A delayed browser frame must not dump the whole buffered response at
33
+ // first paint. Bound catch-up by visible frame time for this handoff only.
34
+ presentationClock.current += Math.min(32, Math.max(0, now - previous));
35
+ previous = now;
36
+ if (document.hidden) current.flush();
37
+ else current.advance(handoff.current ? presentationClock.current : now);
38
+ publish();
39
+ if (current.pending) frame.current = requestAnimationFrame(tick);
40
+ else handoff.current = false;
41
+ };
42
+ if (current.pending) frame.current = requestAnimationFrame(tick);
43
+ return () => cancelAnimationFrame(frame.current);
44
+ }, [source, streaming, immediate, paused, resumed]);
45
+ useEffect(() => {
46
+ const hidden = () => {
47
+ if (!document.hidden) return;
48
+ cancelAnimationFrame(frame.current);
49
+ buffer.current!.flush(); publish(); setFinalizing(false);
50
+ };
51
+ document.addEventListener('visibilitychange', hidden);
52
+ return () => document.removeEventListener('visibilitychange', hidden);
53
+ }, []);
54
+ const pending = display.text !== source;
55
+ useEffect(() => {
56
+ if (streaming || pending) { setFinalizing(true); return; }
57
+ if (immediate || document.hidden) { setFinalizing(false); return; }
58
+ const timer = setTimeout(() => setFinalizing(false), WORD_MOTION.duration + WORD_MOTION.maxDelay);
59
+ return () => clearTimeout(timer);
60
+ }, [streaming, pending, immediate]);
61
+ return { text: immediate ? source : display.text, pending: !immediate && pending, revision: display.revision, formatStreaming: streaming || (!immediate && (pending || finalizing)), reveal: enabled && !options.interrupted && !options.selected };
62
+ }
@@ -0,0 +1,4 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>;
3
+ export default classes;
4
+ }
@@ -0,0 +1,77 @@
1
+ import type { TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client';
2
+
3
+ export interface TimelineItem {
4
+ readonly turn: number;
5
+ readonly prompt: string;
6
+ readonly response: string;
7
+ readonly hasDeliverables: boolean;
8
+ readonly anchor:
9
+ | { readonly kind: 'loaded'; readonly key: string }
10
+ | { readonly kind: 'unloaded'; readonly seq: number };
11
+ }
12
+
13
+ interface RawOutlineEntry {
14
+ readonly turn?: unknown;
15
+ readonly seq?: unknown;
16
+ readonly prompt?: unknown;
17
+ readonly response?: unknown;
18
+ }
19
+
20
+ interface LoadedTurnNavigationItem {
21
+ readonly turn: number;
22
+ readonly anchorKey: string;
23
+ readonly prompt: string;
24
+ readonly response: string;
25
+ }
26
+
27
+ /**
28
+ * Merge host turn outline with loaded navigation items and turn deliverables.
29
+ * Guarantees strictly ascending turn order and dedupes entries.
30
+ */
31
+ export function mergeTimelineItems(
32
+ loaded: readonly LoadedTurnNavigationItem[] | undefined,
33
+ outline: unknown,
34
+ turnsWithDeliverables?: ReadonlySet<number>,
35
+ ): readonly TimelineItem[] {
36
+ const byTurn = new Map<number, TimelineItem>();
37
+
38
+ // 1. Process host whole-log outline (if available)
39
+ if (Array.isArray(outline)) {
40
+ for (const raw of outline) {
41
+ if (typeof raw !== 'object' || raw === null) continue;
42
+ const entry = raw as RawOutlineEntry;
43
+ if (typeof entry.turn !== 'number' || !Number.isSafeInteger(entry.turn) || entry.turn < 0) continue;
44
+ if (typeof entry.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0) continue;
45
+
46
+ byTurn.set(entry.turn, {
47
+ turn: entry.turn,
48
+ prompt: typeof entry.prompt === 'string' ? entry.prompt.trim() : '',
49
+ response: typeof entry.response === 'string' ? entry.response.trim() : '',
50
+ hasDeliverables: turnsWithDeliverables?.has(entry.turn) ?? false,
51
+ anchor: { kind: 'unloaded', seq: entry.seq },
52
+ });
53
+ }
54
+ }
55
+
56
+ // 2. Overlay loaded items (taking loaded anchor, preferring freshest previews)
57
+ if (Array.isArray(loaded)) {
58
+ for (const item of loaded) {
59
+ if (typeof item.turn !== 'number') continue;
60
+ const preview = byTurn.get(item.turn);
61
+ const prompt = item.prompt?.trim() || preview?.prompt || '';
62
+ const response = item.response?.trim() || preview?.response || '';
63
+ const hasDeliverables = turnsWithDeliverables?.has(item.turn) ?? preview?.hasDeliverables ?? false;
64
+
65
+ byTurn.set(item.turn, {
66
+ turn: item.turn,
67
+ prompt,
68
+ response,
69
+ hasDeliverables,
70
+ anchor: { kind: 'loaded', key: item.anchorKey },
71
+ });
72
+ }
73
+ }
74
+
75
+ if (byTurn.size === 0) return [];
76
+ return [...byTurn.values()].sort((a, b) => a.turn - b.turn);
77
+ }