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
@@ -1,17 +1,19 @@
1
1
  import type {} from '@deepseek-ai/dsh-session-turn-outline/types';
2
2
  import type {} from '@deepseek-ai/dsh-agent/types';
3
- import { Fragment, memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
+ import { Fragment, memo, useCallback, useEffect, useId, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
4
4
  import type { ReactNode, RefObject } from 'react';
5
- import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from '@deepseek-ai/dsh-client-ui-chat/client';
5
+ import type { ChatConversationViewNode, ChatNode, ChatNodeKind, ChatNodeOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
6
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
6
7
  import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives';
7
- import { BlockBoundary, Blocks, contentBlocks, CopyAnswer, UserMessageActions } from './Blocks.js';
8
- import { ReasoningCard } from './ReasoningCard.js';
8
+ import { BlockBoundary, Blocks, contentBlocks, UserMessageActions } from './Blocks.js';
9
+ import { AssistantNode } from './AssistantNode.js';
9
10
  import { ToolActivity, ToolMedia } from './ToolActivity.js';
10
- import { OfficialActions, OfficialNode, OfficialTail } from './OfficialContent.js';
11
+ import { OfficialNode, OfficialTail } from './OfficialContent.js';
12
+ import { OFFICIAL_SEATS } from './official-slots.js';
11
13
  import { preparingLabel, readerFlow } from './tool-activity.js';
12
- import { Disclosure, ProcessFragment, RetiringContent, StatusText, useMotionAllowed, usePinnedSelection, useReadingScroll } from './motion.js';
14
+ import { Disclosure, ProcessFragment, RetiringTurns, StatusText, useMotionAllowed, usePinnedSelection, useReadingScroll } from './motion.js';
13
15
  import { StreamMotionContext } from './streaming.js';
14
- 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';
15
17
  import { basename, createProducedFileMentions, dirname, getTurnDeliverables, showDeliverablesRow } from './deliverables.js';
16
18
  import { deliverableOpenModeOf, type DeliverableOpenMode } from './open-file.js';
17
19
  import { asReadonlyArray, pendingSubmissionImages, type PendingSubmissionEcho } from './pending-submission.js';
@@ -21,17 +23,19 @@ import { ContextInjectionRow } from './native/ContextInjectionRow.js';
21
23
  import { TimelineRail } from './TimelineRail.js';
22
24
  import { landTurn, scrollerOf } from './conversation-scroll.js';
23
25
  import { mergeTimelineItems, type TimelineItem } from './timeline.js';
24
- import { presentLiveTurn, segmentLiveTurn } from './live-turn.js';
26
+ import { planProcessSeams, presentLiveTurn, segmentLiveTurn } from './live-turn.js';
25
27
  import type { LiveStep } from './live-turn.js';
26
28
  import { frostedGlassOf } from './fold-intensity.js';
27
29
  import { ChoreographedFlow, useFlowChat } from './ChoreographedFlow.js';
28
30
  import { ClosedProcessSummary } from './ClosedProcessSummary.js';
29
- import { StickyLane } from './StickyLane.js';
31
+ import { ReaderLaneScope, StickyLane } from './StickyLane.js';
30
32
  import type { ReaderGroup, TurnBoundary } from './projection.js';
31
33
  import type { BlockRenderProps, ReaderProps } from './types.js';
32
34
  import css from './Reader.module.css';
33
35
  import { markdownLabels, truncatedJsonLabel } from './primitive-labels.js';
34
36
  import { chatSeatProps } from './chat-seat.js';
37
+ import { visibleChatKeys, visibleChatSignature } from './rewind-cut.js';
38
+ import { ensureStyles } from './styles.js';
35
39
 
36
40
  function isNode<K extends ChatNodeKind>(node: ChatConversationViewNode, kind: K): node is ChatNode<K> {
37
41
  return node.kind === kind;
@@ -74,7 +78,7 @@ function cleanErrorMessage(raw: string | undefined): string {
74
78
  return str;
75
79
  }
76
80
 
77
- type SeatProps = BlockRenderProps & Pick<ReaderProps, 'useChat'> & {
81
+ type SeatProps = BlockRenderProps & Pick<ReaderProps, 'useChat' | 'sessionId'> & {
78
82
  nodeKey: string; boundary: TurnBoundary; pinned?: boolean; processOpen?: boolean;
79
83
  };
80
84
 
@@ -167,53 +171,9 @@ const ProcessNode = memo(function ProcessNode({ useChat, t, nodeKey, open, motio
167
171
  return content && <ProcessFragment open={open} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>{content}</ProcessFragment>;
168
172
  });
169
173
 
170
- const AssistantNode = memo(function AssistantNode({ useChat, nodeKey, boundary, processOpen = false, pinned = false, folded = false, partStart, motion, onRead, returnFocusTo, ...render }: SeatProps & {
171
- motion: boolean; onRead: () => void; returnFocusTo: RefObject<HTMLButtonElement>; partStart?: number; folded?: boolean;
172
- }) {
173
- const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
174
- if (!node || node.visibility === 'hidden' || !isNode(node, 'assistant-step')) return null;
175
- const data = node.data;
176
- const parts = assistantSegments(data.blocks);
177
- const earlier = isEarlierNarration(data, boundary);
178
- const hasToolCalls = data.blocks.some(block => block.kind === 'tool-call');
179
- const isProcessStep = earlier || folded || hasToolCalls || (boundary.latestStep > 0 && data.step < boundary.latestStep);
180
- const body = data.blocks.filter(block => block.kind !== 'reasoning' && block.kind !== 'tool-call');
181
- const visible = partStart === undefined ? parts : parts.filter(part => part.start === partStart);
182
- return <>{visible.map(part => {
183
- const index = parts.findIndex(item => item.start === part.start);
184
- const last = index === parts.length - 1;
185
- return part.kind === 'reasoning'
186
- ? <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>
187
- <ReasoningCard step={data.step} active={processOpen && boundary.status === 'open' && data.step === boundary.latestStep} motion={motion} selected={pinned} onRead={onRead}>
188
- <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running' && last && data.blocks.at(-1)?.kind === 'reasoning'}
189
- holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
190
- </ReasoningCard>
191
- </ProcessFragment>
192
- : isProcessStep ? <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey}>
193
- <article className={css.processCommentary}>
194
- <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running'} holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
195
- </article>
196
- </ProcessFragment>
197
- : hasVisibleBody(part.blocks) && <RetiringContent key={part.start} visible={pinned || processOpen || (!earlier && !folded)}>
198
- <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'}>
199
- <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running'} holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
200
- {last && data.status === 'interrupted' && <span className={css.stopped}>已停止</span>}
201
- {last && !earlier && !folded && data.status !== 'running' && boundary.status === 'closed' && (
202
- <CopyAnswer blocks={body} onFork={(() => {
203
- // The fork anchor must be the durable closing message seq (same as
204
- // the official turn-tail branch). AssistantChatData carries no seq
205
- // of its own; passing it would fork the whole session instead.
206
- const anchor = forkAnchorSeq([data.finalNode, { seq: render.forkSeq }]);
207
- return render.forkAt && anchor !== undefined ? () => render.forkAt!(anchor) : undefined;
208
- })()} metrics={render.metrics} extraActions={<OfficialActions official={render.official} messageId={data.finalNode?.messageId} />} />
209
- )}
210
- </article>
211
- </RetiringContent>;
212
- })}</>;
213
- });
214
-
215
- 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) {
216
175
  const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
176
+ const [disclosureReset] = useState(() => createSnapshotStore(0));
217
177
  if (!node || node.visibility === 'hidden') return null;
218
178
  if (isNode(node, 'user') || isNode(node, 'steering')) {
219
179
  const blocks = contentBlocks(node.data.content);
@@ -221,7 +181,7 @@ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, pr
221
181
  const otherBlocks = blocks.filter(b => b.kind !== 'image');
222
182
  const text = otherBlocks.filter((block): block is Extract<typeof block, { kind: 'text' }> => block.kind === 'text').map(block => block.text).join('\n\n');
223
183
  const time = node.data.time;
224
- return <div className={css.userCluster} data-reader-anchor data-reader-key={nodeKey} {...chatSeatProps(node.kind, nodeKey)}>
184
+ const fallback = <>
225
185
  {node.kind === 'steering' && <p className={css.meta}>补充消息</p>}
226
186
  {imageBlocks.length > 0 && <div className={css.userImages}>
227
187
  <Blocks {...render} blocks={imageBlocks} source="user" />
@@ -230,7 +190,30 @@ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, pr
230
190
  <Blocks {...render} blocks={otherBlocks} source="user" />
231
191
  </div>}
232
192
  <UserMessageActions text={text} time={time} />
233
- </div>;
193
+ </>;
194
+ const official = render.official;
195
+ let rendered: ReactNode = fallback;
196
+ if (official) {
197
+ const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined;
198
+ const owner: ChatNodeOwnerProps & Pick<ReaderProps, 'sessionId'> = {
199
+ cwd: render.cwd,
200
+ openFile: render.openFile ?? (() => {}),
201
+ openSkill: () => {},
202
+ forkAt: render.forkAt ?? (() => {}),
203
+ inspectCall: callId => official.openView('trajectory', callId),
204
+ loadImage: official.officialImageLoader,
205
+ renderMessageImages: images => official.renderSlot(OFFICIAL_SEATS.images, {
206
+ ...images, loadImage: official.officialImageLoader,
207
+ }),
208
+ fileMentions: official.officialFileMentions,
209
+ sessionId: render.sessionId,
210
+ };
211
+ const options = { hookContext: { turnData: turn?.data, disclosureReset }, fallback };
212
+ rendered = node.kind === 'user'
213
+ ? official.renderSlot(OFFICIAL_SEATS.nodes, { ...owner, node }, { ...options, entryKey: 'user' })
214
+ : official.renderSlot(OFFICIAL_SEATS.nodes, { ...owner, node }, { ...options, entryKey: 'steering' });
215
+ }
216
+ return <div className={css.userCluster} data-reader-anchor data-reader-key={nodeKey} {...chatSeatProps(node.kind, nodeKey)}>{rendered}</div>;
234
217
  }
235
218
  if (isNode(node, 'assistant-step')) return null;
236
219
  if (isNode(node, 'tool-call')) return <ToolMedia {...render} block={node.data.root} />;
@@ -295,7 +278,7 @@ function subagentCount(snapshot: { nodes: { get(key: string): ChatConversationVi
295
278
  return 0;
296
279
  }
297
280
 
298
- function GroupStatus({ group, sessionId, useChat, pending, motion }: Pick<ReaderProps, 'sessionId' | 'useChat'> & { group: ReaderGroup; motion: boolean; pending: { kind?: string } | undefined }) {
281
+ function GroupStatus({ group, useChat, pending, motion }: Pick<ReaderProps, 'useChat'> & { group: ReaderGroup; motion: boolean; pending: { kind?: string } | undefined }) {
299
282
  const text = useChat(snapshot => {
300
283
  const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
301
284
  if (turn?.status === 'closed') {
@@ -511,7 +494,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
511
494
  const firstKind = props.useChat(snapshot => snapshot.nodes.get(group.keys[0])?.kind);
512
495
  const startsWithUser = firstKind === 'user';
513
496
  const mainKeys = startsWithUser ? group.keys.slice(1) : group.keys;
514
- const flow = useMemo(() => readerFlow({ ...group, keys: mainKeys }, turn, key => nodes.get(key)), [nodes, group, mainKeys, turn]);
497
+ const flow = useMemo(() => readerFlow({ ...group, keys: mainKeys }, turn, key => nodes.get(key), snapshot.order), [nodes, group, mainKeys, turn, snapshot.order]);
515
498
  const steps = useMemo(() => segmentLiveTurn(flow, key => nodes.get(key)), [flow, nodes]);
516
499
  const liveItems = useMemo(() => presentLiveTurn(steps, boundary, autoFold), [steps, boundary, autoFold]);
517
500
  const openMode = deliverableOpenModeOf(useSyncExternalStore(
@@ -520,6 +503,8 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
520
503
  () => 'external',
521
504
  ));
522
505
  const hasProcess = flow.some(item => item.kind === 'tool' || hasProcessContent(nodes.get(item.nodeKey), boundary));
506
+ const showStatusLane = hasProcess && !isAwaitingModel;
507
+ const showClosedSummary = boundary.status === 'closed' && hasProcess && autoFold;
523
508
  // Only a real, still-active text selection delays folding. Merely clicking,
524
509
  // focusing or scrolling the live card does not create a permanent override.
525
510
  const holdingSelection = selectedProcessKeys.some(key =>
@@ -528,6 +513,12 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
528
513
  ? item.key === key || item.steps.some(step => step.key === key || ('nodeKey' in step && step.nodeKey === key))
529
514
  : item.key === key || ('nodeKey' in item.step && item.step.nodeKey === key)));
530
515
  const expanded = !autoFold || holdingSelection || processExpanded(expansionChoice, boundary);
516
+ // Seam between narration blocks. Skip a run a live-fold cell already
517
+ // covers, and do not drop the previous body's bottom spacing.
518
+ const seamPlan = useMemo(
519
+ () => planProcessSeams(steps, liveItems, expanded),
520
+ [steps, liveItems, expanded],
521
+ );
531
522
  const [foldOpenByKey, setFoldOpenByKey] = useState<Record<string, boolean>>({});
532
523
  useLayoutEffect(() => {
533
524
  // Restoring automatic folding also releases live "prior steps" disclosures.
@@ -586,6 +577,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
586
577
  fileMentions,
587
578
  metrics,
588
579
  official,
580
+ sessionId: props.sessionId,
589
581
  cwd,
590
582
  };
591
583
  const terminal = terminalLabel(boundary.reason);
@@ -600,7 +592,9 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
600
592
  const renderStep = (step: LiveStep, folded: boolean) => {
601
593
  const processOpen = folded || expanded;
602
594
  if (step.kind === 'reasoning' || step.kind === 'body') return <BlockBoundary>
603
- <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} />
595
+ <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}
596
+ seamSummary={step.kind === 'body' ? seamPlan.before.get(step.key) : undefined}
597
+ seamAfter={step.kind === 'body' && seamPlan.after.has(step.key)} />
604
598
  </BlockBoundary>;
605
599
  if (step.kind === 'tool') return <BlockBoundary><ProcessFragment open={processOpen} motion={motion} onRead={pinProcess} returnFocusTo={processButton} nodeKey={step.key} framed>
606
600
  <ToolActivity {...shared} entry={step.entry} motion={motion} turnClosed={boundary.status === 'closed'} onRead={pinProcess} />
@@ -614,25 +608,22 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
614
608
  };
615
609
  return <section className={css.turn} data-reader-turn={group.turn ?? 'unresolved'} data-reader-turn-state={boundary.status} data-reader-turn-result={boundary.reason ?? undefined}>
616
610
  {startsWithUser && <BlockBoundary><MainNode {...shared} useChat={props.useChat} boundary={boundary} nodeKey={group.keys[0]} /></BlockBoundary>}
617
- {hasProcess && !isAwaitingModel && <StickyLane kind="status" className={css.turnProcessSticky}>
618
- <Disclosure open={expanded} onChange={setExpanded} controls={flowId} buttonRef={processButton}
619
- label={<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />} />
620
- </StickyLane>}
621
- {boundary.status === 'closed' && hasProcess && autoFold && <ClosedProcessSummary open={expanded} onChange={setExpanded} controls={flowId}
622
- steps={steps.filter(step => {
623
- if (step.kind === 'user') return false;
624
- if (step.kind !== 'body') return true;
625
- const node = nodes.get(step.nodeKey);
626
- return !!node && isNode(node, 'assistant-step') && (isEarlierNarration(node.data, boundary)
627
- || node.data.blocks.some(block => block.kind === 'tool-call')
628
- || (boundary.latestStep > 0 && node.data.step < boundary.latestStep));
629
- })} />}
630
- <ChoreographedFlow id={flowId} frame={presentation} motion={motion} enabled={autoFold && boundary.status === 'open' && !holdingSelection}
631
- urgent={hasTurnError || interaction !== undefined || !sessionRunning} open={foldOpenByKey} processOpen={expanded}
632
- onOpenChange={(key, value) => { pinProcess(); setFoldOpenByKey(current => ({ ...current, [key]: value })); }} renderStep={renderStep} />
611
+ <div className={css.turnStickyRegion} data-reader-sticky-region>
612
+ {(showStatusLane || showClosedSummary) && <div className={css.turnStickyLanes}>
613
+ {showStatusLane && <StickyLane kind="status" className={css.turnProcessSticky}>
614
+ <Disclosure open={expanded} onChange={setExpanded} controls={flowId} buttonRef={processButton}
615
+ label={<GroupStatus group={group} useChat={props.useChat} pending={interaction} motion={motion} />} />
616
+ </StickyLane>}
617
+ {showClosedSummary && <ClosedProcessSummary open={expanded} onChange={setExpanded} controls={flowId}
618
+ steps={steps.filter(step => step.kind !== 'user' && step.kind !== 'body')} />}
619
+ </div>}
620
+ <ChoreographedFlow id={flowId} frame={presentation} motion={motion} enabled={autoFold && boundary.status === 'open' && !holdingSelection}
621
+ urgent={hasTurnError || interaction !== undefined || !sessionRunning} open={foldOpenByKey} processOpen={expanded}
622
+ onOpenChange={(key, value) => { pinProcess(); setFoldOpenByKey(current => ({ ...current, [key]: value })); }} renderStep={renderStep} />
623
+ </div>
633
624
  {/* 状态指示永远排在流程之后:AI 的响应永远出现在最新消息(含补充消息)的下方 */}
634
625
  {!hasProcess && boundary.status === 'open' && !isAwaitingModel && <div className={css.disclosure} data-reader-status-only>
635
- <GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />
626
+ <GroupStatus group={group} useChat={props.useChat} pending={interaction} motion={motion} />
636
627
  </div>}
637
628
  {showDeliverablesRow(boundary.status, deliverables) && <DeliverablesRow deliverables={deliverables} openFile={props.openFile} revealFile={props.revealFile} openMode={openMode} />}
638
629
  {boundary.status === 'closed' && <BlockBoundary><OfficialTail official={official} owner={tailOwner} produced={deliverables} /></BlockBoundary>}
@@ -641,10 +632,14 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
641
632
  });
642
633
 
643
634
  export function Reader(props: ReaderProps) {
635
+ useInsertionEffect(() => { ensureStyles(); }, []);
644
636
  const root = useRef<HTMLDivElement>(null);
645
637
  const activatedAt = useRef(Date.now());
646
638
  const order = props.useChat(snapshot => snapshot.order);
647
639
  const nodes = props.useChat(snapshot => snapshot.nodes);
640
+ // A rewind upserts the command row in place, so order and nodes keep their identity.
641
+ const rewindSignature = props.useChat(snapshot => visibleChatSignature(snapshot));
642
+ const renderKeys = useMemo(() => visibleChatKeys({ order, nodes }), [order, nodes, rewindSignature]);
648
643
  const timeline = props.useChat(snapshot => snapshot.timeline);
649
644
  const running = props.useSession(snapshot => snapshot.running);
650
645
  const pending = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
@@ -673,7 +668,7 @@ export function Reader(props: ReaderProps) {
673
668
  rows.push(...local.values());
674
669
  return rows;
675
670
  }, [inboxSteering, pendingList, visibleSubmissions]);
676
- const waitAnchor = waitingAnchor(order, key => nodes.get(key), pendingList);
671
+ const waitAnchor = waitingAnchor(renderKeys, key => nodes.get(key), pendingList);
677
672
  const motionPreference = props.useStore(state => state.motion);
678
673
  const motion = useMotionAllowed(motionPreference);
679
674
  const prefsSnap = useSyncExternalStore(
@@ -693,14 +688,14 @@ export function Reader(props: ReaderProps) {
693
688
  }, [autoFold, props.actions]);
694
689
  const frostedGlass = frostedGlassOf(prefsSnap);
695
690
  const streamMotion = useMemo(() => ({ enabled: motion, activatedAt: activatedAt.current }), [motion]);
696
- const groups = useMemo(() => groupNodes(order, key => nodes.get(key)), [order, nodes, timeline]);
691
+ const groups = useMemo(() => groupNodes(renderKeys, key => nodes.get(key)), [renderKeys, nodes, timeline]);
697
692
  const isAwaitingModel = useMemo(() => {
698
693
  // 状态机铁律:「深度求索中」与「正在处理/思考」永远互斥。
699
694
  // 1. 仅空闲发送(!running)的本地回显窗口:用户按下回车第 0 毫秒立即反馈。
700
695
  // 忙碌时的 queued/steering 回显不算等待——模型本来就在工作。
701
696
  if (!running && pendingList.length > 0) return true;
702
697
 
703
- const lastKey = order.at(-1);
698
+ const lastKey = renderKeys.at(-1);
704
699
  const lastNode = lastKey ? nodes.get(lastKey) : undefined;
705
700
  if (!lastKey || !lastNode) return false;
706
701
 
@@ -725,7 +720,7 @@ export function Reader(props: ReaderProps) {
725
720
  const lastGroup = groups.at(-1);
726
721
  const turn = lastGroup?.turn === null || lastGroup?.turn === undefined ? undefined : timeline.turns.get(lastGroup.turn);
727
722
  return turn?.status !== 'closed';
728
- }, [running, pendingList, order, nodes, groups, timeline]);
723
+ }, [running, pendingList, renderKeys, nodes, groups, timeline]);
729
724
  const scroll = useReadingScroll(root, motion);
730
725
  const pinnedKeys = usePinnedSelection(root);
731
726
  const selectedProcessKeys = usePinnedSelection(root, '[data-reader-process]');
@@ -833,7 +828,7 @@ export function Reader(props: ReaderProps) {
833
828
  }
834
829
  }, [props.loadThrough, props.loadOlder, scroll.jump, scroll.release, timelineItems]);
835
830
 
836
- const lastKey = order.at(-1);
831
+ const lastKey = renderKeys.at(-1);
837
832
  const lastNode = lastKey ? nodes.get(lastKey) : undefined;
838
833
  const lastSubmissionId = pendingList.length ? pendingList[pendingList.length - 1].requestId : null;
839
834
  const lastOrderKeyRef = useRef<string | undefined>(lastKey);
@@ -852,9 +847,9 @@ export function Reader(props: ReaderProps) {
852
847
 
853
848
  // ChatView publishes data-chat-flow="" on its column. Skins treat a
854
849
  // scrollport without that hook as inspect-only and hide [data-composer-seat].
855
- 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'}>
850
+ 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'}>
856
851
  <TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
857
- <div className={css.column} data-chat-flow="">
852
+ <div className={css.column} data-chat-flow="" tabIndex={-1}>
858
853
  <StickyLane kind="toolbar" className={css.toolbar}>
859
854
  <button
860
855
  type="button"
@@ -876,12 +871,12 @@ export function Reader(props: ReaderProps) {
876
871
  {historyError && <div className={css.notice}>历史记录加载失败,可再次尝试;现有内容未改变。</div>}
877
872
  {openError && <div className={css.error} role="alert">会话暂时无法读取:{openError.message}</div>}
878
873
  {loading && groups.length === 0 && <p className={css.empty} role="status">正在读取会话…</p>}
879
- {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} />)}
874
+ <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} />} />
880
875
  {pendingInputs.map((item, index) => {
881
876
  const submission = 'requestId' in item ? item : undefined;
882
877
  const text = submission?.text ?? inboxText(item as PendingInboxItem);
883
878
  const images = submission ? pendingSubmissionImages(submission) : [];
884
- const attachmentCount = images.length;
879
+ const attachmentCount = submission ? images.length : inboxAttachmentCount(item as PendingInboxItem);
885
880
  const requestId = submission?.requestId ?? `inbox-${index}`;
886
881
  return (
887
882
  <div key={requestId} className={css.userCluster} data-reader-pending-submission data-pending-steering={submission ? undefined : ''} data-pending-attachment-count={attachmentCount}>
@@ -914,5 +909,5 @@ export function Reader(props: ReaderProps) {
914
909
  <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>
915
910
  </button></div>}
916
911
  </div>
917
- </div></StreamMotionContext.Provider>;
912
+ </div></ReaderLaneScope></StreamMotionContext.Provider>;
918
913
  }
@@ -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';
@@ -17,6 +17,7 @@ import css from './Reader.module.css';
17
17
  const LABEL: Record<ToolPhase, string> = { preparing: '输入生成中', running: '执行中', returned: '已返回', succeeded: '已完成', failed: '失败', interrupted: '已中断' };
18
18
  const ICONS = { write: IconEditOutlineRegular, read: IconBrowseOutlineRegular, terminal: IconApiOutlineRegular, search: IconSearchOutlineRegular, web: IconSearchOutlineRegular, code: IconApiOutlineRegular, other: IconSparkleRegular } satisfies Record<ToolCategory, unknown>;
19
19
  const number = new Intl.NumberFormat('zh-CN');
20
+ const preparingTerminalLabels = { ...terminalBlockLabels, running: '正在生成命令' };
20
21
  const language = (path: string | undefined) => path?.split('.').at(-1);
21
22
  const duration = (ms: number) => ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} 秒`;
22
23
 
@@ -28,14 +29,17 @@ function generatedInput(content: string, target: string | undefined, preparing:
28
29
  </div>;
29
30
  }
30
31
 
31
- function InputView({ model, preparing, fillComposer }: { model: ReturnType<typeof activitySummary>; preparing: boolean; fillComposer: BlockRenderProps['fillComposer'] }) {
32
+ function InputView({ model, phase, fillComposer }: { model: ReturnType<typeof activitySummary>; phase: ToolPhase; fillComposer: BlockRenderProps['fillComposer'] }) {
33
+ const preparing = phase === 'preparing';
34
+ const inProgress = preparing || phase === 'running';
32
35
  if ((model.name === 'render_ui' || model.name === 'show_widget') && typeof model.args?.html === 'string') {
33
36
  return preparing
34
37
  ? <StreamingMcpAppPlaceholder title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} />
35
38
  : <McpAppFrame html={model.args.html as string} title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} fillComposer={fillComposer} />;
36
39
  }
37
40
  if (model.content) return generatedInput(model.content, model.target, preparing);
38
- if (model.command) return <div data-reader-tool-terminal><p className={css.toolDetailNote}>{preparing ? '正在生成命令 · 尚未执行' : '提交的命令'}</p><TerminalBlock command={model.command} cwd={model.cwd} labels={terminalBlockLabels} /></div>;
41
+ if (model.command) return <div data-reader-tool-terminal><p className={css.toolDetailNote}>{preparing ? '正在生成命令 · 尚未执行' : '提交的命令'}</p><TerminalBlock command={model.command} cwd={model.cwd} running={inProgress} labels={preparing ? preparingTerminalLabels : terminalBlockLabels} /></div>;
42
+ if (model.category === 'terminal' && preparing && model.argsState === 'partial') return <p className={css.toolDetailNote} data-reader-tool-command-pending>命令仍在生成,收到可识别的命令文本后会显示预览。</p>;
39
43
  return <JsonTree data={model.args} label={preparing ? '已收到的输入字段' : '工具输入'} labels={jsonTreeLabels} />;
40
44
  }
41
45
 
@@ -95,11 +99,11 @@ function ResultFallback({ entry, model, phase, ...render }: BlockRenderProps & {
95
99
  const block = entry.block;
96
100
  if (!block || !('kind' in block)) return <>
97
101
  <p className={css.toolDetailNote}>{phase === 'interrupted' ? '已中断,没有工具结果。已生成的输入仍可查看。' : phase === 'preparing' ? '模型正在生成工具输入,工具还未开始执行。' : '工具已开始执行,正在等待结果。'}</p>
98
- <InputView model={model} preparing={phase === 'preparing'} fillComposer={render.fillComposer} />
102
+ <InputView model={model} phase={phase} fillComposer={render.fillComposer} />
99
103
  </>;
100
104
  const meta = objectValue(block.meta);
101
105
  const text = block.content.filter(item => item.type === 'text').map(item => item.text).join('\n');
102
- if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model} preparing={false} fillComposer={render.fillComposer} /><pre className={css.toolRaw}>{text}</pre></>;
106
+ if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model} phase={phase} fillComposer={render.fillComposer} /><pre className={css.toolRaw}>{text}</pre></>;
103
107
 
104
108
  if (model.category === 'terminal') {
105
109
  const facts = executionFacts(block);
@@ -247,7 +251,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
247
251
  <div ref={panel} id={`${detailId}-panel`} className={css.toolPanel} role="tabpanel" aria-labelledby={`${detailId}-${tab}`} tabIndex={0}>
248
252
  {selected && <p className={css.toolDetailNote}>为保留选区,预览暂停更新;当前状态见卡片标题。</p>}
249
253
  {tab === 'result' && <ResultView {...render} {...preview} />}
250
- {tab === 'input' && <><InputView model={preview.model} preparing={preview.phase === 'preparing'} fillComposer={render.fillComposer} /><details className={css.detail}><summary>全部输入字段</summary><JsonTree data={preview.model.args} label="输入字段" labels={jsonTreeLabels} /></details></>}
254
+ {tab === 'input' && <><InputView model={preview.model} phase={preview.phase} fillComposer={render.fillComposer} /><details className={css.detail}><summary>全部输入字段</summary><JsonTree data={preview.model.args} label="输入字段" labels={jsonTreeLabels} /></details></>}
251
255
  {tab === 'raw' && <><p className={css.toolDetailNote}>完整记录 · 只读 · 不执行其中的代码</p><h4 className={css.toolRawLabel}>工具输入</h4><pre className={css.toolRaw}>{preview.model.raw || '输入尚未到达'}</pre>{rawResult && <><h4 className={css.toolRawLabel}>工具结果</h4><pre className={css.toolRaw}>{rawResult}</pre></>}</>}
252
256
  </div>
253
257
  </div>
@@ -23,6 +23,7 @@ export function basename(path: string): string {
23
23
  export function dirname(path: string): string {
24
24
  const normalized = path.replace(/[/\\]+$/, '');
25
25
  const at = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'));
26
+ if (at === 0 || (at === 2 && /^[A-Za-z]:/.test(normalized))) return normalized.slice(0, at + 1);
26
27
  return at === -1 ? '.' : normalized.slice(0, at) || '.';
27
28
  }
28
29
 
@@ -6,6 +6,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types';
6
6
  import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path';
7
7
  import * as workspacePathPkg from '@deepseek-ai/dsh-util-workspace-path';
8
8
  import { dirname } from './deliverables.js';
9
+ import { isAbsoluteWorkspacePath } from '../workspace-path.js';
10
+ import { ensureStyles } from './styles.js';
9
11
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client';
10
12
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
11
13
  import { Reader } from './Reader.js';
@@ -17,6 +19,7 @@ import { fileAddressFor, modeFromSnapshot, openDeliverableFile } from './open-fi
17
19
  import type { ReaderInjected } from './types.js';
18
20
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
19
21
  import { installOfficialSlots, officialChildren, type CompositionRegistry } from './official-slots.js';
22
+ import { TEXT_TRANSFORM_SERVICE, createTextTransformRegistry, installTextTransformRegistry } from './text-transform.js';
20
23
 
21
24
  /** Structural face of the sanctioned per-session composer writer. */
22
25
  interface ComposerShell {
@@ -52,12 +55,21 @@ async function openWorkspacePath(
52
55
  }
53
56
  }
54
57
 
55
- 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';
56
60
  export { McpAppFrame } from './McpAppFrame.js';
61
+ export { TEXT_TRANSFORM_SERVICE, createTextTransformRegistry, originalAnswerText } from './text-transform.js';
57
62
  export const name = 'dsh-better-display-client';
58
63
  export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'remote', 'remote.session'];
59
64
 
65
+ // All CSS imports have injected their nodes before this module's body runs.
66
+ ensureStyles();
67
+
60
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);
61
73
  const store = createReaderStore();
62
74
  // conversation.view is session-scoped, so session persist keys are
63
75
  // `dsh.reader.v1.<sessionId>`. One root instance keeps glass, fold
@@ -138,7 +150,7 @@ export function apply(ctx: Context): void {
138
150
  const res = await fetch('/better-display/reveal', {
139
151
  method: 'POST',
140
152
  headers: { 'Content-Type': 'application/json' },
141
- body: JSON.stringify({ path: targetPath }),
153
+ body: JSON.stringify({ path: targetPath, sessionId }),
142
154
  });
143
155
  if (res.ok) {
144
156
  const data = await res.json();
@@ -150,6 +162,10 @@ export function apply(ctx: Context): void {
150
162
 
151
163
  // 2. Fallback to official opener with parent directory
152
164
  const parentDir = dirname(targetPath);
165
+ if (!isAbsoluteWorkspacePath(parentDir)) {
166
+ console.warn('[dsh-better-display] reveal failed; refusing to open a relative parent:', targetPath);
167
+ return;
168
+ }
153
169
  const remote = ctx.remote as unknown as { session?: { openWorkspacePath: (arg: { path: string }) => Promise<{ ok: boolean; error?: { message: string } }> } } | undefined;
154
170
  const remoteSession = remote?.session
155
171
  ?? (ctx.get?.('remote.session') as unknown as { openWorkspacePath: (arg: { path: string }) => Promise<{ ok: boolean; error?: { message: string } }> } | undefined);