dsh-better-display 0.3.8 → 0.3.10

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 (190) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/DESIGN.md +43 -3
  3. package/README.en.md +10 -2
  4. package/README.md +10 -2
  5. package/compat/harness-rc2.json +17 -13
  6. package/docs/dependency-update-policy.md +9 -0
  7. package/docs/extension-points.md +172 -0
  8. package/docs/official-rendering-bridge.md +49 -20
  9. package/docs/openapi.yaml +147 -0
  10. package/docs/runbooks/blank-reader.md +39 -0
  11. package/lib/client.js +1802 -872
  12. package/lib/client.js.map +1 -1
  13. package/lib/dsh-better-display.js +376 -151
  14. package/lib/types/client/AssistantNode.d.ts +20 -0
  15. package/lib/types/client/AssistantNode.d.ts.map +1 -0
  16. package/lib/types/client/AssistantNode.js +46 -0
  17. package/lib/types/client/AssistantNode.js.map +1 -0
  18. package/lib/types/client/Blocks.d.ts.map +1 -1
  19. package/lib/types/client/Blocks.js +33 -10
  20. package/lib/types/client/Blocks.js.map +1 -1
  21. package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
  22. package/lib/types/client/ChoreographedFlow.js +2 -0
  23. package/lib/types/client/ChoreographedFlow.js.map +1 -1
  24. package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
  25. package/lib/types/client/ClosedProcessSummary.js +3 -1
  26. package/lib/types/client/ClosedProcessSummary.js.map +1 -1
  27. package/lib/types/client/McpAppFrame.d.ts.map +1 -1
  28. package/lib/types/client/McpAppFrame.js +5 -11
  29. package/lib/types/client/McpAppFrame.js.map +1 -1
  30. package/lib/types/client/OfficialContent.d.ts +17 -3
  31. package/lib/types/client/OfficialContent.d.ts.map +1 -1
  32. package/lib/types/client/OfficialContent.js +37 -12
  33. package/lib/types/client/OfficialContent.js.map +1 -1
  34. package/lib/types/client/Reader.d.ts.map +1 -1
  35. package/lib/types/client/Reader.js +45 -77
  36. package/lib/types/client/Reader.js.map +1 -1
  37. package/lib/types/client/SettingsSection.d.ts.map +1 -1
  38. package/lib/types/client/SettingsSection.js +0 -5
  39. package/lib/types/client/SettingsSection.js.map +1 -1
  40. package/lib/types/client/StickyLane.d.ts +12 -0
  41. package/lib/types/client/StickyLane.d.ts.map +1 -1
  42. package/lib/types/client/StickyLane.js +78 -2
  43. package/lib/types/client/StickyLane.js.map +1 -1
  44. package/lib/types/client/ToolActivity.js.map +1 -1
  45. package/lib/types/client/index.d.ts +3 -1
  46. package/lib/types/client/index.d.ts.map +1 -1
  47. package/lib/types/client/index.js +6 -0
  48. package/lib/types/client/index.js.map +1 -1
  49. package/lib/types/client/live-turn.d.ts +17 -5
  50. package/lib/types/client/live-turn.d.ts.map +1 -1
  51. package/lib/types/client/live-turn.js +106 -76
  52. package/lib/types/client/live-turn.js.map +1 -1
  53. package/lib/types/client/motion.d.ts +26 -2
  54. package/lib/types/client/motion.d.ts.map +1 -1
  55. package/lib/types/client/motion.js +136 -10
  56. package/lib/types/client/motion.js.map +1 -1
  57. package/lib/types/client/official-slots.d.ts +24 -3
  58. package/lib/types/client/official-slots.d.ts.map +1 -1
  59. package/lib/types/client/official-slots.js +59 -11
  60. package/lib/types/client/official-slots.js.map +1 -1
  61. package/lib/types/client/primitive-labels.d.ts.map +1 -1
  62. package/lib/types/client/primitive-labels.js +1 -0
  63. package/lib/types/client/primitive-labels.js.map +1 -1
  64. package/lib/types/client/projection.d.ts +15 -2
  65. package/lib/types/client/projection.d.ts.map +1 -1
  66. package/lib/types/client/projection.js +16 -4
  67. package/lib/types/client/projection.js.map +1 -1
  68. package/lib/types/client/rewind-cut.d.ts +131 -0
  69. package/lib/types/client/rewind-cut.d.ts.map +1 -0
  70. package/lib/types/client/rewind-cut.js +321 -0
  71. package/lib/types/client/rewind-cut.js.map +1 -0
  72. package/lib/types/client/text-transform.d.ts +93 -0
  73. package/lib/types/client/text-transform.d.ts.map +1 -0
  74. package/lib/types/client/text-transform.js +183 -0
  75. package/lib/types/client/text-transform.js.map +1 -0
  76. package/lib/types/client/timeline.d.ts.map +1 -1
  77. package/lib/types/client/timeline.js.map +1 -1
  78. package/lib/types/client/tool-activity.d.ts +9 -2
  79. package/lib/types/client/tool-activity.d.ts.map +1 -1
  80. package/lib/types/client/tool-activity.js +42 -2
  81. package/lib/types/client/tool-activity.js.map +1 -1
  82. package/lib/types/client/turn-slots.d.ts +29 -0
  83. package/lib/types/client/turn-slots.d.ts.map +1 -0
  84. package/lib/types/client/turn-slots.js +62 -0
  85. package/lib/types/client/turn-slots.js.map +1 -0
  86. package/lib/types/client/types.d.ts +18 -0
  87. package/lib/types/client/types.d.ts.map +1 -1
  88. package/lib/types/dsh-better-display.d.ts +4 -1
  89. package/lib/types/dsh-better-display.d.ts.map +1 -1
  90. package/lib/types/dsh-better-display.js +56 -7
  91. package/lib/types/dsh-better-display.js.map +1 -1
  92. package/lib/types/flags.d.ts +7 -0
  93. package/lib/types/flags.d.ts.map +1 -0
  94. package/lib/types/flags.js +37 -0
  95. package/lib/types/flags.js.map +1 -0
  96. package/lib/types/log.d.ts +17 -0
  97. package/lib/types/log.d.ts.map +1 -0
  98. package/lib/types/log.js +122 -0
  99. package/lib/types/log.js.map +1 -0
  100. package/lib/types/metrics.d.ts +18 -0
  101. package/lib/types/metrics.d.ts.map +1 -0
  102. package/lib/types/metrics.js +35 -0
  103. package/lib/types/metrics.js.map +1 -0
  104. package/package.json +55 -23
  105. package/scripts/changelog.mjs +20 -0
  106. package/scripts/check-agents-md.mjs +67 -0
  107. package/scripts/check-bundle-size.mjs +38 -0
  108. package/scripts/check-dead-flags.mjs +55 -0
  109. package/scripts/check-harness-compat.mjs +4 -2
  110. package/scripts/check-todo-debt.mjs +69 -0
  111. package/scripts/link-harness-dependencies.mjs +48 -11
  112. package/scripts/security-audit-report.mjs +92 -0
  113. package/scripts/verify-commentary-visible.mjs +627 -0
  114. package/src/client/AssistantNode.tsx +72 -0
  115. package/src/client/Blocks.tsx +49 -10
  116. package/src/client/ChoreographedFlow.tsx +3 -1
  117. package/src/client/ClosedProcessSummary.tsx +3 -1
  118. package/src/client/McpAppFrame.tsx +5 -13
  119. package/src/client/OfficialContent.tsx +52 -14
  120. package/src/client/Reader.module.css +68 -18
  121. package/src/client/Reader.tsx +38 -82
  122. package/src/client/SettingsSection.tsx +1 -7
  123. package/src/client/StickyLane.tsx +81 -2
  124. package/src/client/ToolActivity.tsx +1 -1
  125. package/src/client/index.tsx +8 -1
  126. package/src/client/live-turn.ts +100 -69
  127. package/src/client/motion.tsx +135 -7
  128. package/src/client/official-slots.tsx +61 -12
  129. package/src/client/primitive-labels.ts +1 -0
  130. package/src/client/projection.ts +16 -4
  131. package/src/client/rewind-cut.ts +340 -0
  132. package/src/client/text-transform.ts +265 -0
  133. package/src/client/timeline.ts +0 -2
  134. package/src/client/tool-activity.ts +49 -2
  135. package/src/client/turn-slots.ts +80 -0
  136. package/src/client/types.ts +19 -0
  137. package/src/dsh-better-display.ts +58 -7
  138. package/src/flags.ts +40 -0
  139. package/src/log.ts +137 -0
  140. package/src/metrics.ts +50 -0
  141. package/tests/browser-commentary.tsx +191 -0
  142. package/tests/browser-official-bridge.tsx +2 -1
  143. package/tests/chat-flow-hook.test.ts +1 -1
  144. package/tests/cloud-gate.test.ts +1 -1
  145. package/tests/context-tool-update.test.ts +1 -1
  146. package/tests/conversation-scroll.test.ts +1 -1
  147. package/tests/deliverables.test.ts +1 -1
  148. package/tests/entry.test.ts +1 -1
  149. package/tests/fixtures/reader-lane-owners.tsx +46 -0
  150. package/tests/fixtures/reader-official-users.tsx +64 -0
  151. package/tests/fixtures/reader-theme-overlap.html +80 -0
  152. package/tests/fixtures/reader-user-bubble.tsx +244 -0
  153. package/tests/flags.test.ts +66 -0
  154. package/tests/fold-choreography.test.ts +32 -8
  155. package/tests/fold-intensity.test.ts +11 -6
  156. package/tests/footer-precedence.test.ts +1 -1
  157. package/tests/history-word-regression.test.ts +1 -1
  158. package/tests/live-turn.test.ts +125 -54
  159. package/tests/log.test.ts +99 -0
  160. package/tests/mcp-apps.test.ts +1 -1
  161. package/tests/message-chrome.test.ts +1 -1
  162. package/tests/metrics.test.ts +52 -0
  163. package/tests/official-slots.test.ts +41 -2
  164. package/tests/open-file.test.ts +1 -1
  165. package/tests/pending-submission.test.ts +12 -1
  166. package/tests/pinned-selection.test.ts +16 -0
  167. package/tests/platform-media.test.ts +1 -1
  168. package/tests/projection.test.ts +66 -3
  169. package/tests/reader-glass-css.test.ts +1 -1
  170. package/tests/reader-official-users.test.ts +11 -1
  171. package/tests/reader-sticky-surface.test.ts +377 -0
  172. package/tests/reader-theme-lanes.test.ts +54 -0
  173. package/tests/reader-theme-overlap.test.ts +151 -0
  174. package/tests/reader-user-bubble.test.ts +138 -0
  175. package/tests/reasoning-follow.test.ts +1 -1
  176. package/tests/retiring-content.test.ts +98 -6
  177. package/tests/reveal.test.ts +3 -3
  178. package/tests/rewind-cut.test.ts +1090 -0
  179. package/tests/settings-copy.test.ts +1 -1
  180. package/tests/skill-status.test.ts +1 -1
  181. package/tests/stock-install.test.ts +11 -7
  182. package/tests/stream-buffer.test.ts +1 -1
  183. package/tests/styles.test.ts +30 -3
  184. package/tests/terminal-input-preview.test.ts +1 -1
  185. package/tests/text-transform.test.ts +272 -0
  186. package/tests/timeline.test.ts +1 -1
  187. package/tests/tool-clock.test.ts +1 -1
  188. package/tests/tool-diff.test.ts +1 -1
  189. package/tests/waiting-clock.test.ts +1 -1
  190. package/tests/word-timeline.test.ts +1 -1
