dsh-better-display 0.3.7 → 0.3.9

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 (208) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/DESIGN.md +43 -3
  3. package/README.en.md +9 -1
  4. package/README.md +9 -1
  5. package/docs/dependency-update-policy.md +9 -0
  6. package/docs/extension-points.md +172 -0
  7. package/docs/official-rendering-bridge.md +59 -18
  8. package/docs/openapi.yaml +147 -0
  9. package/docs/runbooks/blank-reader.md +39 -0
  10. package/lib/client.js +1629 -586
  11. package/lib/client.js.map +1 -1
  12. package/lib/dsh-better-display.js +535 -57
  13. package/lib/types/client/AssistantNode.d.ts +20 -0
  14. package/lib/types/client/AssistantNode.d.ts.map +1 -0
  15. package/lib/types/client/AssistantNode.js +46 -0
  16. package/lib/types/client/AssistantNode.js.map +1 -0
  17. package/lib/types/client/Blocks.d.ts.map +1 -1
  18. package/lib/types/client/Blocks.js +33 -10
  19. package/lib/types/client/Blocks.js.map +1 -1
  20. package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
  21. package/lib/types/client/ChoreographedFlow.js +2 -0
  22. package/lib/types/client/ChoreographedFlow.js.map +1 -1
  23. package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
  24. package/lib/types/client/ClosedProcessSummary.js +3 -1
  25. package/lib/types/client/ClosedProcessSummary.js.map +1 -1
  26. package/lib/types/client/McpAppFrame.d.ts.map +1 -1
  27. package/lib/types/client/McpAppFrame.js +5 -11
  28. package/lib/types/client/McpAppFrame.js.map +1 -1
  29. package/lib/types/client/OfficialContent.d.ts +17 -3
  30. package/lib/types/client/OfficialContent.d.ts.map +1 -1
  31. package/lib/types/client/OfficialContent.js +33 -7
  32. package/lib/types/client/OfficialContent.js.map +1 -1
  33. package/lib/types/client/Reader.d.ts.map +1 -1
  34. package/lib/types/client/Reader.js +76 -73
  35. package/lib/types/client/Reader.js.map +1 -1
  36. package/lib/types/client/SettingsSection.d.ts.map +1 -1
  37. package/lib/types/client/SettingsSection.js +0 -5
  38. package/lib/types/client/SettingsSection.js.map +1 -1
  39. package/lib/types/client/StickyLane.d.ts +12 -0
  40. package/lib/types/client/StickyLane.d.ts.map +1 -1
  41. package/lib/types/client/StickyLane.js +78 -2
  42. package/lib/types/client/StickyLane.js.map +1 -1
  43. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  44. package/lib/types/client/ToolActivity.js +10 -5
  45. package/lib/types/client/ToolActivity.js.map +1 -1
  46. package/lib/types/client/deliverables.d.ts.map +1 -1
  47. package/lib/types/client/deliverables.js +2 -0
  48. package/lib/types/client/deliverables.js.map +1 -1
  49. package/lib/types/client/index.d.ts +3 -1
  50. package/lib/types/client/index.d.ts.map +1 -1
  51. package/lib/types/client/index.js +15 -1
  52. package/lib/types/client/index.js.map +1 -1
  53. package/lib/types/client/live-turn.d.ts +17 -5
  54. package/lib/types/client/live-turn.d.ts.map +1 -1
  55. package/lib/types/client/live-turn.js +106 -76
  56. package/lib/types/client/live-turn.js.map +1 -1
  57. package/lib/types/client/motion.d.ts +26 -2
  58. package/lib/types/client/motion.d.ts.map +1 -1
  59. package/lib/types/client/motion.js +144 -10
  60. package/lib/types/client/motion.js.map +1 -1
  61. package/lib/types/client/official-slots.d.ts +9 -0
  62. package/lib/types/client/official-slots.d.ts.map +1 -1
  63. package/lib/types/client/official-slots.js +22 -4
  64. package/lib/types/client/official-slots.js.map +1 -1
  65. package/lib/types/client/open-file.d.ts.map +1 -1
  66. package/lib/types/client/open-file.js +1 -6
  67. package/lib/types/client/open-file.js.map +1 -1
  68. package/lib/types/client/projection.d.ts +15 -2
  69. package/lib/types/client/projection.d.ts.map +1 -1
  70. package/lib/types/client/projection.js +16 -4
  71. package/lib/types/client/projection.js.map +1 -1
  72. package/lib/types/client/rewind-cut.d.ts +131 -0
  73. package/lib/types/client/rewind-cut.d.ts.map +1 -0
  74. package/lib/types/client/rewind-cut.js +321 -0
  75. package/lib/types/client/rewind-cut.js.map +1 -0
  76. package/lib/types/client/styles.d.ts +3 -0
  77. package/lib/types/client/styles.d.ts.map +1 -0
  78. package/lib/types/client/styles.js +25 -0
  79. package/lib/types/client/styles.js.map +1 -0
  80. package/lib/types/client/text-transform.d.ts +93 -0
  81. package/lib/types/client/text-transform.d.ts.map +1 -0
  82. package/lib/types/client/text-transform.js +183 -0
  83. package/lib/types/client/text-transform.js.map +1 -0
  84. package/lib/types/client/timeline.d.ts.map +1 -1
  85. package/lib/types/client/timeline.js.map +1 -1
  86. package/lib/types/client/tool-activity.d.ts +11 -4
  87. package/lib/types/client/tool-activity.d.ts.map +1 -1
  88. package/lib/types/client/tool-activity.js +54 -9
  89. package/lib/types/client/tool-activity.js.map +1 -1
  90. package/lib/types/client/turn-slots.d.ts +29 -0
  91. package/lib/types/client/turn-slots.d.ts.map +1 -0
  92. package/lib/types/client/turn-slots.js +62 -0
  93. package/lib/types/client/turn-slots.js.map +1 -0
  94. package/lib/types/client/types.d.ts +18 -0
  95. package/lib/types/client/types.d.ts.map +1 -1
  96. package/lib/types/dsh-better-display.d.ts +4 -1
  97. package/lib/types/dsh-better-display.d.ts.map +1 -1
  98. package/lib/types/dsh-better-display.js +55 -43
  99. package/lib/types/dsh-better-display.js.map +1 -1
  100. package/lib/types/flags.d.ts +7 -0
  101. package/lib/types/flags.d.ts.map +1 -0
  102. package/lib/types/flags.js +37 -0
  103. package/lib/types/flags.js.map +1 -0
  104. package/lib/types/log.d.ts +17 -0
  105. package/lib/types/log.d.ts.map +1 -0
  106. package/lib/types/log.js +122 -0
  107. package/lib/types/log.js.map +1 -0
  108. package/lib/types/metrics.d.ts +18 -0
  109. package/lib/types/metrics.d.ts.map +1 -0
  110. package/lib/types/metrics.js +35 -0
  111. package/lib/types/metrics.js.map +1 -0
  112. package/lib/types/reveal.d.ts +12 -0
  113. package/lib/types/reveal.d.ts.map +1 -0
  114. package/lib/types/reveal.js +147 -0
  115. package/lib/types/reveal.js.map +1 -0
  116. package/lib/types/workspace-path.d.ts +3 -0
  117. package/lib/types/workspace-path.d.ts.map +1 -0
  118. package/lib/types/workspace-path.js +5 -0
  119. package/lib/types/workspace-path.js.map +1 -0
  120. package/package.json +36 -5
  121. package/scripts/changelog.mjs +20 -0
  122. package/scripts/check-agents-md.mjs +67 -0
  123. package/scripts/check-bundle-size.mjs +38 -0
  124. package/scripts/check-dead-flags.mjs +55 -0
  125. package/scripts/check-todo-debt.mjs +69 -0
  126. package/scripts/security-audit-report.mjs +92 -0
  127. package/scripts/verify-commentary-visible.mjs +627 -0
  128. package/src/client/AssistantNode.tsx +72 -0
  129. package/src/client/Blocks.tsx +49 -10
  130. package/src/client/ChoreographedFlow.tsx +3 -1
  131. package/src/client/ClosedProcessSummary.tsx +3 -1
  132. package/src/client/McpAppFrame.tsx +5 -13
  133. package/src/client/OfficialContent.tsx +47 -9
  134. package/src/client/Reader.module.css +103 -21
  135. package/src/client/Reader.tsx +83 -88
  136. package/src/client/SettingsSection.tsx +1 -7
  137. package/src/client/StickyLane.tsx +81 -2
  138. package/src/client/ToolActivity.tsx +10 -6
  139. package/src/client/deliverables.ts +1 -0
  140. package/src/client/index.tsx +18 -2
  141. package/src/client/live-turn.ts +100 -69
  142. package/src/client/motion.tsx +142 -7
  143. package/src/client/official-slots.tsx +23 -4
  144. package/src/client/open-file.ts +2 -8
  145. package/src/client/projection.ts +16 -4
  146. package/src/client/rewind-cut.ts +340 -0
  147. package/src/client/styles.ts +22 -0
  148. package/src/client/text-transform.ts +265 -0
  149. package/src/client/timeline.ts +0 -2
  150. package/src/client/tool-activity.ts +67 -9
  151. package/src/client/turn-slots.ts +80 -0
  152. package/src/client/types.ts +19 -0
  153. package/src/dsh-better-display.ts +57 -42
  154. package/src/flags.ts +40 -0
  155. package/src/log.ts +137 -0
  156. package/src/metrics.ts +50 -0
  157. package/src/reveal.ts +90 -0
  158. package/src/workspace-path.ts +4 -0
  159. package/tests/browser-commentary.tsx +191 -0
  160. package/tests/chat-flow-hook.test.ts +1 -1
  161. package/tests/cloud-gate.test.ts +1 -1
  162. package/tests/context-tool-update.test.ts +1 -1
  163. package/tests/conversation-scroll.test.ts +1 -1
  164. package/tests/deliverables.test.ts +1 -1
  165. package/tests/entry.test.ts +1 -1
  166. package/tests/fixtures/reader-lane-owners.tsx +46 -0
  167. package/tests/fixtures/reader-official-users.tsx +234 -0
  168. package/tests/fixtures/reader-theme-overlap.html +80 -0
  169. package/tests/fixtures/reader-user-bubble.tsx +222 -0
  170. package/tests/flags.test.ts +66 -0
  171. package/tests/fold-choreography.test.ts +32 -8
  172. package/tests/fold-intensity.test.ts +11 -6
  173. package/tests/footer-precedence.test.ts +9 -1
  174. package/tests/history-word-regression.test.ts +1 -1
  175. package/tests/live-turn.test.ts +125 -54
  176. package/tests/log.test.ts +99 -0
  177. package/tests/mcp-apps.test.ts +1 -1
  178. package/tests/message-chrome.test.ts +1 -1
  179. package/tests/metrics.test.ts +52 -0
  180. package/tests/official-slots.test.ts +41 -2
  181. package/tests/open-file.test.ts +1 -1
  182. package/tests/pending-submission.test.ts +12 -1
  183. package/tests/pinned-selection.test.ts +16 -0
  184. package/tests/platform-media.test.ts +1 -1
  185. package/tests/projection.test.ts +66 -3
  186. package/tests/reader-glass-css.test.ts +28 -0
  187. package/tests/reader-official-users.test.ts +91 -0
  188. package/tests/reader-sticky-surface.test.ts +377 -0
  189. package/tests/reader-theme-lanes.test.ts +54 -0
  190. package/tests/reader-theme-overlap.test.ts +151 -0
  191. package/tests/reader-user-bubble.test.ts +137 -0
  192. package/tests/reasoning-follow.test.ts +1 -1
  193. package/tests/retiring-content.test.ts +229 -0
  194. package/tests/reveal.test.ts +128 -0
  195. package/tests/rewind-cut.test.ts +1090 -0
  196. package/tests/settings-copy.test.ts +1 -1
  197. package/tests/skill-status.test.ts +1 -1
  198. package/tests/stock-install.test.ts +9 -1
  199. package/tests/stream-buffer.test.ts +1 -1
  200. package/tests/styles.test.ts +75 -0
  201. package/tests/terminal-input-preview.test.ts +113 -0
  202. package/tests/text-transform.test.ts +272 -0
  203. package/tests/timeline.test.ts +1 -1
  204. package/tests/tool-clock.test.ts +1 -1
  205. package/tests/tool-diff.test.ts +1 -1
  206. package/tests/waiting-clock.test.ts +1 -1
  207. package/tests/word-timeline.test.ts +1 -1
  208. package/tsdown.config.ts +49 -1
