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
@@ -2,7 +2,6 @@ import type { AssistantChatData, ChatConversationViewNode } from '@deepseek-ai/d
2
2
  import type { AssistantBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
3
3
  import { assistantSegments, hasVisibleBody } from './projection.js';
4
4
  import type { TurnBoundary } from './projection.js';
5
- import { activitySummary, stringValue } from './tool-activity.js';
6
5
  import type { ReaderFlowEntry, ToolActivityEntry } from './tool-activity.js';
7
6
 
8
7
  export type LiveStep =
@@ -40,17 +39,6 @@ export function foldSummary(steps: readonly LiveStep[]): string {
40
39
  return parts.join(' · ') || '此前步骤';
41
40
  }
42
41
 
43
- /** One chain: fold only when a new reasoning step has prior body/tool/reasoning. */
44
- export function splitChain(chain: readonly LiveStep[]): { fold: readonly LiveStep[] | null; open: readonly LiveStep[] } {
45
- const lastReasoning = chain.findLastIndex(step => step.kind === 'reasoning');
46
- if (lastReasoning < 0) return { fold: null, open: chain };
47
- const prior = chain.slice(0, lastReasoning);
48
- if (!prior.length) return { fold: null, open: chain };
49
- const trigger = prior.some(step => step.kind === 'reasoning' || step.kind === 'body' || step.kind === 'tool');
50
- if (!trigger) return { fold: null, open: chain };
51
- return { fold: prior, open: chain.slice(lastReasoning) };
52
- }
53
-
54
42
  function skipReasoning(part: { kind: string; blocks: AssistantBlock[] }): boolean {
55
43
  return part.kind === 'reasoning' && !part.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '');
56
44
  }
@@ -125,74 +113,117 @@ export function segmentLiveTurn(
125
113
  return steps;
126
114
  }
127
115
 
128
- /** One finished tool call, described by what it actually did. */
129
- function toolSummary(entry: ToolActivityEntry): string {
130
- const info = activitySummary(entry);
131
- const clip = (value: string | undefined, max = 38): string | undefined => {
132
- if (!value) return undefined;
133
- const flat = value.replace(/\s+/gu, ' ').trim();
134
- if (!flat) return undefined;
135
- return flat.length > max ? flat.slice(0, max - 1) + '\u2026' : flat;
136
- };
137
- const base = info.target ? clip(info.target.split(/[/\\]/u).at(-1), 28) : undefined;
138
- const said = clip(stringValue(info.args, 'description'), 40);
139
- switch (info.category) {
140
- case 'read': return '读取 ' + (base ?? '文件');
141
- case 'write': return clip(info.title, 40) ?? '写入文件';
142
- case 'terminal': {
143
- const cmd = clip(info.command, 32);
144
- return said ?? (cmd ? '运行 ' + cmd : '运行命令');
145
- }
146
- case 'search': {
147
- const needle = clip(stringValue(info.args, 'pattern', 'query'), 26);
148
- return info.name === 'glob' ? '查找 ' + (needle ?? '文件') : '搜索 ' + (needle ?? '内容');
149
- }
150
- case 'web': {
151
- const query = clip(info.target, 30);
152
- return (info.name === 'web_search' ? '搜索网页' : '读取网页') + (query ? ' ' + query : '');
153
- }
154
- default: {
155
- // Schema-less tools (run_code and friends) describe themselves in an
156
- // argument rather than a known field: prefer the model's own
157
- // description, then the first meaningful line of the code it ran.
158
- if (said) return said;
159
- const code = stringValue(info.args, 'code', 'source', 'script');
160
- if (code) {
161
- const line = code.split('\n').map(part => part.trim())
162
- .find(part => part && !/^[)\]}]/.test(part) && !/^(\/\/|\*|\/\*|#)/.test(part));
163
- if (line) return clip(line.replace(/\s+/gu, ' '), 44) ?? '运行代码';
164
- }
165
- return clip(info.title ?? info.name, 32) ?? '工具调用';
166
- }
167
- }
116
+ /** Only a run that contains real thinking/tool work is worth folding. */
117
+ function foldableRun(run: readonly LiveStep[]): boolean {
118
+ return run.some(step => step.kind === 'reasoning' || step.kind === 'tool');
168
119
  }
169
120
 