@@ -5,15 +5,15 @@ import type { ReactNode, RefObject } from 'react';
5
5
  import type { ChatConversationViewNode, ChatNode, ChatNodeKind, ChatNodeOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
6
6
  import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
7
7
  import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives';
8
- import { BlockBoundary, Blocks, contentBlocks, CopyAnswer, UserMessageActions } from './Blocks.js';
9
- import { ReasoningCard } from './ReasoningCard.js';
8
+ import { BlockBoundary, Blocks, contentBlocks, UserMessageActions } from './Blocks.js';
9
+ import { AssistantNode } from './AssistantNode.js';
10
10
  import { ToolActivity, ToolMedia } from './ToolActivity.js';
11
- import { OfficialActions, OfficialNode, OfficialTail } from './OfficialContent.js';
12
- import { OFFICIAL_SEATS } from './official-slots.js';
11
+ import { OfficialNode, OfficialTail } from './OfficialContent.js';
12
+ import { OFFICIAL_SEATS, readerGroupAction } from './official-slots.js';
13
13
  import { preparingLabel, readerFlow } from './tool-activity.js';
14
- import { Disclosure, ProcessFragment, RetiringContent, StatusText, useMotionAllowed, usePinnedSelection, useReadingScroll } from './motion.js';
14
+ import { Disclosure, ProcessFragment, RetiringTurns, StatusText, useMotionAllowed, usePinnedSelection, useReadingScroll } from './motion.js';
15
15
  import { StreamMotionContext } from './streaming.js';
16
- import { assistantSegments, boundaryOf, forkAnchorSeq, groupNodes, hasProcessContent, hasVisibleBody, isEarlierNarration, processChoiceKey, processExpanded, terminalLabel } from './projection.js';
16
+ import { boundaryOf, forkAnchorSeq, groupNodes, hasProcessContent, processChoiceKey, processExpanded, terminalLabel } from './projection.js';
17
17
  import { basename, createProducedFileMentions, dirname, getTurnDeliverables, showDeliverablesRow } from './deliverables.js';
18
18
  import { deliverableOpenModeOf, type DeliverableOpenMode } from './open-file.js';
19
19
  import { asReadonlyArray, pendingSubmissionImages, type PendingSubmissionEcho } from './pending-submission.js';
@@ -23,17 +23,18 @@ import { ContextInjectionRow } from './native/ContextInjectionRow.js';
23
23
  import { TimelineRail } from './TimelineRail.js';
24
24
  import { landTurn, scrollerOf } from './conversation-scroll.js';
25
25
  import { mergeTimelineItems, type TimelineItem } from './timeline.js';
26
- import { presentLiveTurn, segmentLiveTurn } from './live-turn.js';
26
+ import { planProcessSeams, presentLiveTurn, segmentLiveTurn } from './live-turn.js';
27
27
  import type { LiveStep } from './live-turn.js';
28
28
  import { frostedGlassOf } from './fold-intensity.js';
29
29
  import { ChoreographedFlow, useFlowChat } from './ChoreographedFlow.js';
30
30
  import { ClosedProcessSummary } from './ClosedProcessSummary.js';
31
- import { StickyLane } from './StickyLane.js';
31
+ import { ReaderLaneScope, StickyLane } from './StickyLane.js';
32
32
  import type { ReaderGroup, TurnBoundary } from './projection.js';
33
33
  import type { BlockRenderProps, ReaderProps } from './types.js';
34
34
  import css from './Reader.module.css';
35
35
  import { markdownLabels, truncatedJsonLabel } from './primitive-labels.js';
36
36
  import { chatSeatProps } from './chat-seat.js';
37
+ import { visibleChatKeys, visibleChatSignature } from './rewind-cut.js';
37
38
  import { ensureStyles } from './styles.js';
38
39
 
39
40
  function isNode<K extends ChatNodeKind>(node: ChatConversationViewNode, kind: K): node is ChatNode<K> {
@@ -170,52 +171,7 @@ const ProcessNode = memo(function ProcessNode({ useChat, t, nodeKey, open, motio
170
171
  return content && <ProcessFragment open={open} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>{content}</ProcessFragment>;
171
172
  });
172
173
 
173
- const AssistantNode = memo(function AssistantNode({ useChat, nodeKey, boundary, processOpen = false, pinned = false, folded = false, partStart, motion, onRead, returnFocusTo, ...render }: SeatProps & {
174
- motion: boolean; onRead: () => void; returnFocusTo: RefObject<HTMLButtonElement>; partStart?: number; folded?: boolean;
175
- }) {
176
- const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
177
- if (!node || node.visibility === 'hidden' || !isNode(node, 'assistant-step')) return null;
178
- const data = node.data;
179
- const parts = assistantSegments(data.blocks);
180
- const earlier = isEarlierNarration(data, boundary);
181
- const hasToolCalls = data.blocks.some(block => block.kind === 'tool-call');
182
- const isProcessStep = earlier || folded || hasToolCalls || (boundary.latestStep > 0 && data.step < boundary.latestStep);
183
- const body = data.blocks.filter(block => block.kind !== 'reasoning' && block.kind !== 'tool-call');
184
- const visible = partStart === undefined ? parts : parts.filter(part => part.start === partStart);
185
- return <>{visible.map(part => {
186
- const index = parts.findIndex(item => item.start === part.start);
187
- const last = index === parts.length - 1;
188
- return part.kind === 'reasoning'
189
- ? <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>
190
- <ReasoningCard step={data.step} active={processOpen && boundary.status === 'open' && data.step === boundary.latestStep} motion={motion} selected={pinned} onRead={onRead}>
191
- <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running' && last && data.blocks.at(-1)?.kind === 'reasoning'}
192
- holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
193
- </ReasoningCard>
194
- </ProcessFragment>
195
- : isProcessStep ? <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey}>
196
- <article className={css.processCommentary}>
197
- <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running'} holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
198
- </article>
199
- </ProcessFragment>
200
- : hasVisibleBody(part.blocks) && <RetiringContent key={part.start} visible={pinned || processOpen || (!earlier && !folded)}>
201
- <article className={css.answer} data-reader-answer data-reader-anchor data-reader-key={nodeKey} data-reader-source-start={part.start} data-answer-status={data.status} data-answer-phase={earlier || folded ? 'process' : 'body'}>
202
- <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running'} holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
203
- {last && data.status === 'interrupted' && <span className={css.stopped}>已停止</span>}
204
- {last && !earlier && !folded && data.status !== 'running' && boundary.status === 'closed' && (
205
- <CopyAnswer blocks={body} onFork={(() => {
206
- // The fork anchor must be the durable closing message seq (same as
207
- // the official turn-tail branch). AssistantChatData carries no seq
208
- // of its own; passing it would fork the whole session instead.
209
- const anchor = forkAnchorSeq([data.finalNode, { seq: render.forkSeq }]);
210
- return render.forkAt && anchor !== undefined ? () => render.forkAt!(anchor) : undefined;
211
- })()} metrics={render.metrics} extraActions={<OfficialActions official={render.official} messageId={data.finalNode?.messageId} />} />
212
- )}
213
- </article>
214
- </RetiringContent>;
215
- })}</>;
216
- });
217
-
218
- const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, processOpen = false, ...render }: SeatProps) {
174
+ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, processOpen: _processOpen = false, ...render }: SeatProps) {
219
175
  const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
220
176
  const [disclosureReset] = useState(() => createSnapshotStore(0));
221
177
  if (!node || node.visibility === 'hidden') return null;
@@ -242,17 +198,13 @@ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, pr
242
198
  const owner: ChatNodeOwnerProps & Pick<ReaderProps, 'sessionId'> = {
243
199
  cwd: render.cwd,
244
200
  openFile: render.openFile ?? (() => {}),
245
- openSkill: () => {},
246
201
  forkAt: render.forkAt ?? (() => {}),
247
202
  inspectCall: callId => official.openView('trajectory', callId),
248
203
  loadImage: official.officialImageLoader,
249
- renderMessageImages: images => official.renderSlot(OFFICIAL_SEATS.images, {
250
- ...images, loadImage: official.officialImageLoader,
251
- }),
252
204
  fileMentions: official.officialFileMentions,
253
205
  sessionId: render.sessionId,
254
206
  };
255
- const options = { hookContext: { turnData: turn?.data, disclosureReset }, fallback };
207
+ const options = { hookContext: { turnData: turn?.data, disclosureReset, useGroupAction: readerGroupAction }, fallback };
256
208
  rendered = node.kind === 'user'
257
209
  ? official.renderSlot(OFFICIAL_SEATS.nodes, { ...owner, node }, { ...options, entryKey: 'user' })
258
210
  : official.renderSlot(OFFICIAL_SEATS.nodes, { ...owner, node }, { ...options, entryKey: 'steering' });
@@ -322,7 +274,7 @@ function subagentCount(snapshot: { nodes: { get(key: string): ChatConversationVi
322
274
  return 0;
323
275
  }
324
276
 
325
- function GroupStatus({ group, sessionId, useChat, pending, motion }: Pick<ReaderProps, 'sessionId' | 'useChat'> & { group: ReaderGroup; motion: boolean; pending: { kind?: string } | undefined }) {
277
+ function GroupStatus({ group, useChat, pending, motion }: Pick<ReaderProps, 'useChat'> & { group: ReaderGroup; motion: boolean; pending: { kind?: string } | undefined }) {
326
278
  const text = useChat(snapshot => {
327
279
  const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
328
280
  if (turn?.status === 'closed') {
@@ -538,7 +490,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
538
490
  const firstKind = props.useChat(snapshot => snapshot.nodes.get(group.keys[0])?.kind);
539
491
  const startsWithUser = firstKind === 'user';
540
492
  const mainKeys = startsWithUser ? group.keys.slice(1) : group.keys;
541
- const flow = useMemo(() => readerFlow({ ...group, keys: mainKeys }, turn, key => nodes.get(key)), [nodes, group, mainKeys, turn]);
493
+ const flow = useMemo(() => readerFlow({ ...group, keys: mainKeys }, turn, key => nodes.get(key), snapshot.order), [nodes, group, mainKeys, turn, snapshot.order]);
542
494
  const steps = useMemo(() => segmentLiveTurn(flow, key => nodes.get(key)), [flow, nodes]);
543
495
  const liveItems = useMemo(() => presentLiveTurn(steps, boundary, autoFold), [steps, boundary, autoFold]);
544
496
  const openMode = deliverableOpenModeOf(useSyncExternalStore(
@@ -557,6 +509,12 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
557
509
  ? item.key === key || item.steps.some(step => step.key === key || ('nodeKey' in step && step.nodeKey === key))
558
510
  : item.key === key || ('nodeKey' in item.step && item.step.nodeKey === key)));
559
511
  const expanded = !autoFold || holdingSelection || processExpanded(expansionChoice, boundary);
512
+ // Seam between narration blocks. Skip a run a live-fold cell already
513
+ // covers, and do not drop the previous body's bottom spacing.
514
+ const seamPlan = useMemo(
515
+ () => planProcessSeams(steps, liveItems, expanded),
516
+ [steps, liveItems, expanded],
517
+ );
560
518
  const [foldOpenByKey, setFoldOpenByKey] = useState<Record<string, boolean>>({});
561
519
  useLayoutEffect(() => {
562
520
  // Restoring automatic folding also releases live "prior steps" disclosures.
@@ -630,7 +588,9 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
630
588
  const renderStep = (step: LiveStep, folded: boolean) => {
631
589
  const processOpen = folded || expanded;
632
590
  if (step.kind === 'reasoning' || step.kind === 'body') return <BlockBoundary>
633
- <AssistantNode {...shared} boundary={boundary} nodeKey={step.nodeKey} partStart={step.start} pinned={pinnedKeys.includes(step.nodeKey)} processOpen={processOpen} folded={folded} motion={motion} onRead={pinProcess} returnFocusTo={processButton} />
591
+ <AssistantNode {...shared} boundary={boundary} nodeKey={step.nodeKey} partStart={step.start} pinned={pinnedKeys.includes(step.nodeKey)} processOpen={processOpen} folded={folded} motion={motion} onRead={pinProcess} returnFocusTo={processButton}
592
+ seamSummary={step.kind === 'body' ? seamPlan.before.get(step.key) : undefined}
593
+ seamAfter={step.kind === 'body' && seamPlan.after.has(step.key)} />
634
594
  </BlockBoundary>;
635
595
  if (step.kind === 'tool') return <BlockBoundary><ProcessFragment open={processOpen} motion={motion} onRead={pinProcess} returnFocusTo={processButton} nodeKey={step.key} framed>
636
596
  <ToolActivity {...shared} entry={step.entry} motion={motion} turnClosed={boundary.status === 'closed'} onRead={pinProcess} />
@@ -648,17 +608,10 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
648
608
  {(showStatusLane || showClosedSummary) && <div className={css.turnStickyLanes}>
649
609
  {showStatusLane && <StickyLane kind="status" className={css.turnProcessSticky}>
650
610
  <Disclosure open={expanded} onChange={setExpanded} controls={flowId} buttonRef={processButton}
651
- label={<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />} />
611
+ label={<GroupStatus group={group} useChat={props.useChat} pending={interaction} motion={motion} />} />
652
612
  </StickyLane>}
653
613
  {showClosedSummary && <ClosedProcessSummary open={expanded} onChange={setExpanded} controls={flowId}
654
- steps={steps.filter(step => {
655
- if (step.kind === 'user') return false;
656
- if (step.kind !== 'body') return true;
657
- const node = nodes.get(step.nodeKey);
658
- return !!node && isNode(node, 'assistant-step') && (isEarlierNarration(node.data, boundary)
659
- || node.data.blocks.some(block => block.kind === 'tool-call')
660
- || (boundary.latestStep > 0 && node.data.step < boundary.latestStep));
661
- })} />}
614
+ steps={steps.filter(step => step.kind !== 'user' && step.kind !== 'body')} />}
662
615
  </div>}
663
616
  <ChoreographedFlow id={flowId} frame={presentation} motion={motion} enabled={autoFold && boundary.status === 'open' && !holdingSelection}
664
617
  urgent={hasTurnError || interaction !== undefined || !sessionRunning} open={foldOpenByKey} processOpen={expanded}
@@ -666,7 +619,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
666
619
  </div>
667
620
  {/* 状态指示永远排在流程之后:AI 的响应永远出现在最新消息(含补充消息)的下方 */}
668
621
  {!hasProcess && boundary.status === 'open' && !isAwaitingModel && <div className={css.disclosure} data-reader-status-only>
669
- <GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />
622
+ <GroupStatus group={group} useChat={props.useChat} pending={interaction} motion={motion} />
670
623
  </div>}
671
624
  {showDeliverablesRow(boundary.status, deliverables) && <DeliverablesRow deliverables={deliverables} openFile={props.openFile} revealFile={props.revealFile} openMode={openMode} />}
672
625
  {boundary.status === 'closed' && <BlockBoundary><OfficialTail official={official} owner={tailOwner} produced={deliverables} /></BlockBoundary>}
@@ -680,6 +633,9 @@ export function Reader(props: ReaderProps) {
680
633
  const activatedAt = useRef(Date.now());
681
634
  const order = props.useChat(snapshot => snapshot.order);
682
635
  const nodes = props.useChat(snapshot => snapshot.nodes);
636
+ // A rewind upserts the command row in place, so order and nodes keep their identity.
637
+ const rewindSignature = props.useChat(snapshot => visibleChatSignature(snapshot));
638
+ const renderKeys = useMemo(() => visibleChatKeys({ order, nodes }), [order, nodes, rewindSignature]);
683
639
  const timeline = props.useChat(snapshot => snapshot.timeline);
684
640
  const running = props.useSession(snapshot => snapshot.running);
685
641
  const pending = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
@@ -708,7 +664,7 @@ export function Reader(props: ReaderProps) {
708
664
  rows.push(...local.values());
709
665
  return rows;
710
666
  }, [inboxSteering, pendingList, visibleSubmissions]);
711
- const waitAnchor = waitingAnchor(order, key => nodes.get(key), pendingList);
667
+ const waitAnchor = waitingAnchor(renderKeys, key => nodes.get(key), pendingList);
712
668
  const motionPreference = props.useStore(state => state.motion);
713
669
  const motion = useMotionAllowed(motionPreference);
714
670
  const prefsSnap = useSyncExternalStore(
@@ -728,14 +684,14 @@ export function Reader(props: ReaderProps) {
728
684
  }, [autoFold, props.actions]);
729
685
  const frostedGlass = frostedGlassOf(prefsSnap);
730
686
  const streamMotion = useMemo(() => ({ enabled: motion, activatedAt: activatedAt.current }), [motion]);
731
- const groups = useMemo(() => groupNodes(order, key => nodes.get(key)), [order, nodes, timeline]);
687
+ const groups = useMemo(() => groupNodes(renderKeys, key => nodes.get(key)), [renderKeys, nodes, timeline]);
732
688
  const isAwaitingModel = useMemo(() => {
733
689
  // 状态机铁律:「深度求索中」与「正在处理/思考」永远互斥。
734
690
  // 1. 仅空闲发送(!running)的本地回显窗口:用户按下回车第 0 毫秒立即反馈。
735
691
  // 忙碌时的 queued/steering 回显不算等待——模型本来就在工作。
736
692
  if (!running && pendingList.length > 0) return true;
737
693
 
738
- const lastKey = order.at(-1);
694
+ const lastKey = renderKeys.at(-1);
739
695
  const lastNode = lastKey ? nodes.get(lastKey) : undefined;
740
696
  if (!lastKey || !lastNode) return false;
741
697
 
@@ -760,7 +716,7 @@ export function Reader(props: ReaderProps) {
760
716
  const lastGroup = groups.at(-1);
761
717
  const turn = lastGroup?.turn === null || lastGroup?.turn === undefined ? undefined : timeline.turns.get(lastGroup.turn);
762
718
  return turn?.status !== 'closed';
763
- }, [running, pendingList, order, nodes, groups, timeline]);
719
+ }, [running, pendingList, renderKeys, nodes, groups, timeline]);
764
720
  const scroll = useReadingScroll(root, motion);
765
721
  const pinnedKeys = usePinnedSelection(root);
766
722
  const selectedProcessKeys = usePinnedSelection(root, '[data-reader-process]');
@@ -868,7 +824,7 @@ export function Reader(props: ReaderProps) {
868
824
  }
869
825
  }, [props.loadThrough, props.loadOlder, scroll.jump, scroll.release, timelineItems]);
870
826
 
871
- const lastKey = order.at(-1);
827
+ const lastKey = renderKeys.at(-1);
872
828
  const lastNode = lastKey ? nodes.get(lastKey) : undefined;
873
829
  const lastSubmissionId = pendingList.length ? pendingList[pendingList.length - 1].requestId : null;
874
830
  const lastOrderKeyRef = useRef<string | undefined>(lastKey);
@@ -887,9 +843,9 @@ export function Reader(props: ReaderProps) {
887
843
 
888
844
  // ChatView publishes data-chat-flow="" on its column. Skins treat a
889
845
  // scrollport without that hook as inspect-only and hide [data-composer-seat].
890
- return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.4" data-dsh-better-display="0.3.4" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
846
+ return <StreamMotionContext.Provider value={streamMotion}><ReaderLaneScope><div ref={root} className={css.root} data-reader-build="0.3.4" data-dsh-better-display="0.3.4" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
891
847
  <TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
892
- <div className={css.column} data-chat-flow="">
848
+ <div className={css.column} data-chat-flow="" tabIndex={-1}>
893
849
  <StickyLane kind="toolbar" className={css.toolbar}>
894
850
  <button
895
851
  type="button"
@@ -911,12 +867,12 @@ export function Reader(props: ReaderProps) {
911
867
  {historyError && <div className={css.notice}>历史记录加载失败,可再次尝试;现有内容未改变。</div>}
912
868
  {openError && <div className={css.error} role="alert">会话暂时无法读取:{openError.message}</div>}
913
869
  {loading && groups.length === 0 && <p className={css.empty} role="status">正在读取会话…</p>}
914
- {groups.map(group => <TurnGroup key={group.key} {...props} group={group} motion={motion} autoFold={autoFold} pinnedKeys={pinnedKeys} selectedProcessKeys={selectedProcessKeys} isAwaitingModel={isAwaitingModel && group.key === groups.at(-1)?.key} />)}
870
+ <RetiringTurns groups={groups} render={group => <TurnGroup {...props} group={group} motion={motion} autoFold={autoFold} pinnedKeys={pinnedKeys} selectedProcessKeys={selectedProcessKeys} isAwaitingModel={isAwaitingModel && group.key === groups.at(-1)?.key} />} />
915
871
  {pendingInputs.map((item, index) => {
916
872
  const submission = 'requestId' in item ? item : undefined;
917
873
  const text = submission?.text ?? inboxText(item as PendingInboxItem);
918
874
  const images = submission ? pendingSubmissionImages(submission) : [];
919
- const attachmentCount = images.length;
875
+ const attachmentCount = submission ? images.length : inboxAttachmentCount(item as PendingInboxItem);
920
876
  const requestId = submission?.requestId ?? `inbox-${index}`;
921
877
  return (
922
878
  <div key={requestId} className={css.userCluster} data-reader-pending-submission data-pending-steering={submission ? undefined : ''} data-pending-attachment-count={attachmentCount}>
@@ -949,5 +905,5 @@ export function Reader(props: ReaderProps) {
949
905
  <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true"><path d="M3 5.5 7 9.5 11 5.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
950
906
  </button></div>}
951
907
  </div>
952
- </div></StreamMotionContext.Provider>;
908
+ </div></ReaderLaneScope></StreamMotionContext.Provider>;
953
909
  }
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
2
- import { foldIntensityOf, frostedGlassOf, type FoldIntensity } from './fold-intensity.js';
2
+ import { frostedGlassOf, type FoldIntensity } from './fold-intensity.js';
3
3
  import { deliverableOpenModeOf, type DeliverableOpenMode } from './open-file.js';
4
4
  import { settingsCopyFor, type SettingsCopy, type SettingsCopyKey } from './settings-copy.js';
5
5
  import { CONVENTIONAL_SKILL_ROOTS, detectGenerativeMcpappsSkill, shortestInstallCommand, type SkillStatusProbe, type SkillStatusSnapshot } from './skill-status.js';
@@ -49,12 +49,6 @@ function text(props: SettingsProps, copy: SettingsCopy, key: keyof SettingsCopy)
49
49
  return copy[key];
50
50
  }
51
51
 
52
- const FOLD_STOPS: { value: FoldIntensity; key: 'foldNone' | 'foldStandard' | 'foldSummary' }[] = [
53
- { value: 0, key: 'foldNone' },
54
- { value: 1, key: 'foldStandard' },
55
- { value: 2, key: 'foldSummary' },
56
- ];
57
-
58
52
  export function SettingsSection(props: SettingsProps) {
59
53
  const copy = props.copy ?? settingsCopyFor(props.languageTag);
60
54
  const snap = useSyncExternalStore(
@@ -1,6 +1,85 @@
1
- import { useLayoutEffect, useRef } from 'react';
1
+ import { createContext, useContext, useLayoutEffect, useRef } from 'react';
2
2
  import type { ReactNode } from 'react';
3
3
 
4
+ const MIN_LANE_PX = 40;
5
+ type LaneOwners = Map<string, HTMLElement>;
6
+
7
+ const LaneOwnerContext = createContext<LaneOwners | null>(null);
8
+ const ownersByRoot = new WeakMap<HTMLElement, LaneOwners>();
9
+
10
+ /** One map per Reader, so two roots that both contain turn 1 do not share a writer. */
11
+ export function ReaderLaneScope({ children }: { children: ReactNode }) {
12
+ const owners = useRef<LaneOwners>(new Map());
13
+ useLayoutEffect(() => {
14
+ const map = owners.current;
15
+ return () => { map.clear(); };
16
+ }, []);
17
+ return <LaneOwnerContext.Provider value={owners.current}>{children}</LaneOwnerContext.Provider>;
18
+ }
19
+
20
+ function laneScope(el: HTMLElement) {
21
+ return el.closest<HTMLElement>('[data-reader-turn]') ?? el.closest<HTMLElement>('[data-dsh-better-display]');
22
+ }
23
+
24
+ function ownersFor(el: HTMLElement, scoped: LaneOwners | null) {
25
+ if (scoped) return scoped;
26
+ const root = el.closest<HTMLElement>('[data-dsh-better-display]') ?? el;
27
+ let owners = ownersByRoot.get(root);
28
+ if (!owners) {
29
+ owners = new Map();
30
+ ownersByRoot.set(root, owners);
31
+ }
32
+ return owners;
33
+ }
34
+
35
+ function publishedLanePx(el: HTMLElement) {
36
+ return Math.max(MIN_LANE_PX, Math.ceil(el.getBoundingClientRect().height));
37
+ }
38
+
39
+ /** One writer per turn inside a single Reader. A retiring summary cannot clear the lane that replaced it. */
40
+ export function publishLaneHeight(el: HTMLElement, property: string, scoped: LaneOwners | null = null) {
41
+ const scope = laneScope(el);
42
+ if (!scope) return () => {};
43
+ const owners = ownersFor(el, scoped);
44
+ const root = el.closest<HTMLElement>('[data-dsh-better-display]');
45
+ const key = `${property}\0${scope.getAttribute('data-reader-turn') ?? 'root'}`;
46
+ const update = () => {
47
+ if (owners.get(key) !== el) return;
48
+ scope.style.setProperty(property, `${publishedLanePx(el)}px`);
49
+ };
50
+ owners.set(key, el);
51
+ update();
52
+ const observer = new ResizeObserver(update);
53
+ observer.observe(el);
54
+ return () => {
55
+ observer.disconnect();
56
+ if (owners.get(key) !== el) return;
57
+ owners.delete(key);
58
+ scope.style.removeProperty(property);
59
+ if (!scoped && root && owners.size === 0) ownersByRoot.delete(root);
60
+ };
61
+ }
62
+
63
+ export function usePublishedHeight(property: string) {
64
+ const owners = useContext(LaneOwnerContext);
65
+ const ref = useRef<HTMLDivElement>(null);
66
+ useLayoutEffect(() => {
67
+ const el = ref.current;
68
+ if (!el) return;
69
+ return publishLaneHeight(el, property, owners);
70
+ }, [owners, property]);
71
+ return ref;
72
+ }
73
+
74
+ export function useSummaryLane(ref: { readonly current: HTMLElement | null }, active: boolean) {
75
+ const owners = useContext(LaneOwnerContext);
76
+ useLayoutEffect(() => {
77
+ const el = ref.current;
78
+ if (!active || !el) return;
79
+ return publishLaneHeight(el, '--reader-summary-height', owners);
80
+ }, [active, owners, ref]);
81
+ }
82
+
4
83
  /** Publish real lane geometry; wrapped labels must not collide with statistics. */
5
84
  export function StickyLane({ kind, className, children }: { kind: 'toolbar' | 'status'; className: string; children: ReactNode }) {
6
85
  const ref = useRef<HTMLDivElement>(null);
@@ -10,7 +89,7 @@ export function StickyLane({ kind, className, children }: { kind: 'toolbar' | 's
10
89
  if (!el || !scope) return;
11
90
  const property = kind === 'toolbar' ? '--reader-toolbar-height' : '--reader-status-height';
12
91
  const update = () => {
13
- scope.style.setProperty(property, `${el.getBoundingClientRect().height}px`);
92
+ scope.style.setProperty(property, `${Math.max(MIN_LANE_PX, el.getBoundingClientRect().height)}px`);
14
93
  if (kind !== 'toolbar') return;
15
94
  // The status lane is sized as calc(100% - --reader-control-width), so this
16
95
  // must reserve the WHOLE control group. Measuring only the first button
@@ -6,7 +6,7 @@ import { DiffBlock, DisclosureRow, JsonTree, ReadBlock, SearchBlock, TerminalBlo
6
6
  import { Blocks, contentBlocks } from './Blocks.js';
7
7
  import { OfficialTool } from './OfficialContent.js';
8
8
  import { ProcessFragment } from './motion.js';
9
- import { activityPhase, activitySummary, callDiffHunks, diffTotals, executionFacts, forgetCallClock, objectValue, runningClockBase, toolIdentity } from './tool-activity.js';
9
+ import { activityPhase, activitySummary, callDiffHunks, diffTotals, executionFacts, forgetCallClock, objectValue, runningClockBase } from './tool-activity.js';
10
10
  import type { ToolActivityEntry, ToolCategory, ToolPhase } from './tool-activity.js';
11
11
  import type { BlockRenderProps } from './types.js';
12
12
  import { classifyTool, toolRowModel, VARIANT_TITLES } from './native/tool-call-model.js';
@@ -19,6 +19,7 @@ import { fileAddressFor, modeFromSnapshot, openDeliverableFile } from './open-fi
19
19
  import type { ReaderInjected } from './types.js';
20
20
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
21
21
  import { installOfficialSlots, officialChildren, type CompositionRegistry } from './official-slots.js';
22
+ import { TEXT_TRANSFORM_SERVICE, createTextTransformRegistry, installTextTransformRegistry } from './text-transform.js';
22
23
 
23
24
  /** Structural face of the sanctioned per-session composer writer. */
24
25
  interface ComposerShell {
@@ -54,8 +55,10 @@ async function openWorkspacePath(
54
55
  }
55
56
  }
56
57
 
57
- export type { ReaderBlockOwner } from './types.js';
58
+ export type { ReaderBlockOwner, ReaderBlockPresentation } from './types.js';
59
+ export type { DisplayCursor, DisplayText, DisplayTextKind, DisplayTextRequest, DisplayTextTransformer, TextTransformRegistry } from './text-transform.js';
58
60
  export { McpAppFrame } from './McpAppFrame.js';
61
+ export { TEXT_TRANSFORM_SERVICE, createTextTransformRegistry, originalAnswerText } from './text-transform.js';
59
62
  export const name = 'dsh-better-display-client';
60
63
  export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'remote', 'remote.session'];
61
64
 
@@ -63,6 +66,10 @@ export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'r
63
66
  ensureStyles();
64
67
 
65
68
  export function apply(ctx: Context): void {
69
+ const textTransform = createTextTransformRegistry();
70
+ const restoreTextTransform = installTextTransformRegistry(textTransform);
71
+ ctx.provide(TEXT_TRANSFORM_SERVICE, textTransform);
72
+ ctx.effect(() => restoreTextTransform);
66
73
  const store = createReaderStore();
67
74
  // conversation.view is session-scoped, so session persist keys are
68
75
  // `dsh.reader.v1.<sessionId>`. One root instance keeps glass, fold
@@ -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
+ }