@@ -0,0 +1,80 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Reader theme overlap</title>
6
+ <link rel="stylesheet" href="/Reader.module.css">
7
+ <link rel="stylesheet" href="/MarkdownText.module.css">
8
+ <style>
9
+ :root {
10
+ --dsh-content-font-size: 24px;
11
+ --dsh-content-font-delta: 10px;
12
+ --dsw-alias-bg-base: transparent;
13
+ --dsw-alias-bg-layer-1: #102040;
14
+ --dsw-alias-label-primary: #f5f7fa;
15
+ --dsw-alias-label-secondary: #c5cad3;
16
+ --dsw-alias-label-tertiary: #9aa3af;
17
+ --dsw-font-family: ui-sans-serif, sans-serif;
18
+ --dsw-font-markdown-h2: 700 29px/38px var(--dsw-font-family);
19
+ --dsw-font-markdown-base: 24px/34px var(--dsw-font-family);
20
+ }
21
+ body { margin: 0; background: #ff00aa; }
22
+ .scroll { height: 280px; overflow: auto; background: #ff00aa; }
23
+ .spacer { height: 900px; }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div class="scroll" id="scroll">
28
+ <div class="root" data-dsh-better-display="fixture">
29
+ <div class="column" data-chat-flow="">
30
+ <div class="toolbar" data-reader-lane="toolbar"><button class="textButton">自动折叠开</button></div>
31
+ <section class="turn" data-reader-turn="1">
32
+ <div class="turnStickyRegion">
33
+ <div class="turnStickyLanes">
34
+ <div class="turnProcessSticky" data-reader-lane="status">
35
+ <button class="disclosureButton" type="button"><span class="statusText">用时 64 分 24 秒</span></button>
36
+ </div>
37
+ <div class="closedProcessSummary" id="summary" data-reader-closed-summary>
38
+ <div class="summaryRow">
39
+ <button class="disclosureButton" type="button"><span class="foldSummary">思考×79 · 输出×15 · 工具×79 · 记录×3</span></button>
40
+ <span class="diffStatRoot"><button class="diffStatButton" type="button"><span class="diffAdded">+751</span><span class="diffRemoved">-63</span></button></span>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ <div class="answer">
45
+ <div class="markdown">
46
+ <h2 id="heading">核心对比 (2024/25 赛季, Deloitte 统一口径)</h2>
47
+ <p class="spacer">回答正文在标题下面继续,用来把标题滚进吸顶带。</p>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </section>
52
+ <section class="turn" data-reader-turn="2">
53
+ <div class="turnProcessSticky" id="flow-status" data-reader-lane="flow-status">
54
+ <button class="disclosureButton" type="button"><span class="statusText">用时 64 分 24 秒,字号放大后这一行变高</span></button>
55
+ </div>
56
+ <div class="flowCell" id="flow-summary" data-flow-summary>
57
+ <button class="disclosureButton" type="button"><span class="foldSummary">思考×79 · 输出×15 · 工具×79 · 记录×3</span></button>
58
+ </div>
59
+ <div class="spacer"></div>
60
+ </section>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ <script>
65
+ const root = document.querySelector('[data-dsh-better-display]');
66
+ const turn = document.querySelector('[data-reader-turn="2"]');
67
+ const toolbar = document.querySelector('.toolbar');
68
+ const flowStatus = document.querySelector('#flow-status');
69
+ const flowSummary = document.querySelector('#flow-summary');
70
+ const publish = () => {
71
+ root.style.setProperty('--reader-toolbar-height', `${Math.ceil(toolbar.getBoundingClientRect().height)}px`);
72
+ turn.style.setProperty('--reader-status-height', `${Math.ceil(flowStatus.getBoundingClientRect().height)}px`);
73
+ turn.style.setProperty('--reader-summary-height', `${Math.ceil(flowSummary.getBoundingClientRect().height)}px`);
74
+ };
75
+ publish();
76
+ for (const el of [toolbar, flowStatus, flowSummary]) new ResizeObserver(publish).observe(el);
77
+ window.__themeOverlapReady = true;
78
+ </script>
79
+ </body>
80
+ </html>
@@ -0,0 +1,222 @@
1
+ import { Context } from '@deepseek-ai/cordis';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { flushSync } from 'react-dom';
4
+ import { SlotRegistry } from '@fixture/registry';
5
+ import { createSlotRenderer } from '@fixture/renderer';
6
+ import { UserMessageNodeView } from '@fixture/user-message';
7
+ import { Reader } from '../../src/client/Reader.js';
8
+ import { createReaderStore } from '../../src/client/store.js';
9
+ import { OFFICIAL_SEATS, OFFICIAL_SLOTS, installOfficialSlots, officialChildren } from '../../src/client/official-slots.js';
10
+ import type { ReaderProps } from '../../src/client/types.js';
11
+
12
+ // Real Reader, real slot seats, and the host's user/steering renderer.
13
+ // Session text is the only synthetic part. Width is measured after layout.
14
+
15
+ const CASES = [
16
+ { name: 'short', width: 748, axis: '', text: '升级插件' },
17
+ { name: 'thirty', width: 748, axis: '', text: '测'.repeat(30) },
18
+ { name: 'long', width: 748, axis: '', text: '测'.repeat(80) },
19
+ { name: 'narrow', width: 480, axis: '', text: '测'.repeat(30) },
20
+ { name: 'wide-axis', width: 1000, axis: '1000px', text: '测'.repeat(80) },
21
+ ] as const;
22
+
23
+ function turnOf() {
24
+ return {
25
+ turn: 1, status: 'closed' as const, start: { seq: 1, time: 1 },
26
+ end: { time: 2, data: { reason: { kind: 'completed' } } }, data: new Map(), steps: [],
27
+ };
28
+ }
29
+
30
+ function messageNode(kind: 'user' | 'steering', text: string) {
31
+ const turn = turnOf();
32
+ return {
33
+ key: `${kind}-key`, kind, anchorSeq: kind === 'user' ? 1 : 2, visibility: 'visible' as const,
34
+ location: { kind: 'turn' as const, turn },
35
+ data: { seq: kind === 'user' ? 1 : 2, time: 1, content: [{ type: 'text', text }] },
36
+ };
37
+ }
38
+
39
+ function readerProps(text: string, renderSlot: ReaderProps['renderSlot'], renderSlotChain: ReaderProps['renderSlotChain']): ReaderProps {
40
+ const turn = turnOf();
41
+ const user = messageNode('user', text);
42
+ const steering = messageNode('steering', text);
43
+ user.location = { kind: 'turn', turn };
44
+ steering.location = { kind: 'turn', turn };
45
+ const nodes = new Map([[user.key, user], [steering.key, steering]]);
46
+ const chat = { order: [user.key, steering.key], nodes, timeline: { turns: new Map([[1, turn]]) } };
47
+ const session = {
48
+ running: false, openState: 'ready', hasMore: false, loadingOlder: false,
49
+ pendingSubmissions: [{ requestId: `pending-${text.length}`, text, time: 1, placement: 'transcript' }],
50
+ };
51
+ const store = createReaderStore().create(`session-${text.length}`);
52
+ store.actions.setMotion(false);
53
+ const source = <T,>(value: T) => ({ getSnapshot: () => value, subscribe: () => () => {} });
54
+ return {
55
+ sessionId: `session-${text.length}`,
56
+ useChat: (select: (snapshot: typeof chat) => unknown) => select(chat),
57
+ useSession: (select: (snapshot: typeof session) => unknown) => select(session),
58
+ useSessions: (select: (snapshot: { byId: Record<string, { cwd: string }> }) => unknown) => select({ byId: { [`session-${text.length}`]: { cwd: '/fixture' } } }),
59
+ useSessionStatus: (select: (snapshot: Map<string, unknown>) => unknown) => select(new Map()),
60
+ useStore: (select: (snapshot: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
61
+ actions: store.actions,
62
+ t: (key: string) => key,
63
+ loadImage: async () => ({ data: new Uint8Array(), mediaType: 'image/png' }),
64
+ officialImageLoader: Object.assign(async () => null, { peek: () => null }),
65
+ officialFileMentions: () => undefined,
66
+ officialPreviewFile: () => {},
67
+ officialHost: source({ home: '/fixture' }),
68
+ fillComposer: () => true,
69
+ openFile: () => {},
70
+ loadOlder: async () => {},
71
+ openView: () => {},
72
+ renderSlot,
73
+ renderSlotChain,
74
+ } as ReaderProps;
75
+ }
76
+
77
+ function NativeColumn({ renderSlot, kind, text }: {
78
+ renderSlot: ReaderProps['renderSlot'];
79
+ kind: 'user' | 'steering';
80
+ text: string;
81
+ }) {
82
+ const node = messageNode(kind, text);
83
+ const owner = {
84
+ cwd: '/fixture', openFile: () => {}, openSkill: () => {}, forkAt: () => {},
85
+ inspectCall: () => {}, loadImage: async () => null,
86
+ renderMessageImages: () => null, fileMentions: () => undefined, node,
87
+ };
88
+ return (
89
+ <div data-native={kind} style={{ width: '100%', maxWidth: 'var(--dsh-chat-content-width, 748px)', display: 'flex', flexDirection: 'column' }}>
90
+ <div style={{ minWidth: 0 }}>
91
+ {renderSlot(OFFICIAL_SLOTS.nodes, owner, {
92
+ entryKey: kind,
93
+ hookContext: { turnData: node.location.turn.data, disclosureReset: { getSnapshot: () => 0, subscribe: () => () => {}, set: () => {} } },
94
+ fallback: null,
95
+ })}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ function boxOf(root: ParentNode | null, kind: 'user' | 'steering' | 'pending') {
102
+ const scope = kind === 'pending'
103
+ ? root?.querySelector('[data-reader-pending-submission]')
104
+ : root?.querySelector(`[data-reader-key="${kind}-key"]`);
105
+ if (!scope) return null;
106
+ if (kind === 'pending') {
107
+ return [...scope.children].find(element => element instanceof HTMLElement && element.querySelector('button') === null) ?? null;
108
+ }
109
+ const slot = scope.querySelector('[data-slot]');
110
+ return slot?.firstElementChild?.firstElementChild?.firstElementChild ?? null;
111
+ }
112
+
113
+ function nativeBox(frame: ParentNode, kind: 'user' | 'steering') {
114
+ const column = frame.querySelector(`[data-native="${kind}"]`);
115
+ const slot = column?.querySelector('[data-slot]');
116
+ return slot?.firstElementChild?.firstElementChild?.firstElementChild ?? null;
117
+ }
118
+
119
+ function measure(element: Element | null) {
120
+ if (!element) return { w: 0, h: 0, font: '', line: '' };
121
+ const style = getComputedStyle(element);
122
+ const rect = element.getBoundingClientRect();
123
+ return {
124
+ w: Math.round(rect.width * 10) / 10,
125
+ h: Math.round(rect.height * 10) / 10,
126
+ font: style.fontSize,
127
+ line: style.lineHeight,
128
+ };
129
+ }
130
+
131
+ async function verify() {
132
+ const root = new Context();
133
+ await root.plugin(SlotRegistry);
134
+ let run = async () => {};
135
+ await root.plugin({ name: 'reader-bubble-width', inject: ['slots'], async apply(ctx: Context) {
136
+ const slots = ctx.slots as any;
137
+ slots.install(createSlotRenderer());
138
+ const source = <T,>(value: T) => ({ getSnapshot: () => value, subscribe: () => () => {} });
139
+ const binding = { key: 'session-width', ctx: new Context(), hooks: {}, keyedHooks: {}, props: { sessionId: 'session-width' } };
140
+ slots.installScope('session', {
141
+ current: source(binding), bindingSource: () => source(binding), renderArea: (_: unknown, props: { children?: unknown }) => props.children,
142
+ });
143
+ slots.installLocale({ ...source({}), bind: () => (key: string) => key });
144
+ const HostUser = (props: Record<string, unknown>) => (
145
+ <UserMessageNodeView
146
+ {...props as never}
147
+ t={(props.t as ((key: string) => string) | undefined) ?? ((key: string) => key)}
148
+ openFile={(props.openFile as (() => void) | undefined) ?? (() => {})}
149
+ openSkill={(props.openSkill as (() => void) | undefined) ?? (() => {})}
150
+ renderMessageImages={(props.renderMessageImages as (() => null) | undefined) ?? (() => null)}
151
+ />
152
+ );
153
+ slots.register({ name: 'root', children: {
154
+ 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: { hooks: {
155
+ turnData: (_: unknown, context: { turnData?: unknown }) => () => context.turnData,
156
+ disclosure: (_: unknown, context: { disclosureReset?: { getSnapshot(): number } }) => () => context.disclosureReset,
157
+ } } },
158
+ 'conversation.message.images': { kind: 'single', scope: 'session' },
159
+ 'fixture.reader': { kind: 'single', scope: 'session' },
160
+ } }, ({ SessionProvider, renderSlot }: any) => (
161
+ <SessionProvider>
162
+ {renderSlot('fixture.reader', {})}
163
+ {CASES.map(item => (
164
+ <section key={`native-${item.name}`} data-native-case={item.name} style={{ width: item.width, ['--dsh-chat-content-width' as string]: item.axis || undefined }}>
165
+ <NativeColumn renderSlot={renderSlot} kind="user" text={item.text} />
166
+ <NativeColumn renderSlot={renderSlot} kind="steering" text={item.text} />
167
+ </section>
168
+ ))}
169
+ </SessionProvider>
170
+ ));
171
+ slots.register({ name: 'fixture.reader', children: {
172
+ ...officialChildren(slots),
173
+ 'dsh-better-display.block': { kind: 'chain', scope: 'session' },
174
+ } }, ({ renderSlot, renderSlotChain }: any) => (
175
+ <>
176
+ {CASES.map(item => (
177
+ <section key={item.name} data-case={item.name} style={{ width: item.width, ['--dsh-chat-content-width' as string]: item.axis || undefined }}>
178
+ <div data-reader-mount>
179
+ <Reader {...readerProps(item.text, renderSlot, renderSlotChain)} />
180
+ </div>
181
+ </section>
182
+ ))}
183
+ </>
184
+ ));
185
+ slots.register({ name: 'conversation.chat.node', key: 'user', locale: 'chat' }, HostUser);
186
+ slots.register({ name: 'conversation.chat.node', key: 'steering', locale: 'chat' }, HostUser);
187
+ const stopMirror = installOfficialSlots(ctx);
188
+ run = async () => {
189
+ await new Promise(resolve => queueMicrotask(resolve));
190
+ const container = document.getElementById('app')!;
191
+ const reactRoot = createRoot(container);
192
+ flushSync(() => reactRoot.render(slots.renderSlot('root', {})));
193
+ for (const reader of container.querySelectorAll<HTMLElement>('[data-dsh-better-display]')) reader.style.padding = '0';
194
+ await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)));
195
+ const readerSlot = container.querySelector('[data-reader-key="user-key"] [data-slot]')?.getAttribute('data-slot') ?? '';
196
+ const nativeSlot = container.querySelector('[data-native="user"] [data-slot]')?.getAttribute('data-slot') ?? '';
197
+ const rows = CASES.map(item => {
198
+ const frame = container.querySelector(`[data-case="${item.name}"]`);
199
+ const nativeFrame = container.querySelector(`[data-native-case="${item.name}"]`);
200
+ const mount = frame?.querySelector('[data-reader-mount]') ?? null;
201
+ const user = measure(boxOf(mount, 'user'));
202
+ const steering = measure(boxOf(mount, 'steering'));
203
+ const pending = measure(boxOf(mount, 'pending'));
204
+ const nativeUser = measure(nativeFrame ? nativeBox(nativeFrame, 'user') : null);
205
+ const nativeSteering = measure(nativeFrame ? nativeBox(nativeFrame, 'steering') : null);
206
+ return {
207
+ name: item.name,
208
+ user, steering, pending, nativeUser, nativeSteering,
209
+ readerHtml: mount?.querySelector('[data-reader-key="user-key"]')?.innerHTML.slice(0, 400) ?? '',
210
+ };
211
+ });
212
+ stopMirror();
213
+ Object.assign(window, { readerBubbleResult: { readerSlot, nativeSlot, expectedReaderSlot: OFFICIAL_SEATS.nodes, expectedNativeSlot: OFFICIAL_SLOTS.nodes, rows } });
214
+ };
215
+ } });
216
+ await run();
217
+ }
218
+
219
+ verify().catch(error => {
220
+ console.error(error);
221
+ Object.assign(window, { readerBubbleResult: { error: `${error instanceof Error ? error.stack : String(error)}\n${document.getElementById('app')?.innerHTML.slice(0, 2000)}` } });
222
+ });
@@ -0,0 +1,66 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { test } from 'vitest';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { apply } from '../src/dsh-better-display.js';
9
+ import { defaultFlags, loadFlags } from '../src/flags.js';
10
+
11
+ test('readerEnabled defaults to true', () => {
12
+ assert.equal(defaultFlags.readerEnabled, true);
13
+ const file = JSON.parse(readFileSync(fileURLToPath(new URL('../flags.json', import.meta.url)), 'utf8')) as { readerEnabled: boolean };
14
+ assert.equal(file.readerEnabled, true);
15
+ assert.equal(loadFlags().readerEnabled, true);
16
+ assert.equal(loadFlags({ readerEnabled: false }).readerEnabled, false);
17
+ const dir = mkdtempSync(join(tmpdir(), 'bd-flags-'));
18
+ try {
19
+ const broken = join(dir, 'flags.json');
20
+ writeFileSync(broken, '{ "readerEnabled": "yes" }');
21
+ assert.equal(loadFlags(broken).readerEnabled, true);
22
+ assert.equal(loadFlags(join(dir, 'missing.json')).readerEnabled, true);
23
+ } finally {
24
+ rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ test('apply skips route registration when readerEnabled is false', () => {
29
+ let registered = 0;
30
+ let effects = 0;
31
+ apply({
32
+ webServer: {
33
+ register() {
34
+ registered += 1;
35
+ return () => undefined;
36
+ },
37
+ },
38
+ effect(run: () => void) {
39
+ effects += 1;
40
+ run();
41
+ },
42
+ } as never, { flags: { readerEnabled: false } });
43
+ assert.equal(registered, 0);
44
+ assert.equal(effects, 0);
45
+ assert.doesNotThrow(() => apply({} as never, { flags: { readerEnabled: true } }));
46
+ });
47
+
48
+ test('check-dead-flags fails when a flags.json key is unused under src', () => {
49
+ const script = fileURLToPath(new URL('../scripts/check-dead-flags.mjs', import.meta.url));
50
+ const ok = spawnSync(process.execPath, [script], { encoding: 'utf8' });
51
+ assert.equal(ok.status, 0, ok.stderr || ok.stdout);
52
+ const dir = mkdtempSync(join(tmpdir(), 'bd-dead-flags-'));
53
+ try {
54
+ const flags = join(dir, 'flags.json');
55
+ writeFileSync(flags, JSON.stringify({ readerEnabled: true, unusedFlag: false }));
56
+ const bad = spawnSync(process.execPath, [script], {
57
+ encoding: 'utf8',
58
+ env: { ...process.env, FLAGS_JSON: flags },
59
+ });
60
+ assert.notEqual(bad.status, 0);
61
+ assert.match(bad.stderr, /unusedFlag/);
62
+ assert.doesNotMatch(bad.stderr, /readerEnabled/);
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ });
@@ -1,28 +1,52 @@
1
- import test from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { collapseRows, containsNewUser, flowRows, FOLD_TIMING, retiringKeys } from '../src/client/fold-choreography.js';
4
4
  import { presentLiveTurn } from '../src/client/live-turn.js';
5
5
  import type { LiveStep } from '../src/client/live-turn.js';
6
6
  const reasoning = (n: number): LiveStep => ({ kind: 'reasoning', key: `r${n}`, nodeKey: `n${n}`, start: 0, blocks: [], step: n });
7
7
  const body: LiveStep = { kind: 'body', key: 'b1', nodeKey: 'n1', start: 1, blocks: [], step: 1 };
8
+ const tool: LiveStep = {
9
+ kind: 'tool',
10
+ key: 't1',
11
+ entry: {
12
+ kind: 'tool',
13
+ key: 't1',
14
+ callId: 'c1',
15
+ step: 0,
16
+ order: 0,
17
+ draft: { kind: 'tool-call', callId: 'c1', name: 'read', argsRaw: '{"path":"Reader.tsx"}' },
18
+ },
19
+ };
8
20
  const open = { status: 'open' } as const;
9
21
  test('first fold retains every visible step key and admits no incoming reasoning', () => {
10
- const before = presentLiveTurn([reasoning(1), body], open);
11
- const after = presentLiveTurn([reasoning(1), body, reasoning(2)], open);
12
- assert.deepEqual(retiringKeys(before, after, {}), ['r1', 'b1']);
22
+ const before = presentLiveTurn([reasoning(1), tool], open);
23
+ const after = presentLiveTurn([reasoning(1), tool, reasoning(2)], open);
24
+ assert.deepEqual(retiringKeys(before, after, {}), ['r1', 't1']);
13
25
  const rows = collapseRows(before, after);
14
- assert.deepEqual(rows.filter(x => x.kind === 'step').map(x => x.key), ['r1', 'b1']);
26
+ assert.deepEqual(rows.filter(x => x.kind === 'step').map(x => x.key), ['r1', 't1']);
15
27
  assert.equal(rows[0].kind, 'summary');
16
- assert.equal(rows[0].kind === 'summary' && rows[0].item.summary, '思考×0 · 输出×0');
28
+ assert.equal(rows[0].kind === 'summary' && rows[0].item.summary, '思考×0 · 工具×0');
17
29
  assert.equal(new Set(flowRows(after).map(row => row.key)).size, flowRows(after).length);
18
30
  });
19
31
  test('repeat fold retires only displayed open rows, retaining the same summary identity', () => {
20
- const before = presentLiveTurn([reasoning(1), body, reasoning(2)], open);
21
- const after = presentLiveTurn([reasoning(1), body, reasoning(2), reasoning(3)], open);
32
+ const before = presentLiveTurn([reasoning(1), tool, reasoning(2)], open);
33
+ const after = presentLiveTurn([reasoning(1), tool, reasoning(2), reasoning(3)], open);
22
34
  assert.deepEqual(retiringKeys(before, after, {}), ['r2']);
23
35
  assert.equal(collapseRows(before, after)[0].key, flowRows(before)[0].key);
24
36
  assert.deepEqual(retiringKeys(before, after, { 'live-fold:r1': true }), []);
25
37
  });
38
+ test('narration never enters a fold cell, so choreography never retires it', () => {
39
+ const before = presentLiveTurn([reasoning(1)], open);
40
+ const withNarration = presentLiveTurn([reasoning(1), body], open);
41
+ assert.deepEqual(retiringKeys(before, withNarration, {}), []);
42
+ assert.deepEqual(flowRows(withNarration).map(row => row.key), ['r1', 'b1']);
43
+
44
+ const after = presentLiveTurn([reasoning(1), body, tool, reasoning(2)], open);
45
+ assert.equal(after.some(item => item.kind === 'fold' && item.steps.some(step => step.kind === 'body')), false);
46
+ // The prior reasoning run folds (r1 retires), but narration itself never does.
47
+ assert.deepEqual(retiringKeys(withNarration, after, {}), ['r1']);
48
+ assert.ok(!retiringKeys(withNarration, after, {}).includes('b1'));
49
+ });
26
50
  test('user admission bypasses buffered choreography and preserves source ordering', () => {
27
51
  const before = presentLiveTurn([reasoning(1)], open);
28
52
  const user: LiveStep = { kind: 'user', key: 'u2', nodeKey: 'u2' };
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test } from 'vitest';
2
2
  import assert from 'node:assert/strict';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { dirname, resolve } from 'node:path';
@@ -57,17 +57,22 @@ test('fold slider maps 0/1/2 and migrates older persist flags', () => {
57
57
  assert.equal(processOnlyFromIntensity(2), true);
58
58
  });
59
59
 
60
- test('autoFold false keeps every step open; autoFold true is current-main fold-on-next-reasoning', () => {
60
+ test('autoFold false keeps every step open; autoFold true folds only thinking/tool runs', () => {
61
61
  const steps = [reasoning(1), body, tool, reasoning(2)];
62
62
  const none = presentLiveTurn(steps, open, false);
63
63
  assert.equal(none.some(item => item.kind === 'fold'), false);
64
64
  assert.deepEqual(none.map(item => item.key), ['r1', 'b1', 't1', 'r2']);
65
65
 
66
+ // Narration stays open; each prior reasoning/tool run folds in its own place.
66
67
  const standard = presentLiveTurn(steps, open, true);
67
- assert.equal(standard[0]?.kind, 'fold');
68
- if (standard[0]?.kind !== 'fold') throw new Error('expected standard fold');
69
- assert.deepEqual(standard[0].steps.map(step => step.key), ['r1', 'b1', 't1']);
70
- assert.deepEqual(standard.filter(item => item.kind === 'open').map(item => item.key), ['r2']);
68
+ assert.deepEqual(standard.map(item => item.kind), ['fold', 'open', 'fold', 'open']);
69
+ assert.deepEqual(standard.filter(item => item.kind === 'open').map(item => item.key), ['b1', 'r2']);
70
+ const folds = standard.filter(item => item.kind === 'fold');
71
+ assert.equal(folds.length, 2);
72
+ if (folds[0]?.kind !== 'fold' || folds[1]?.kind !== 'fold') throw new Error('expected standard folds');
73
+ assert.deepEqual(folds[0].steps.map(step => step.key), ['r1']);
74
+ assert.deepEqual(folds[1].steps.map(step => step.key), ['t1']);
75
+ assert.equal(standard.some(item => item.kind === 'fold' && item.steps.some(step => step.kind === 'body')), false);
71
76
  });
72
77
 
73
78
  test('root reader store persists glass off and standard fold on dsh.reader.v1', () => {
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
- import { test } from 'node:test';
4
+ import { test } from 'vitest';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -21,6 +21,7 @@ const HOST_FOOTER_Z = 7;
21
21
 
22
22
  /** Lanes a scroll can clamp into the footer band. */
23
23
  const CLAMPED_LANES = [
24
+ '.turnStickyLanes',
24
25
  '.turnProcessSticky',
25
26
  '.liveFoldContainer',
26
27
  '.flowCell[data-flow-summary]',
@@ -85,6 +86,13 @@ test('the top toolbar lane stays at 7 and pins to the scrollport top', () => {
85
86
  assert.equal(declarations.get('top'), '0');
86
87
  });
87
88
 
89
+ test('official code block banners stay beneath Reader sticky lanes', () => {
90
+ const blockZ = Number(mergedDeclarations(css, '.root :global(.md-code-block)').get('z-index'));
91
+ const summaryZ = Number(mergedDeclarations(css, '.flowCell[data-flow-summary]').get('z-index'));
92
+ assert.ok(Number.isFinite(blockZ), 'Reader scopes official markdown code blocks into a stacking context');
93
+ assert.ok(blockZ < summaryZ, `code block banner context (${blockZ}) must stay below the summary lane (${summaryZ})`);
94
+ });
95
+
88
96
  test('the live status lane pins below the toolbar lane instead of over it', () => {
89
97
  const declarations = mergedDeclarations(css, '.turnProcessSticky');
90
98
  assert.match(
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
2
+ import { test } from 'vitest';
3
3
  import { WordTimeline } from '../src/client/word-timeline.js';
4
4
 
5
5
  test('opening a long existing answer does not animate every historical word', () => {