121
+ /**
122
+ * One live chain: narration stays visible, while every earlier reasoning/tool
123
+ * run collapses into its own box at its original position.
124
+ */
170
125
  export function presentLiveTurn(steps: readonly LiveStep[], boundary: TurnBoundary, autoFold = true): LiveTurnItem[] {
171
126
  const live = autoFold && liveFoldEnabled(boundary);
172
127
  const items: LiveTurnItem[] = [];
173
- let chain: LiveStep[] = [];
174
- const flush = () => {
175
- if (!chain.length) return;
176
- if (!live) {
177
- for (const step of chain) items.push({ kind: 'open', key: step.key, step });
178
- chain = [];
179
- return;
180
- }
181
- const { fold, open } = splitChain(chain);
182
- if (fold?.length) {
183
- items.push({ kind: 'fold', key: `live-fold:${chain[0]!.key}`, steps: fold, summary: foldSummary(fold) });
128
+ const open = (step: LiveStep) => items.push({ kind: 'open', key: step.key, step });
129
+ const fold = (run: readonly LiveStep[]) => {
130
+ if (!run.length) return;
131
+ if (live && foldableRun(run)) {
132
+ items.push({ kind: 'fold', key: `live-fold:${run[0]!.key}`, steps: run, summary: foldSummary(run) });
133
+ } else {
134
+ for (const step of run) open(step);
184
135
  }
185
- for (const step of open) items.push({ kind: 'open', key: step.key, step });
186
- chain = [];
187
136
  };
137
+ let run: LiveStep[] = [];
138
+ const flush = () => { fold(run); run = []; };
139
+
140
+ // A user insert resets the chain: steps before it are never folded by
141
+ // reasoning after it. Split into user-delimited segments and walk each one.
142
+ const segments: LiveStep[][] = [];
143
+ let segment: LiveStep[] = [];
188
144
  for (const step of steps) {
189
145
  if (step.kind === 'user') {
190
- flush();
191
- items.push({ kind: 'user', key: step.key, step });
146
+ segments.push(segment); segments.push([step]); segment = [];
192
147
  } else {
193
- chain.push(step);
148
+ segment.push(step);
194
149
  }
195
150
  }
196
- flush();
151
+ segments.push(segment);
152
+
153
+ for (const part of segments) {
154
+ if (!part.length) continue;
155
+ const first = part[0]!;
156
+ if (part.length === 1 && first.kind === 'user') {
157
+ items.push({ kind: 'user', key: first.key, step: first });
158
+ continue;
159
+ }
160
+ if (!live) {
161
+ for (const step of part) open(step);
162
+ continue;
163
+ }
164
+ // Narration is never folded. Once a later reasoning step exists, every
165
+ // reasoning/tool run before it folds at its own position, so the latest
166
+ // thinking and everything after it stay open while earlier steps collapse —
167
+ // even across narration blocks, which the reader still renders in place.
168
+ const lastReasoning = part.findLastIndex(step => step.kind === 'reasoning');
169
+ if (lastReasoning <= 0) {
170
+ for (const step of part) open(step);
171
+ continue;
172
+ }
173
+ for (let index = 0; index < part.length; index++) {
174
+ const step = part[index]!;
175
+ if (index < lastReasoning) {
176
+ if (step.kind === 'body') { flush(); open(step); }
177
+ else run.push(step);
178
+ } else {
179
+ if (index === lastReasoning) flush();
180
+ open(step);
181
+ }
182
+ }
183
+ flush();
184
+ }
197
185
  return items;
198
186
  }
187
+
188
+ /**
189
+ * Summaries for collapsed process that sits between two narration blocks.
190
+ *
191
+ * A live-fold cell already shows that summary while the turn is open. Drawing
192
+ * the seam as well repeats the same counts, so a run whose every step is
193
+ * already inside a fold cell contributes neither a seam nor `after`. `after`
194
+ * is what marks the previous body with `data-reader-seam-after` and drops its
195
+ * bottom padding; skipping the seam must leave that spacing alone.
196
+ */
197
+ export function planProcessSeams(
198
+ steps: readonly LiveStep[],
199
+ liveItems: readonly LiveTurnItem[],
200
+ expanded: boolean,
201
+ ): { before: Map<string, string>; after: Set<string> } {
202
+ const before = new Map<string, string>();
203
+ const after = new Set<string>();
204
+ if (expanded) return { before, after };
205
+ const liveFolded = new Set(
206
+ liveItems.flatMap(item => item.kind === 'fold' ? item.steps.map(step => step.key) : []),
207
+ );
208
+ let previousBody: Extract<LiveStep, { kind: 'body' }> | undefined;
209
+ let process: LiveStep[] = [];
210
+ for (const step of steps) {
211
+ if (step.kind === 'body') {
212
+ const folded = process.filter(item => item.kind !== 'user');
213
+ const coveredByLiveFold = folded.length > 0 && folded.every(item => liveFolded.has(item.key));
214
+ if (!coveredByLiveFold && previousBody
215
+ && folded.some(item => item.kind === 'reasoning' || item.kind === 'tool' || item.kind === 'other')) {
216
+ before.set(step.key, foldSummary(folded));
217
+ after.add(previousBody.key);
218
+ }
219
+ previousBody = step;
220
+ process = [];
221
+ } else if (step.kind === 'user') {
222
+ previousBody = undefined;
223
+ process = [];
224
+ } else {
225
+ process.push(step);
226
+ }
227
+ }
228
+ return { before, after };
229
+ }
@@ -2,6 +2,7 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState }
2
2
  import type { ReactNode, RefObject } from 'react';
3
3
  import css from './Reader.module.css';
4
4
  import { StreamMotionContext } from './streaming.js';
5
+ import { reconcileTurnSlots, type TurnSlot } from './turn-slots.js';
5
6
 
6
7
  const EASING = 'cubic-bezier(.22,1,.36,1)';
7
8
 
@@ -16,7 +17,7 @@ export function useMotionAllowed(enabled: boolean): boolean {
16
17
  return enabled && !reduced;
17
18
  }
18
19
 
19
- export function usePinnedSelection(root: RefObject<HTMLElement>, selector = '[data-reader-answer], [data-reader-process]'): readonly string[] {
20
+ export function usePinnedSelection(root: RefObject<HTMLElement>, selector = '[data-reader-answer], [data-reader-body], [data-reader-process]'): readonly string[] {
20
21
  const [keys, setKeys] = useState<readonly string[]>([]);
21
22
  useEffect(() => {
22
23
  const update = () => {
@@ -89,7 +90,7 @@ export function StatusText({ text, motion, shimmer = false }: { text: string; mo
89
90
 
90
91
  export function Disclosure({ open, onChange, label, controls, buttonRef, ariaLabel }: {
91
92
  open: boolean; onChange: (value: boolean) => void; label: ReactNode;
92
- controls?: string; buttonRef: RefObject<HTMLButtonElement>;
93
+ controls?: string; buttonRef?: RefObject<HTMLButtonElement>;
93
94
  ariaLabel?: string;
94
95
  }) {
95
96
  const name = ariaLabel ?? '思考与过程';
@@ -170,12 +171,16 @@ export function ProcessFragment({ open, motion, onRead, returnFocusTo, nodeKey,
170
171
  }
171
172
 
172
173
  /** Retire only narration that was actually visible; historical rows stay folded. */
173
- export function RetiringContent({ visible, children }: { visible: boolean; children: ReactNode }) {
174
+ export function RetiringContent({ visible, children, onRetired }: { visible: boolean; children: ReactNode; onRetired?: () => void }) {
174
175
  const { enabled } = useContext(StreamMotionContext);
175
176
  const root = useRef<HTMLDivElement>(null);
176
177
  const animation = useRef<Animation | null>(null);
177
178
  const [present, setPresent] = useState(visible);
178
179
  const [focusHeld, setFocusHeld] = useState(false);
180
+ // Held in a ref so a caller passing an inline closure does not restart the
181
+ // collapse every render, and so the settle path always sees the latest one.
182
+ const retired = useRef(onRetired);
183
+ retired.current = onRetired;
179
184
  useLayoutEffect(() => {
180
185
  const element = root.current;
181
186
  if (visible) {
@@ -184,14 +189,52 @@ export function RetiringContent({ visible, children }: { visible: boolean; child
184
189
  return;
185
190
  }
186
191
  if (!element) return;
187
- if (element.contains(document.activeElement)) { setFocusHeld(true); return; }
188
- if (focusHeld) return;
192
+ // Reduced motion and a hidden page settle immediately. Focus may defer a
193
+ // collapse that will actually play; it must not pin a reduced-motion rewind.
194
+ // Move it to a surviving turn or the reading column first, or the browser
195
+ // drops it on the document when this subtree unmounts.
196
+ let retiredNow = false;
197
+ const retireNow = () => {
198
+ if (retiredNow) return;
199
+ retiredNow = true;
200
+ animation.current?.cancel(); animation.current = null;
201
+ focusSurvivesRetire(element);
202
+ setPresent(false); retired.current?.();
203
+ };
204
+ if (!enabled || document.hidden) { retireNow(); return; }
205
+ // The retiring-content harness stubs document without addEventListener.
206
+ const canListen = typeof document.addEventListener === 'function';
207
+ const holdsFocus = element.contains(document.activeElement);
208
+ if (holdsFocus || focusHeld) {
209
+ if (holdsFocus) setFocusHeld(true);
210
+ // Focus defers only a collapse that will play. `document.hidden` is not
211
+ // state, so a page hidden while focus waits must retire from here.
212
+ const onHiddenWhileHeld = () => { if (document.hidden) retireNow(); };
213
+ if (canListen) document.addEventListener('visibilitychange', onHiddenWhileHeld);
214
+ return () => { if (canListen) document.removeEventListener('visibilitychange', onHiddenWhileHeld); };
215
+ }
189
216
  const from = element.getBoundingClientRect().height;
190
217
  animation.current?.cancel(); animation.current = null;
191
- if (!enabled || from < 1) { setPresent(false); return; }
218
+ if (from < 1) { setPresent(false); retired.current?.(); return; }
192
219
  const next = element.animate([{ height: `${from}px`, opacity: 1 }, { height: '0px', opacity: 0 }], { duration: 220, easing: EASING, fill: 'both' });
193
220
  animation.current = next;
194
- next.onfinish = () => { if (animation.current === next) { animation.current = null; next.cancel(); setPresent(false); } };
221
+ let settled = false;
222
+ const settle = () => {
223
+ if (settled || animation.current !== next) return;
224
+ settled = true;
225
+ animation.current = null; next.cancel(); setPresent(false); retired.current?.();
226
+ };
227
+ next.onfinish = settle;
228
+ // #60: a stalled WAAPI finish must not leave the row mounted.
229
+ const deadline = window.setTimeout(settle, 220 + 240);
230
+ const onHide = () => { if (document.hidden) settle(); };
231
+ if (canListen) document.addEventListener('visibilitychange', onHide);
232
+ return () => {
233
+ window.clearTimeout(deadline);
234
+ if (canListen) document.removeEventListener('visibilitychange', onHide);
235
+ if (animation.current === next) animation.current = null;
236
+ next.cancel();
237
+ };
195
238
  }, [visible, enabled, focusHeld]);
196
239
  useEffect(() => () => animation.current?.cancel(), []);
197
240
  if (!visible && !present) return null;
@@ -201,6 +244,98 @@ export function RetiringContent({ visible, children }: { visible: boolean; child
201
244
  </div>;
202
245
  }
203
246
 
247
+ /**
248
+ * Keeps a departing turn mounted for the length of its own collapse.
249
+ *
250
+ * A rewind removes turns from the visible order, and unmounting them outright
251
+ * snapped the transcript: every withdrawn row disappeared in one frame with
252
+ * nothing to scroll. Instead each departing turn is retained, rendered with
253
+ * `visible={false}` so it animates to zero height, and released only once the
254
+ * collapse has settled — so the reader watches the rows fold away and the rows
255
+ * below glide up into their place.
256
+ *
257
+ * Live slots follow the incoming group order, so older history stays above the
258
+ * tail. A collapsing turn stays next to the live neighbour that preceded it;
259
+ * appending it would jump it to the bottom for the length of the animation.
260
+ * Retention is bounded by the animation itself: `onRetired`
261
+ * drops the slot, so a departing turn lives for one collapse and no longer —
262
+ * and a key that comes back later is re-added as a live slot.
263
+ */
264
+ export function RetiringTurns<T extends { readonly key: string }>({ groups, render }: {
265
+ groups: readonly T[];
266
+ render: (group: T) => ReactNode;
267
+ }) {
268
+ const [slots, setSlots] = useState<readonly TurnSlot<T>[]>(() =>
269
+ groups.map((group) => ({ group, live: true })),
270
+ );
271
+ // Reconcile after every commit rather than during render: a key that left the
272
+ // order becomes `live: false` and keeps its index, which is what lets the
273
+ // collapse play in place instead of jumping the turn to the bottom.
274
+ useLayoutEffect(() => {
275
+ setSlots(current => reconcileTurnSlots(current, groups));
276
+ }, [groups]);
277
+ return (
278
+ <>
279
+ {slots.map((slot) => (
280
+ <RetiringContent
281
+ key={slot.group.key}
282
+ visible={slot.live}
283
+ onRetired={() => setSlots((current) => current.filter((candidate) =>
284
+ candidate.live || candidate.group.key !== slot.group.key))}
285
+ >
286
+ {render(slot.group)}
287
+ </RetiringContent>
288
+ ))}
289
+ </>
290
+ );
291
+ }
292
+
293
+ /** A turn that is still on screen, not one this commit is about to unmount. */
294
+ function survivingTurn(node: Element): HTMLElement | null {
295
+ if (!(node instanceof HTMLElement) || node.dataset.readerRetiring === 'retiring') return null;
296
+ const turn = node.hasAttribute('data-reader-turn') ? node : node.querySelector<HTMLElement>('[data-reader-turn]');
297
+ if (turn === null) return null;
298
+ const retiring = turn.closest('[data-reader-retiring]');
299
+ if (retiring instanceof HTMLElement && retiring.dataset.readerRetiring === 'retiring') return null;
300
+ return turn;
301
+ }
302
+
303
+ /**
304
+ * Where focus goes when cut content is removed without an animation.
305
+ * Prefer the nearest turn that is still live. Otherwise the reading column
306
+ * (or the host scrollport) so focus does not fall through to the document.
307
+ */
308
+ function retireFocusTarget(element: HTMLElement): HTMLElement | null {
309
+ const parent = element.parentElement;
310
+ if (parent) {
311
+ const siblings = [...parent.children];
312
+ const index = siblings.indexOf(element);
313
+ for (let cursor = index - 1; cursor >= 0; cursor--) {
314
+ const turn = survivingTurn(siblings[cursor]!);
315
+ if (turn) return turn;
316
+ }
317
+ for (let cursor = index + 1; cursor < siblings.length; cursor++) {
318
+ const turn = survivingTurn(siblings[cursor]!);
319
+ if (turn) return turn;
320
+ }
321
+ }
322
+ const column = element.closest<HTMLElement>('[data-chat-flow]');
323
+ if (column && !element.contains(column)) return column;
324
+ const scroll = element.closest<HTMLElement>('[data-conversation-scroll]');
325
+ if (scroll && !element.contains(scroll)) return scroll;
326
+ return null;
327
+ }
328
+
329
+ /** Move focus out of `element` before it unmounts. A no-op when focus is elsewhere. */
330
+ function focusSurvivesRetire(element: HTMLElement): void {
331
+ const active = document.activeElement;
332
+ if (active === null || !element.contains(active)) return;
333
+ const target = retireFocusTarget(element);
334
+ if (target === null || element.contains(target)) return;
335
+ if (target.tabIndex < 0) target.tabIndex = -1;
336
+ target.focus({ preventScroll: true });
337
+ }
338
+
204
339
  // DOM-only behavior: the native Session remains the sole source of business data.
205
340
  export function useReadingScroll(root: RefObject<HTMLElement>, motion: boolean): {
206
341
  detached: boolean;
@@ -56,9 +56,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
56
56
  }
57
57
  }
58
58
 
59
- // Existing Reader presentations own these rows. All other node kinds, including
60
- // future registrations, reach the official renderer through the fallback seat.
61
- const READER_NODES = new Set(['user', 'steering', 'assistant-step', 'tool-call', 'turn-tail', 'turn-process']);
59
+ // Reader draws these rows itself. Everything else, including future kinds, is
60
+ // mirrored so the official renderer still runs inside the reading view.
61
+ // 'user' and 'steering' stay mirrored (message-recall buttons and the like).
62
+ // 'assistant-step' is special: the official renderer is priority 0 and Reader
63
+ // keeps that presentation. A strictly lower priority is a keyed-slot
64
+ // replacement (lowest priority renders) and is mirrored for body content.
65
+ const READER_OWNED_NODES = new Set(['tool-call', 'turn-tail', 'turn-process']);
66
+
67
+ /** Official `AssistantNodeView` registers at priority 0. Lower wins. */
68
+ export const OFFICIAL_ASSISTANT_PRIORITY = 0;
69
+
70
+ /**
71
+ * Mirror a chat-node registration into the Reader seat.
72
+ * Assistant replacements are the entries that would actually replace the
73
+ * official renderer on the host. The official priority itself stays out, so
74
+ * Reader keeps its own answer markdown until a plugin opts in.
75
+ */
76
+ export function acceptMirroredChatNode(entry: StoredEntry): boolean {
77
+ const key = entry.options.key ?? '';
78
+ if (key === 'assistant-step') return (entry.options.priority ?? OFFICIAL_ASSISTANT_PRIORITY) < OFFICIAL_ASSISTANT_PRIORITY;
79
+ return !READER_OWNED_NODES.has(key);
80
+ }
62
81
  const EXPECTED: Record<OfficialFamily, Spec> = {
63
82
  actions: { kind: 'list', scope: 'session' },
64
83
  tools: { kind: 'keyed', scope: 'session' },
@@ -198,7 +217,7 @@ export function installOfficialSlots(ctx: Context): () => void {
198
217
  throw new Error(`Official slot contract changed: ${source}`);
199
218
  }
200
219
  return mirrorOfficialSlot(slots, source, target, `dsh-better-display.official.${family}`,
201
- family === 'nodes' ? entry => !READER_NODES.has(entry.options.key ?? '') : undefined);
220
+ family === 'nodes' ? acceptMirroredChatNode : undefined);
202
221
  }));
203
222
  }
204
223
  } catch (error) {
@@ -1,3 +1,5 @@
1
+ import { isAbsoluteWorkspacePath } from '../workspace-path.js';
2
+
1
3
  export type DeliverableOpenMode = 'external' | 'sidebar';
2
4
 
3
5
  export interface OpenModeSnapshot {
@@ -22,14 +24,6 @@ export function isFolderOpenPath(path: string): boolean {
22
24
  return path === '.' || path === '';
23
25
  }
24
26
 
25
- function isWindowsStylePath(value: string): boolean {
26
- return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\');
27
- }
28
-
29
- function isAbsoluteWorkspacePath(path: string): boolean {
30
- return path.startsWith('/') || isWindowsStylePath(path);
31
- }
32
-
33
27
  function encodeSegment(segment: string): string {
34
28
  return encodeURIComponent(segment).replace(/%3A/gi, ':');
35
29
  }
@@ -33,6 +33,13 @@ export function boundaryOf(turn: TurnLocation | undefined): TurnBoundary {
33
33
  };
34
34
  }
35
35
 
36
+ /**
37
+ * A settled body step before the closing step of a successful turn.
38
+ *
39
+ * This is presentation classification only. Narration is never process content
40
+ * and never folds. The flag only marks body text that is no longer the turn's
41
+ * final answer, so copy and fork stay on that final answer.
42
+ */
36
43
  export function isEarlierNarration(data: AssistantChatData, boundary: TurnBoundary): boolean {
37
44
  // New body text, a tool call, or a settled step is not a completed turn.
38
45
  if (data.status !== 'settled' || boundary.status !== 'closed' || boundary.reason !== 'completed') return false;
@@ -49,13 +56,18 @@ export function processChoiceKey(groupKey: string, boundary: TurnBoundary): stri
49
56
  return `${groupKey}:${boundary.status}:${boundary.reason ?? 'pending'}`;
50
57
  }
51
58
 
52
- /** A body-only assistant step is not a thinking/process disclosure. */
53
- export function hasProcessContent(node: ChatConversationViewNode | undefined, boundary: TurnBoundary): boolean {
59
+ /**
60
+ * A body-only assistant step is not a thinking/process disclosure.
61
+ *
62
+ * Narration (text/image/other blocks) is always visible, so it never counts as
63
+ * foldable process content; only literal reasoning does. Tool calls arrive as
64
+ * their own flow entries and are checked separately by the reader.
65
+ */
66
+ export function hasProcessContent(node: ChatConversationViewNode | undefined, _boundary: TurnBoundary): boolean {
54
67
  if (!node || node.visibility === 'hidden') return false;
55
68
  if (node.kind === 'assistant-step') {
56
69
  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));
70
+ return data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '');
59
71
  }
60
72
  return node.kind === 'context' || node.kind === 'model-retry' || node.kind === 'system-prompt' || node.kind === 'turn-process'
61
73
  || node.kind === 'command' || node.kind === 'manual-compaction' || node.kind === 'compaction';