dsh-better-display 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/DESIGN.md +43 -3
  3. package/README.en.md +9 -1
  4. package/README.md +9 -1
  5. package/docs/dependency-update-policy.md +9 -0
  6. package/docs/extension-points.md +172 -0
  7. package/docs/official-rendering-bridge.md +59 -18
  8. package/docs/openapi.yaml +147 -0
  9. package/docs/runbooks/blank-reader.md +39 -0
  10. package/lib/client.js +1629 -586
  11. package/lib/client.js.map +1 -1
  12. package/lib/dsh-better-display.js +535 -57
  13. package/lib/types/client/AssistantNode.d.ts +20 -0
  14. package/lib/types/client/AssistantNode.d.ts.map +1 -0
  15. package/lib/types/client/AssistantNode.js +46 -0
  16. package/lib/types/client/AssistantNode.js.map +1 -0
  17. package/lib/types/client/Blocks.d.ts.map +1 -1
  18. package/lib/types/client/Blocks.js +33 -10
  19. package/lib/types/client/Blocks.js.map +1 -1
  20. package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
  21. package/lib/types/client/ChoreographedFlow.js +2 -0
  22. package/lib/types/client/ChoreographedFlow.js.map +1 -1
  23. package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
  24. package/lib/types/client/ClosedProcessSummary.js +3 -1
  25. package/lib/types/client/ClosedProcessSummary.js.map +1 -1
  26. package/lib/types/client/McpAppFrame.d.ts.map +1 -1
  27. package/lib/types/client/McpAppFrame.js +5 -11
  28. package/lib/types/client/McpAppFrame.js.map +1 -1
  29. package/lib/types/client/OfficialContent.d.ts +17 -3
  30. package/lib/types/client/OfficialContent.d.ts.map +1 -1
  31. package/lib/types/client/OfficialContent.js +33 -7
  32. package/lib/types/client/OfficialContent.js.map +1 -1
  33. package/lib/types/client/Reader.d.ts.map +1 -1
  34. package/lib/types/client/Reader.js +76 -73
  35. package/lib/types/client/Reader.js.map +1 -1
  36. package/lib/types/client/SettingsSection.d.ts.map +1 -1
  37. package/lib/types/client/SettingsSection.js +0 -5
  38. package/lib/types/client/SettingsSection.js.map +1 -1
  39. package/lib/types/client/StickyLane.d.ts +12 -0
  40. package/lib/types/client/StickyLane.d.ts.map +1 -1
  41. package/lib/types/client/StickyLane.js +78 -2
  42. package/lib/types/client/StickyLane.js.map +1 -1
  43. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  44. package/lib/types/client/ToolActivity.js +10 -5
  45. package/lib/types/client/ToolActivity.js.map +1 -1
  46. package/lib/types/client/deliverables.d.ts.map +1 -1
  47. package/lib/types/client/deliverables.js +2 -0
  48. package/lib/types/client/deliverables.js.map +1 -1
  49. package/lib/types/client/index.d.ts +3 -1
  50. package/lib/types/client/index.d.ts.map +1 -1
  51. package/lib/types/client/index.js +15 -1
  52. package/lib/types/client/index.js.map +1 -1
  53. package/lib/types/client/live-turn.d.ts +17 -5
  54. package/lib/types/client/live-turn.d.ts.map +1 -1
  55. package/lib/types/client/live-turn.js +106 -76
  56. package/lib/types/client/live-turn.js.map +1 -1
  57. package/lib/types/client/motion.d.ts +26 -2
  58. package/lib/types/client/motion.d.ts.map +1 -1
  59. package/lib/types/client/motion.js +144 -10
  60. package/lib/types/client/motion.js.map +1 -1
  61. package/lib/types/client/official-slots.d.ts +9 -0
  62. package/lib/types/client/official-slots.d.ts.map +1 -1
  63. package/lib/types/client/official-slots.js +22 -4
  64. package/lib/types/client/official-slots.js.map +1 -1
  65. package/lib/types/client/open-file.d.ts.map +1 -1
  66. package/lib/types/client/open-file.js +1 -6
  67. package/lib/types/client/open-file.js.map +1 -1
  68. package/lib/types/client/projection.d.ts +15 -2
  69. package/lib/types/client/projection.d.ts.map +1 -1
  70. package/lib/types/client/projection.js +16 -4
  71. package/lib/types/client/projection.js.map +1 -1
  72. package/lib/types/client/rewind-cut.d.ts +131 -0
  73. package/lib/types/client/rewind-cut.d.ts.map +1 -0
  74. package/lib/types/client/rewind-cut.js +321 -0
  75. package/lib/types/client/rewind-cut.js.map +1 -0
  76. package/lib/types/client/styles.d.ts +3 -0
  77. package/lib/types/client/styles.d.ts.map +1 -0
  78. package/lib/types/client/styles.js +25 -0
  79. package/lib/types/client/styles.js.map +1 -0
  80. package/lib/types/client/text-transform.d.ts +93 -0
  81. package/lib/types/client/text-transform.d.ts.map +1 -0
  82. package/lib/types/client/text-transform.js +183 -0
  83. package/lib/types/client/text-transform.js.map +1 -0
  84. package/lib/types/client/timeline.d.ts.map +1 -1
  85. package/lib/types/client/timeline.js.map +1 -1
  86. package/lib/types/client/tool-activity.d.ts +11 -4
  87. package/lib/types/client/tool-activity.d.ts.map +1 -1
  88. package/lib/types/client/tool-activity.js +54 -9
  89. package/lib/types/client/tool-activity.js.map +1 -1
  90. package/lib/types/client/turn-slots.d.ts +29 -0
  91. package/lib/types/client/turn-slots.d.ts.map +1 -0
  92. package/lib/types/client/turn-slots.js +62 -0
  93. package/lib/types/client/turn-slots.js.map +1 -0
  94. package/lib/types/client/types.d.ts +18 -0
  95. package/lib/types/client/types.d.ts.map +1 -1
  96. package/lib/types/dsh-better-display.d.ts +4 -1
  97. package/lib/types/dsh-better-display.d.ts.map +1 -1
  98. package/lib/types/dsh-better-display.js +55 -43
  99. package/lib/types/dsh-better-display.js.map +1 -1
  100. package/lib/types/flags.d.ts +7 -0
  101. package/lib/types/flags.d.ts.map +1 -0
  102. package/lib/types/flags.js +37 -0
  103. package/lib/types/flags.js.map +1 -0
  104. package/lib/types/log.d.ts +17 -0
  105. package/lib/types/log.d.ts.map +1 -0
  106. package/lib/types/log.js +122 -0
  107. package/lib/types/log.js.map +1 -0
  108. package/lib/types/metrics.d.ts +18 -0
  109. package/lib/types/metrics.d.ts.map +1 -0
  110. package/lib/types/metrics.js +35 -0
  111. package/lib/types/metrics.js.map +1 -0
  112. package/lib/types/reveal.d.ts +12 -0
  113. package/lib/types/reveal.d.ts.map +1 -0
  114. package/lib/types/reveal.js +147 -0
  115. package/lib/types/reveal.js.map +1 -0
  116. package/lib/types/workspace-path.d.ts +3 -0
  117. package/lib/types/workspace-path.d.ts.map +1 -0
  118. package/lib/types/workspace-path.js +5 -0
  119. package/lib/types/workspace-path.js.map +1 -0
  120. package/package.json +36 -5
  121. package/scripts/changelog.mjs +20 -0
  122. package/scripts/check-agents-md.mjs +67 -0
  123. package/scripts/check-bundle-size.mjs +38 -0
  124. package/scripts/check-dead-flags.mjs +55 -0
  125. package/scripts/check-todo-debt.mjs +69 -0
  126. package/scripts/security-audit-report.mjs +92 -0
  127. package/scripts/verify-commentary-visible.mjs +627 -0
  128. package/src/client/AssistantNode.tsx +72 -0
  129. package/src/client/Blocks.tsx +49 -10
  130. package/src/client/ChoreographedFlow.tsx +3 -1
  131. package/src/client/ClosedProcessSummary.tsx +3 -1
  132. package/src/client/McpAppFrame.tsx +5 -13
  133. package/src/client/OfficialContent.tsx +47 -9
  134. package/src/client/Reader.module.css +103 -21
  135. package/src/client/Reader.tsx +83 -88
  136. package/src/client/SettingsSection.tsx +1 -7
  137. package/src/client/StickyLane.tsx +81 -2
  138. package/src/client/ToolActivity.tsx +10 -6
  139. package/src/client/deliverables.ts +1 -0
  140. package/src/client/index.tsx +18 -2
  141. package/src/client/live-turn.ts +100 -69
  142. package/src/client/motion.tsx +142 -7
  143. package/src/client/official-slots.tsx +23 -4
  144. package/src/client/open-file.ts +2 -8
  145. package/src/client/projection.ts +16 -4
  146. package/src/client/rewind-cut.ts +340 -0
  147. package/src/client/styles.ts +22 -0
  148. package/src/client/text-transform.ts +265 -0
  149. package/src/client/timeline.ts +0 -2
  150. package/src/client/tool-activity.ts +67 -9
  151. package/src/client/turn-slots.ts +80 -0
  152. package/src/client/types.ts +19 -0
  153. package/src/dsh-better-display.ts +57 -42
  154. package/src/flags.ts +40 -0
  155. package/src/log.ts +137 -0
  156. package/src/metrics.ts +50 -0
  157. package/src/reveal.ts +90 -0
  158. package/src/workspace-path.ts +4 -0
  159. package/tests/browser-commentary.tsx +191 -0
  160. package/tests/chat-flow-hook.test.ts +1 -1
  161. package/tests/cloud-gate.test.ts +1 -1
  162. package/tests/context-tool-update.test.ts +1 -1
  163. package/tests/conversation-scroll.test.ts +1 -1
  164. package/tests/deliverables.test.ts +1 -1
  165. package/tests/entry.test.ts +1 -1
  166. package/tests/fixtures/reader-lane-owners.tsx +46 -0
  167. package/tests/fixtures/reader-official-users.tsx +234 -0
  168. package/tests/fixtures/reader-theme-overlap.html +80 -0
  169. package/tests/fixtures/reader-user-bubble.tsx +222 -0
  170. package/tests/flags.test.ts +66 -0
  171. package/tests/fold-choreography.test.ts +32 -8
  172. package/tests/fold-intensity.test.ts +11 -6
  173. package/tests/footer-precedence.test.ts +9 -1
  174. package/tests/history-word-regression.test.ts +1 -1
  175. package/tests/live-turn.test.ts +125 -54
  176. package/tests/log.test.ts +99 -0
  177. package/tests/mcp-apps.test.ts +1 -1
  178. package/tests/message-chrome.test.ts +1 -1
  179. package/tests/metrics.test.ts +52 -0
  180. package/tests/official-slots.test.ts +41 -2
  181. package/tests/open-file.test.ts +1 -1
  182. package/tests/pending-submission.test.ts +12 -1
  183. package/tests/pinned-selection.test.ts +16 -0
  184. package/tests/platform-media.test.ts +1 -1
  185. package/tests/projection.test.ts +66 -3
  186. package/tests/reader-glass-css.test.ts +28 -0
  187. package/tests/reader-official-users.test.ts +91 -0
  188. package/tests/reader-sticky-surface.test.ts +377 -0
  189. package/tests/reader-theme-lanes.test.ts +54 -0
  190. package/tests/reader-theme-overlap.test.ts +151 -0
  191. package/tests/reader-user-bubble.test.ts +137 -0
  192. package/tests/reasoning-follow.test.ts +1 -1
  193. package/tests/retiring-content.test.ts +229 -0
  194. package/tests/reveal.test.ts +128 -0
  195. package/tests/rewind-cut.test.ts +1090 -0
  196. package/tests/settings-copy.test.ts +1 -1
  197. package/tests/skill-status.test.ts +1 -1
  198. package/tests/stock-install.test.ts +9 -1
  199. package/tests/stream-buffer.test.ts +1 -1
  200. package/tests/styles.test.ts +75 -0
  201. package/tests/terminal-input-preview.test.ts +113 -0
  202. package/tests/text-transform.test.ts +272 -0
  203. package/tests/timeline.test.ts +1 -1
  204. package/tests/tool-clock.test.ts +1 -1
  205. package/tests/tool-diff.test.ts +1 -1
  206. package/tests/waiting-clock.test.ts +1 -1
  207. package/tests/word-timeline.test.ts +1 -1
  208. package/tsdown.config.ts +49 -1
@@ -0,0 +1,72 @@
1
+ import { Fragment, memo } from 'react';
2
+ import type { RefObject } from 'react';
3
+ import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from '@deepseek-ai/dsh-client-ui-chat/client';
4
+ import { Blocks, CopyAnswer } from './Blocks.js';
5
+ import { ReasoningCard } from './ReasoningCard.js';
6
+ import { OfficialActions, OfficialAssistantBody } from './OfficialContent.js';
7
+ import { Disclosure, ProcessFragment } from './motion.js';
8
+ import { FoldSummaryText } from './LiveFold.js';
9
+ import { assistantSegments, forkAnchorSeq, hasVisibleBody, isEarlierNarration } from './projection.js';
10
+ import type { TurnBoundary } from './projection.js';
11
+ import type { BlockRenderProps, ReaderProps } from './types.js';
12
+ import css from './Reader.module.css';
13
+
14
+ function isNode<K extends ChatNodeKind>(node: ChatConversationViewNode, kind: K): node is ChatNode<K> {
15
+ return node.kind === kind;
16
+ }
17
+
18
+ type SeatProps = BlockRenderProps & Pick<ReaderProps, 'useChat' | 'sessionId'> & {
19
+ nodeKey: string; boundary: TurnBoundary; pinned?: boolean; processOpen?: boolean;
20
+ };
21
+
22
+ export const AssistantNode = memo(function AssistantNode({ useChat, nodeKey, boundary, processOpen = false, pinned = false, folded = false, partStart, seamSummary, seamAfter = false, motion, onRead, returnFocusTo, ...render }: SeatProps & {
23
+ motion: boolean; onRead: () => void; returnFocusTo: RefObject<HTMLButtonElement>; partStart?: number; folded?: boolean;
24
+ seamSummary?: string; seamAfter?: boolean;
25
+ }) {
26
+ const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
27
+ if (!node || node.visibility === 'hidden' || !isNode(node, 'assistant-step')) return null;
28
+ const data = node.data;
29
+ const parts = assistantSegments(data.blocks);
30
+ const earlier = isEarlierNarration(data, boundary);
31
+ const hasToolCalls = data.blocks.some(block => block.kind === 'tool-call');
32
+ const isProcessStep = earlier || folded || hasToolCalls || (boundary.latestStep > 0 && data.step < boundary.latestStep);
33
+ const body = data.blocks.filter(block => block.kind !== 'reasoning' && block.kind !== 'tool-call');
34
+ const visible = partStart === undefined ? parts : parts.filter(part => part.start === partStart);
35
+ const bodyProps = { streaming: data.status === 'running', holdFormatting: pinned, startedAt: data.time, interrupted: data.status === 'interrupted', liveText: true as const };
36
+ return <>{visible.map(part => {
37
+ const index = parts.findIndex(item => item.start === part.start);
38
+ const last = index === parts.length - 1;
39
+ if (part.kind === 'reasoning') return <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>
40
+ <ReasoningCard step={data.step} active={processOpen && boundary.status === 'open' && data.step === boundary.latestStep} motion={motion} selected={pinned} onRead={onRead}>
41
+ <Blocks {...render} blocks={part.blocks} streaming={data.status === 'running' && last && data.blocks.at(-1)?.kind === 'reasoning'}
42
+ holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
43
+ </ReasoningCard>
44
+ </ProcessFragment>;
45
+ if (!hasVisibleBody(part.blocks)) return null;
46
+ // Narration stays body prose and never enters a disclosure. The seam in
47
+ // the gap summarises folded process between two body blocks. A foreign
48
+ // assistant-step renderer receives these original blocks; Reader markdown,
49
+ // including the text transform, is only the fallback.
50
+ const isFinalAnswer = !isProcessStep;
51
+ return <Fragment key={part.start}>
52
+ {seamSummary && <div className={css.processSeamRow} data-reader-process-seam data-reader-process-seam-summary={seamSummary}>
53
+ <Disclosure open={false} onChange={() => onRead()} ariaLabel="被折叠的过程"
54
+ label={<FoldSummaryText summary={seamSummary} motion={false} />} />
55
+ </div>}
56
+ <article className={css.answer} data-reader-body data-reader-anchor data-reader-key={nodeKey} data-reader-source-start={part.start}
57
+ data-reader-seam-after={seamAfter || undefined} data-answer-status={data.status} data-answer-phase={isFinalAnswer ? 'body' : 'process'}>
58
+ <OfficialAssistantBody {...render} node={node} blocks={part.blocks} {...bodyProps} />
59
+ {isFinalAnswer && last && data.status === 'interrupted' && <span className={css.stopped}>已停止</span>}
60
+ {isFinalAnswer && last && data.status !== 'running' && boundary.status === 'closed' && (
61
+ <CopyAnswer blocks={body} onFork={(() => {
62
+ // The fork anchor must be the durable closing message seq (same as
63
+ // the official turn-tail branch). AssistantChatData carries no seq
64
+ // of its own; passing it would fork the whole session instead.
65
+ const anchor = forkAnchorSeq([data.finalNode, { seq: render.forkSeq }]);
66
+ return render.forkAt && anchor !== undefined ? () => render.forkAt!(anchor) : undefined;
67
+ })()} metrics={render.metrics} extraActions={<OfficialActions official={render.official} messageId={data.finalNode?.messageId} />} />
68
+ )}
69
+ </article>
70
+ </Fragment>;
71
+ })}</>;
72
+ });
@@ -1,4 +1,4 @@
1
- import { Component, Fragment, memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
1
+ import { Component, Fragment, memo, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
2
2
  import type { ReactNode } from 'react';
3
3
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
4
4
  import type { AssistantBlock, UserMessageNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
@@ -9,6 +9,8 @@ import { markdownLabels, truncatedJsonLabel } from './primitive-labels.js';
9
9
  import type { BlockRenderProps, ReaderBlockOwner } from './types.js';
10
10
  import { useStreamingText } from './streaming.js';
11
11
  import { MotionMarkdown, MotionPlainText } from './word-motion.js';
12
+ import { activeTextTransform, displayStreaming, originalAnswerText, readerBlockOwner, useDisplayText } from './text-transform.js';
13
+ import type { TextTransformRegistry } from './text-transform.js';
12
14
  import css from './Reader.module.css';
13
15
 
14
16
  const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
@@ -82,7 +84,16 @@ export function contentBlocks(content: UserMessageNode['content']): AssistantBlo
82
84
 
83
85
  type TextPresentation = { startedAt?: number; interrupted?: boolean; liveText?: boolean };
84
86
 
85
- function ReadingMarkdown({ text, streaming, holdFormatting, startedAt, interrupted = false, liveText = false, kind = 'body', fileMentions }: { text: string; streaming: boolean; holdFormatting: boolean; kind?: 'body' | 'reasoning'; fileMentions?: BlockRenderProps['fileMentions'] } & TextPresentation) {
87
+ type MarkdownStreamProps = {
88
+ text: string;
89
+ streaming: boolean;
90
+ holdFormatting: boolean;
91
+ kind?: 'body' | 'reasoning';
92
+ fileMentions?: BlockRenderProps['fileMentions'];
93
+ transformed?: boolean;
94
+ } & TextPresentation;
95
+
96
+ function MarkdownStream({ text, streaming, holdFormatting, startedAt, interrupted = false, liveText = false, kind = 'body', fileMentions, transformed = false }: MarkdownStreamProps) {
86
97
  const root = useRef<HTMLDivElement>(null);
87
98
  const presentation = useStreamingText(text, streaming, { startedAt, interrupted: interrupted || !liveText, selected: holdFormatting });
88
99
  // Native Markdown changes block keys for its full final parse. Keep the last
@@ -92,24 +103,50 @@ function ReadingMarkdown({ text, streaming, holdFormatting, startedAt, interrupt
92
103
  const effectiveMode = holdFormatting ? committedMode.current : presentation.formatStreaming;
93
104
  useLayoutEffect(() => { committedMode.current = effectiveMode; }, [effectiveMode]);
94
105
  return <div ref={root} className={css.readingText} data-reader-text data-reader-text-kind={kind} data-received-length={text.length} data-shown-length={presentation.text.length}
95
- data-presentation-pending={presentation.pending || undefined} data-motion-style="opacity-blur" data-ud-motion="reader-text-arrival" data-ud-motion-type="reveal" data-ud-motion-no-flash="true">
106
+ data-presentation-pending={presentation.pending || undefined} data-motion-style="opacity-blur" data-ud-motion="reader-text-arrival" data-ud-motion-type="reveal" data-ud-motion-no-flash="true"
107
+ data-text-transform={transformed ? (streaming ? 'live' : 'final') : undefined}>
96
108
  <MotionMarkdown text={presentation.text} streaming={effectiveMode} enabled={liveText && presentation.reveal && effectiveMode} revision={presentation.revision} fileMentions={fileMentions} />
97
109
  </div>;
98
110
  }
99
111
 
100
- function ReadingReasoning({ text, streaming, holdFormatting, startedAt, interrupted = false, liveText = false }: { text: string; streaming: boolean; holdFormatting: boolean } & TextPresentation) {
112
+ function TransformedMarkdown({ registry, blockKey, source, text, streaming, ...props }: MarkdownStreamProps & { registry: TextTransformRegistry; blockKey: string; source: ReaderBlockOwner['source'] }) {
113
+ const shown = useDisplayText(registry, { text, streaming, blockKey, kind: 'body', source });
114
+ return <MarkdownStream {...props} text={shown.text} streaming={displayStreaming(streaming, shown, true)} transformed />;
115
+ }
116
+
117
+ function ReadingMarkdown({ blockKey, source, ...props }: MarkdownStreamProps & { blockKey: string; source: ReaderBlockOwner['source'] }) {
118
+ const registry = activeTextTransform();
119
+ if (registry?.has('body')) return <TransformedMarkdown registry={registry} blockKey={blockKey} source={source} {...props} />;
120
+ return <MarkdownStream {...props} />;
121
+ }
122
+
123
+ type ReasoningStreamProps = { text: string; streaming: boolean; holdFormatting: boolean; transformed?: boolean } & TextPresentation;
124
+
125
+ function ReasoningStream({ text, streaming, holdFormatting, startedAt, interrupted = false, liveText = false, transformed = false }: ReasoningStreamProps) {
101
126
  const presentation = useStreamingText(text, streaming, { startedAt, interrupted: interrupted || !liveText, selected: holdFormatting });
102
127
  return <div className={css.readingText} data-reader-text data-reader-text-kind="reasoning" data-received-length={text.length} data-shown-length={presentation.text.length}
103
- data-presentation-pending={presentation.pending || undefined} data-motion-style="opacity-blur" data-ud-motion="reader-text-arrival" data-ud-motion-type="reveal" data-ud-motion-no-flash="true">
128
+ data-presentation-pending={presentation.pending || undefined} data-motion-style="opacity-blur" data-ud-motion="reader-text-arrival" data-ud-motion-type="reveal" data-ud-motion-no-flash="true"
129
+ data-text-transform={transformed ? (streaming ? 'live' : 'final') : undefined}>
104
130
  <MotionPlainText text={presentation.text} enabled={liveText && presentation.reveal} revision={presentation.revision} />
105
131
  </div>;
106
132
  }
107
133
 
108
- function fallback(block: AssistantBlock, streaming: boolean, source: ReaderBlockOwner['source'], loadImage: BlockRenderProps['loadImage'], fillComposer: BlockRenderProps['fillComposer'], holdFormatting: boolean, presentation: TextPresentation, fileMentions?: BlockRenderProps['fileMentions']): ReactNode {
134
+ function TransformedReasoning({ registry, blockKey, source, text, streaming, ...props }: ReasoningStreamProps & { registry: TextTransformRegistry; blockKey: string; source: ReaderBlockOwner['source'] }) {
135
+ const shown = useDisplayText(registry, { text, streaming, blockKey, kind: 'reasoning', source });
136
+ return <ReasoningStream {...props} text={shown.text} streaming={displayStreaming(streaming, shown, true)} transformed />;
137
+ }
138
+
139
+ function ReadingReasoning({ blockKey, source, ...props }: ReasoningStreamProps & { blockKey: string; source: ReaderBlockOwner['source'] }) {
140
+ const registry = activeTextTransform();
141
+ if (registry?.has('reasoning')) return <TransformedReasoning registry={registry} blockKey={blockKey} source={source} {...props} />;
142
+ return <ReasoningStream {...props} />;
143
+ }
144
+
145
+ function fallback(block: AssistantBlock, streaming: boolean, source: ReaderBlockOwner['source'], loadImage: BlockRenderProps['loadImage'], fillComposer: BlockRenderProps['fillComposer'], holdFormatting: boolean, presentation: TextPresentation, blockKey: string, fileMentions?: BlockRenderProps['fileMentions']): ReactNode {
109
146
  switch (block.kind) {
110
- case 'text': return source === 'user' ? <MarkdownText text={block.text} labels={markdownLabels} /> : <ReadingMarkdown text={block.text} streaming={streaming} holdFormatting={holdFormatting} {...presentation} fileMentions={fileMentions} />;
147
+ case 'text': return source === 'user' ? <MarkdownText text={block.text} labels={markdownLabels} /> : <ReadingMarkdown text={block.text} streaming={streaming} holdFormatting={holdFormatting} {...presentation} fileMentions={fileMentions} blockKey={blockKey} source={source} />;
111
148
  case 'image': return <ImageBlock attachment={block.attachment} loadImage={loadImage} />;
112
- case 'reasoning': return <ReadingReasoning text={block.text} streaming={streaming} holdFormatting={holdFormatting} {...presentation} />;
149
+ case 'reasoning': return <ReadingReasoning text={block.text} streaming={streaming} holdFormatting={holdFormatting} {...presentation} blockKey={blockKey} source={source} />;
113
150
  case 'tool-call': return <JsonBlock label={`工具参数 · ${block.name}`} payload={block.argsRaw} truncatedLabel={truncatedJsonLabel} />;
114
151
  case 'other': {
115
152
  const raw = block.block;
@@ -130,10 +167,11 @@ function fallback(block: AssistantBlock, streaming: boolean, source: ReaderBlock
130
167
  export const Blocks = memo(function Blocks({ blocks, streaming = false, source = 'assistant', holdFormatting = false, startedAt, interrupted, liveText, renderSlotChain, loadImage, fillComposer, fileMentions }: BlockRenderProps & TextPresentation & {
131
168
  blocks: readonly AssistantBlock[]; streaming?: boolean; source?: ReaderBlockOwner['source']; holdFormatting?: boolean;
132
169
  }) {
170
+ const reactId = useId();
133
171
  return <ComposerFillContext.Provider value={fillComposer}>
134
172
  <div className={css.blocks} data-streaming={streaming || undefined}>
135
173
  {blocks.map((block, index) => <BlockBoundary key={block.kind === 'image' ? `image:${block.attachment.attachmentId}:${index}` : `${index}:${block.kind}`}>
136
- <Fragment>{renderSlotChain('dsh-better-display.block', { block, streaming, source }, { fallback: fallback(block, streaming, source, loadImage, fillComposer, holdFormatting, { startedAt, interrupted, liveText }, fileMentions) })}</Fragment>
174
+ <Fragment>{renderSlotChain('dsh-better-display.block', readerBlockOwner(block, streaming, source, { startedAt, interrupted, liveText }, holdFormatting), { fallback: fallback(block, streaming, source, loadImage, fillComposer, holdFormatting, { startedAt, interrupted, liveText }, `${reactId}:${index}:${block.kind}`, fileMentions) })}</Fragment>
137
175
  </BlockBoundary>)}
138
176
  </div>
139
177
  </ComposerFillContext.Provider>;
@@ -164,7 +202,8 @@ function MessageClock({ time }: { time: number }) {
164
202
 
165
203
  export function CopyAnswer({ blocks, onFork, metrics, extraActions }: { blocks: readonly AssistantBlock[]; onFork?: () => void; metrics?: BlockRenderProps['metrics']; extraActions?: ReactNode }) {
166
204
  const { receipt, copy } = useCopyReceipt();
167
- const text = blocks.filter((block): block is Extract<AssistantBlock, { kind: 'text' }> => block.kind === 'text').map(block => block.text).join('\n\n');
205
+ // Session text. A display transformer does not write these blocks.
206
+ const text = originalAnswerText(blocks);
168
207
  const endedAt = metrics?.endedAt;
169
208
  const hasMetrics = metrics !== undefined && (metrics.usage !== undefined || metrics.runMs !== undefined || endedAt !== undefined);
170
209
  if (!text.trim() && !onFork && !hasMetrics) return null;
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
- import type { ReactNode, RefObject } from 'react';
2
+ import type { ReactNode } from 'react';
3
3
  import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client';
4
4
  import type { ReaderProps } from './types.js';
5
5
  import type { LiveStep, LiveTurnItem } from './live-turn.js';
@@ -10,6 +10,7 @@ import { FoldSummaryText } from './LiveFold.js';
10
10
  import { DiffStat } from './DiffPanel.js';
11
11
  import { StreamMotionContext } from './streaming.js';
12
12
  import css from './Reader.module.css';
13
+ import { useSummaryLane } from './StickyLane.js';
13
14
 
14
15
  const FlowSnapshot = createContext<ChatSnapshot | null>(null);
15
16
  /** Public projection only. Descendants cannot leak live data through a frozen frame. */
@@ -44,6 +45,7 @@ function FlowCell({ hidden, instant, motion, rowKey, children, summary = false }
44
45
  hidden: boolean; instant: boolean; motion: boolean; rowKey: string; children: ReactNode; summary?: boolean;
45
46
  }) {
46
47
  const ref = useRef<HTMLDivElement>(null);
48
+ useSummaryLane(ref, summary);
47
49
  const [present, setPresent] = useState(!hidden);
48
50
  const previous = useRef(hidden);
49
51
  if (!hidden && !present) setPresent(true);
@@ -4,6 +4,7 @@ import { Disclosure } from './motion.js';
4
4
  import { FoldSummaryText } from './LiveFold.js';
5
5
  import { foldSummary } from './live-turn.js';
6
6
  import { DiffStat } from './DiffPanel.js';
7
+ import { usePublishedHeight } from './StickyLane.js';
7
8
  import type { LiveStep } from './live-turn.js';
8
9
 
9
10
  /** Keep process counts discoverable after the live-fold presentation retires. */
@@ -11,9 +12,10 @@ export function ClosedProcessSummary({ steps, open, onChange, controls }: {
11
12
  steps: readonly LiveStep[]; open: boolean; onChange: (value: boolean) => void; controls: string;
12
13
  }) {
13
14
  const button = useRef<HTMLButtonElement>(null);
15
+ const lane = usePublishedHeight('--reader-summary-height');
14
16
  if (!steps.length) return null;
15
17
  const summary = foldSummary(steps);
16
- return <div className={css.closedProcessSummary} data-reader-closed-summary={summary}>
18
+ return <div ref={lane} className={css.closedProcessSummary} data-reader-closed-summary={summary}>
17
19
  <div className={css.summaryRow}>
18
20
  <Disclosure open={open} onChange={onChange} controls={controls} buttonRef={button} ariaLabel="本轮过程详情"
19
21
  label={<FoldSummaryText summary={summary} motion={false} />} />
@@ -44,7 +44,6 @@ export const McpAppFrame = memo(function McpAppFrame({
44
44
  const iframeRef = useRef<HTMLIFrameElement>(null);
45
45
  const lastParamsRef = useRef<Record<string, unknown>>({});
46
46
  const [height, setHeight] = useState(() => Math.max(60, Math.min(2400, initialHeight)));
47
- const [ready, setReady] = useState(false);
48
47
  const [receipt, setReceipt] = useState<string | null>(null);
49
48
  const [lastPrompt, setLastPrompt] = useState<string | null>(null);
50
49
  const [reloadNonce, setReloadNonce] = useState(0);
@@ -81,7 +80,7 @@ export const McpAppFrame = memo(function McpAppFrame({
81
80
 
82
81
  const handleUserSubmit = useCallback((params: Record<string, unknown>) => {
83
82
  lastParamsRef.current = params;
84
- let summary = '';
83
+ let summary: string;
85
84
  if (typeof params.choice === 'string') {
86
85
  const desc = typeof params.desc === 'string' ? ` (${params.desc})` : '';
87
86
  summary = `选择: ${params.choice}${desc}`;
@@ -127,17 +126,13 @@ export const McpAppFrame = memo(function McpAppFrame({
127
126
  },
128
127
  };
129
128
  iframe.contentWindow?.postMessage(response, '*');
130
- setReady(true);
131
129
  return;
132
130
  }
133
131
 
134
- // 2. ui/ready or initialized notification
135
- if (data.method === 'ui/ready' || data.method === 'ui/notifications/initialized') {
136
- setReady(true);
137
- return;
138
- }
132
+ // ui/ready is a notification. The frame has no ready flag to flip.
133
+ if (data.method === 'ui/ready' || data.method === 'ui/notifications/initialized') return;
139
134
 
140
- // 3. ui/resize
135
+ // ui/resize
141
136
  if (data.method === 'ui/resize' && data.params?.height) {
142
137
  const h = Number(data.params.height);
143
138
  if (Number.isFinite(h) && h > 0) {
@@ -146,7 +141,7 @@ export const McpAppFrame = memo(function McpAppFrame({
146
141
  return;
147
142
  }
148
143
 
149
- // 4. ui/submit or ui/update-model-context
144
+ // ui/submit or ui/update-model-context
150
145
  if (data.method === 'ui/submit' || data.method === 'ui/update-model-context') {
151
146
  const params = (data.params as Record<string, unknown>) || {};
152
147
  handleUserSubmit(params);
@@ -209,13 +204,10 @@ export const McpAppFrame = memo(function McpAppFrame({
209
204
  },
210
205
  },
211
206
  }, '*');
212
-
213
- setReady(true);
214
207
  }, []);
215
208
 
216
209
  const handleReload = useCallback(() => {
217
210
  setReloadNonce(n => n + 1);
218
- setReady(false);
219
211
  setReceipt(null);
220
212
  }, []);
221
213
 
@@ -1,12 +1,12 @@
1
1
  import { useCallback, useState, useSyncExternalStore, type ReactNode } from 'react';
2
- import type { ChatConversationViewNode, ChatNodeOwnerProps, TurnTailOwnerProps, UseDisclosure } from '@deepseek-ai/dsh-client-ui-chat/client';
2
+ import type { AssistantActionOwnerProps, ChatConversationViewNode, ChatNode, ChatNodeOwnerProps, TurnTailOwnerProps, UseDisclosure } from '@deepseek-ai/dsh-client-ui-chat/client';
3
3
  import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
4
- import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
5
- import type { AssistantActionOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
4
+ import type { AssistantBlock, ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
6
5
  type MessageId = AssistantActionOwnerProps['messageId'];
7
6
  import { OFFICIAL_SEATS } from './official-slots.js';
8
7
  import type { BlockRenderProps } from './types.js';
9
8
  import { chatSeatProps } from './chat-seat.js';
9
+ import { Blocks } from './Blocks.js';
10
10
 
11
11
  export function OfficialActions({ official, messageId }: Pick<BlockRenderProps, 'official'> & { messageId?: MessageId }) {
12
12
  if (!official || !messageId) return null;
@@ -38,11 +38,8 @@ export function OfficialTool({ official, block, toolName, cwd, openFile, fallbac
38
38
  </div>;
39
39
  }
40
40
 
41
- export function OfficialNode({ node, fallback, ...render }: BlockRenderProps & { node: ChatConversationViewNode; fallback: ReactNode }) {
42
- const official = render.official;
43
- const [disclosureReset] = useState(() => createSnapshotStore(0));
44
- if (!official) return fallback;
45
- const owner: ChatNodeOwnerProps = {
41
+ function chatNodeOwner(render: BlockRenderProps, official: NonNullable<BlockRenderProps['official']>): ChatNodeOwnerProps {
42
+ return {
46
43
  cwd: render.cwd,
47
44
  openFile: render.openFile ?? (() => {}),
48
45
  openSkill: () => {},
@@ -54,14 +51,55 @@ export function OfficialNode({ node, fallback, ...render }: BlockRenderProps & {
54
51
  }),
55
52
  fileMentions: official.officialFileMentions,
56
53
  };
54
+ }
55
+
56
+ export function OfficialNode({ node, fallback, ...render }: BlockRenderProps & { node: ChatConversationViewNode; fallback: ReactNode }) {
57
+ const official = render.official;
58
+ const [disclosureReset] = useState(() => createSnapshotStore(0));
59
+ if (!official) return fallback;
57
60
  const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined;
58
61
  // The runtime node domain is open; the public SlotMap enumerates the known kinds.
59
62
  const renderNode = official.renderSlot as unknown as (key: string, owner: object, options: object) => ReactNode;
60
63
  return <div data-reader-official-node={node.kind} {...(node.kind === 'user' || node.kind === 'steering' ? chatSeatProps(node.kind, node.key) : {})}>
61
- {renderNode(OFFICIAL_SEATS.nodes, { ...owner, node }, { entryKey: node.kind, hookContext: { turnData: turn?.data, disclosureReset }, fallback })}
64
+ {renderNode(OFFICIAL_SEATS.nodes, { ...chatNodeOwner(render, official), node }, { entryKey: node.kind, hookContext: { turnData: turn?.data, disclosureReset }, fallback })}
62
65
  </div>;
63
66
  }
64
67
 
68
+ type AssistantBodyProps = BlockRenderProps & {
69
+ node: ChatNode<'assistant-step'>;
70
+ blocks: readonly AssistantBlock[];
71
+ streaming?: boolean;
72
+ holdFormatting?: boolean;
73
+ startedAt?: number;
74
+ interrupted?: boolean;
75
+ liveText?: boolean;
76
+ };
77
+
78
+ /**
79
+ * Body of one assistant segment. A mirrored assistant-step replacement draws
80
+ * it; Reader markdown is the fallback when no replacement is registered.
81
+ * The caller keeps reasoning blocks on the reading card.
82
+ */
83
+ export function OfficialAssistantBody({
84
+ node, blocks, streaming = false, holdFormatting = false, startedAt, interrupted = false, liveText = false, ...render
85
+ }: AssistantBodyProps) {
86
+ const official = render.official;
87
+ const [disclosureReset] = useState(() => createSnapshotStore(0));
88
+ const fallback = <Blocks {...render} blocks={blocks} streaming={streaming} holdFormatting={holdFormatting} startedAt={startedAt} interrupted={interrupted} liveText={liveText} />;
89
+ if (!official) return fallback;
90
+ const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined;
91
+ const sameBlocks = blocks.length === node.data.blocks.length && blocks.every((block, index) => block === node.data.blocks[index]);
92
+ const view = sameBlocks ? node : { ...node, data: { ...node.data, blocks } };
93
+ const renderNode = official.renderSlot as unknown as (key: string, owner: object, options: object) => ReactNode;
94
+ // groupPart matches the host split: response skips reasoning. The block
95
+ // slice is what a replacement that ignores groupPart actually receives.
96
+ return renderNode(OFFICIAL_SEATS.nodes, { ...chatNodeOwner(render, official), node: view, groupPart: 'response' }, {
97
+ entryKey: 'assistant-step',
98
+ hookContext: { turnData: turn?.data, disclosureReset },
99
+ fallback,
100
+ });
101
+ }
102
+
65
103
  export function OfficialTail({ official, owner, produced }: Pick<BlockRenderProps, 'official'> & { owner?: TurnTailOwnerProps; produced: readonly string[] }) {
66
104
  const tailOwner = owner && official ? { ...owner, openFile: official.officialPreviewFile, readerProducedPaths: produced } : undefined;
67
105
  return official && tailOwner ? <div data-reader-official-tail style={{ display: 'contents' }}>
@@ -8,13 +8,25 @@
8
8
  flex: 0 0 auto;
9
9
  overflow-anchor: auto;
10
10
  container-type: inline-size;
11
+ /* 40px at the host's default 14px content size. A smaller content size
12
+ must not shrink the lane under that floor. Positive font delta still
13
+ grows it, or the next sticky band lands on the previous one. */
14
+ --reader-lane-block: max(40px, calc(40px + var(--dsh-content-font-delta, 0px)));
15
+ /* Wallpaper themes clear --dsw-alias-bg-base, or leave it only partly
16
+ opaque. The token and the inner surface paint above Canvas, the page
17
+ surface colour (it follows color-scheme). An opaque stock token covers
18
+ that underlay, so the stock theme does not change. */
19
+ --reader-lane-plate: linear-gradient(var(--dsw-alias-bg-base), var(--dsw-alias-bg-base)), linear-gradient(var(--dsw-alias-bg-layer-1, transparent), var(--dsw-alias-bg-layer-1, transparent));
11
20
  }
12
21
  .column { max-width: var(--dsh-chat-content-width, 748px); width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: 22px; min-width: 0; }
13
- .toolbar { position: sticky; top: 0; display: flex; justify-content: flex-end; align-items: center; gap: 6px; min-height: 32px; padding: 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; background: var(--dsw-alias-bg-base); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
22
+ /* The column is only a programmatic focus-restore target (tabIndex -1), never a
23
+ Tab stop, so it must not draw a ring around the whole transcript. */
24
+ .column:focus, .column:focus-visible { outline: none; }
25
+ .toolbar { position: sticky; top: 0; display: flex; justify-content: flex-end; align-items: center; gap: 6px; min-height: 32px; padding: 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; background: var(--dsw-alias-bg-base); backdrop-filter: blur(12px); }
14
26
  /* 吸顶带第二车道:左「正在处理」+ 右「自动折叠」共享同一条 40px 水平基线。
15
27
  top / z-index 由下方 measured-flow 段统一定义:这一道永远钉在工具栏车道
16
28
  之下、且永远低于宿主输入卡(composerSeat z-index 7)。 */
17
- .turnProcessSticky { position: sticky; background: var(--dsw-alias-bg-base); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); min-height: 32px; display: flex; align-items: center; }
29
+ .turnProcessSticky { position: sticky; background: var(--dsw-alias-bg-base); backdrop-filter: blur(12px); min-height: 32px; display: flex; align-items: center; }
18
30
  .turnProcessSticky .disclosureButton { min-height: 32px; }
19
31
  /* 第二车道:折叠胶囊吸附在第一车道正下方,互不重叠 */
20
32
  .textButton, .historyButton, .iconButton, .jump, .disclosureButton, .imageOpen, .dialogClose { font: inherit; cursor: pointer; }
@@ -22,11 +34,26 @@
22
34
  .textButton:hover, .iconButton:hover, .disclosureButton:hover { background: var(--dsw-alias-interactive-bg-hover); }
23
35
  .root button:focus-visible, .root summary:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 3px; }
24
36
  .turn { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
25
- .turn + .turn { margin-top: 12px; }
26
- .userCluster { display: flex; flex-direction: column; align-self: flex-end; align-items: flex-end; max-width: min(525px, 82%); gap: 8px; min-width: 0; margin-bottom: 4px; }
27
- .userImages { display: flex; flex-direction: column; align-items: flex-end; max-width: 100%; min-width: 0; }
37
+ /* Keep pinned process lanes inside the process/answer block, before turn-tail UI. */
38
+ .turnStickyRegion { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
39
+ .turnStickyLanes { position: sticky; top: var(--reader-toolbar-height, var(--reader-lane-block, 40px)); z-index: 6; display: flex; flex-direction: column; min-width: 0; }
40
+ /* Every turn renders through its own retiring lane, so the sibling rule has to
41
+ reach one level in: the margin still lands between two consecutive turns. */
42
+ .turn + .turn, .retiringContent + .retiringContent > .turn { margin-top: 12px; }
43
+ /* Host .userStack caps itself with min(axis * 0.702, 82%). In the native column
44
+ that percentage resolves against a .userRow stretched across the column. The
45
+ slot outlet is display:contents, so the host row is this cluster's flex item.
46
+ Naming the official slot here misses the mirrored seat
47
+ (dsh-better-display.official.nodes/…), the row shrink-wraps to the text, and
48
+ the same 82% is applied again. The cluster fills the column. The direct child
49
+ of its own slot — the host row, not a nested image slot — stretches with it.
50
+ The host .userStack is left alone. Pending bubbles are not that row, so they
51
+ carry the same cap themselves. */
52
+ .userCluster { display: flex; flex-direction: column; align-self: stretch; align-items: flex-end; width: 100%; max-width: 100%; gap: 8px; min-width: 0; margin-bottom: 4px; }
53
+ .userCluster > [data-slot] > * { align-self: stretch; width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
54
+ .userImages { display: flex; flex-direction: column; align-items: flex-end; align-self: flex-end; width: fit-content; max-width: 100%; min-width: 0; }
28
55
  .userImages .imageFigure { align-self: flex-end; }
29
- .user { align-self: flex-end; max-width: 100%; padding: 10px 16px; border-radius: 22px; background: var(--dsw-specific-bubble); color: var(--dsw-alias-label-primary); line-height: 24px; overflow-wrap: anywhere; box-sizing: border-box; }
56
+ .user { align-self: flex-end; width: fit-content; max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%); margin-left: auto; padding: 10px 16px; border-radius: var(--dsw-radius-xl, 22px); background: var(--dsw-specific-bubble); color: var(--dsw-alias-label-primary); font-size: var(--dsh-content-font-size, 14px); line-height: calc(22px + var(--dsh-content-font-delta, 0px)); font-weight: 400; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; box-sizing: border-box; }
30
57
  .user .blocks { gap: 10px; }
31
58
  .answer { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }
32
59
  .readingText { min-width: 0; }
@@ -45,9 +72,9 @@
45
72
  .blocks > :last-child { margin-bottom: 0; }
46
73
  .blocks :global(.md-table-wide) { max-width: 100%; }
47
74
  .disclosure { min-width: 0; }
48
- .disclosureButton { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; border: 0; background: transparent; border-radius: 7px; color: var(--dsw-alias-label-tertiary); font-size: 14px; line-height: 24px; font-weight: 400; padding: 4px 6px; margin-left: -6px; text-align: left; }
75
+ .disclosureButton { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; border: 0; background: transparent; border-radius: 7px; color: var(--dsw-alias-label-tertiary); font-size: calc(14px + var(--dsh-content-font-delta, 0px)); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); font-weight: 400; padding: 4px 6px; margin-left: -6px; text-align: left; }
49
76
  .disclosure[data-reader-status-only] { display: flex; align-items: center; min-height: 32px; }
50
- .statusText { display: inline-flex; min-width: 0; max-width: 100%; font-size: 14px; line-height: 24px; font-weight: 400; color: var(--dsw-alias-label-secondary); }
77
+ .statusText { display: inline-flex; min-width: 0; max-width: 100%; font-size: calc(14px + var(--dsh-content-font-delta, 0px)); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); font-weight: 400; color: var(--dsw-alias-label-secondary); }
51
78
  .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
52
79
  /* Transitions.dev — Thinking states, supplied by the user. Native DSH colors;
53
80
  the hidden sizer shares this font and the short phases share a left edge. */
@@ -106,7 +133,20 @@
106
133
  rail width, same gutter, so a column of nested rows reads as one line. */
107
134
  .processContents { display: flow-root; border-left: 1px solid var(--dsw-alias-border-l2); padding: 3px 0 2px var(--reader-gutter, 16px); margin: 0 0 0 var(--reader-rail, 6px); color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 24px; }
108
135
  .processContents > :last-child { margin-bottom: 0; }
109
- .processCommentary { color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 24px; margin: 4px 0 8px var(--reader-rail, 6px); padding-left: var(--reader-gutter, 16px); border-left: 1px solid var(--dsw-alias-border-l2); }
136
+ /* Narration stays ordinary body text (the `.answer` typography) and carries no
137
+ left rule of its own. When collapsed reasoning/tool rows sat between two
138
+ narration blocks, the gap carries a compact folded-process summary in the
139
+ process-chain language: a 1px left rail plus the familiar `思考×N · 工具×K`
140
+ label with its chevron. The preceding narration cell drops its outer bottom
141
+ gap so the seam row owns exactly that space. */
142
+ .processSeamRow {
143
+ display: flex; align-items: center; min-height: 40px;
144
+ margin-left: var(--reader-rail, 6px); padding-left: var(--reader-gutter, 16px);
145
+ border-left: 1px solid var(--dsw-alias-border-l2); box-sizing: border-box;
146
+ }
147
+ .processSeamRow > .disclosure { display: flex; align-items: center; min-height: 24px; }
148
+ .processSeamRow .disclosureButton { min-height: 24px; padding-block: 2px; font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); }
149
+ .flowCell:has(> .flowCellInner > [data-reader-seam-after]) > .flowCellInner { padding-bottom: 0; }
110
150
  .processFrame { min-width: 0; }
111
151
  .toolActivity { min-width: 0; color: var(--dsw-alias-label-secondary); }
112
152
  .nativeToolRow { min-width: 0; }
@@ -473,7 +513,7 @@
473
513
  .morphDrawer { overflow: hidden; height: 0; transform-origin: top center; will-change: height, opacity, transform; }
474
514
  .liveFoldImpact { animation: liveFoldElastic 180ms cubic-bezier(0.2, 0, 0, 1) both; }
475
515
  @keyframes liveFoldElastic { 0% { transform: scale(1); } 50% { transform: scale(1.015); } 100% { transform: scale(1); } }
476
- .liveFoldContainer { position: sticky; top: calc(max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)) + var(--reader-sticky-lane, 40px) * 2); z-index: 4; background: var(--dsw-alias-bg-base); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); padding: 4px 0; margin-bottom: 6px; animation: liveFoldPulse 320ms cubic-bezier(0.16, 1, 0.3, 1) both; transform-origin: left center; will-change: transform, opacity; }
516
+ .liveFoldContainer { position: sticky; top: calc(var(--reader-toolbar-height, var(--reader-lane-block, 40px)) + var(--reader-status-height, var(--reader-lane-block, 40px)) + var(--reader-summary-height, var(--reader-lane-block, 40px))); z-index: 4; background: var(--dsw-alias-bg-base); backdrop-filter: blur(12px); padding: 4px 0; margin-bottom: 6px; animation: liveFoldPulse 320ms cubic-bezier(0.16, 1, 0.3, 1) both; transform-origin: left center; will-change: transform, opacity; }
477
517
  @keyframes liveFoldPulse { 0% { transform: translateY(-4px) scale(0.98); opacity: 0.8; } 50% { transform: translateY(0) scale(1.02); opacity: 1; } 100% { transform: translateY(0) scale(1); opacity: 1; } }
478
518
  [data-motion=off] .liveFoldContainer { animation: none !important; }
479
519
  @media (prefers-reduced-motion: reduce) { .liveFoldContainer { animation: none !important; } }
@@ -511,7 +551,7 @@
511
551
  [data-motion=off] .turnStatusLabel { animation: none !important; color: var(--dsw-static-deepseek-500, #4d6bfe) !important; -webkit-text-fill-color: var(--dsw-static-deepseek-500, #4d6bfe) !important; }
512
552
  @media (prefers-reduced-motion: reduce) { .turnStatusLabel { animation: none !important; color: var(--dsw-static-deepseek-500, #4d6bfe) !important; -webkit-text-fill-color: var(--dsw-static-deepseek-500, #4d6bfe) !important; } }
513
553
  @media (pointer: coarse) { .disclosureButton { min-height: 44px; } }
514
- @media (max-width: 600px) { .root { padding-inline: 20px; } .column { gap: 18px; } .toolbar { font-size: 11px; } .user { max-width: 90%; } }
554
+ @media (max-width: 600px) { .root { padding-inline: 20px; } .column { gap: 18px; } .toolbar { font-size: 11px; } }
515
555
 
516
556
  /* Measured flow owns geometry; steps never change React parents when folded. */
517
557
  .choreographedFlow { display: flex; flex-direction: column; min-width: 0; overflow-anchor: none; }
@@ -532,7 +572,7 @@
532
572
  input card. Only the top toolbar lane may use 7: it pins to the scrollport's
533
573
  first lane and its containing block always ends above the seat, so it can
534
574
  never reach the band, and 7 keeps it above the host's code-block banners. */
535
- .toolbar { width: 100%; max-width: 100%; align-self: stretch; box-sizing: border-box; min-height: 40px; z-index: 7; border-bottom: 0; backdrop-filter: none; -webkit-backdrop-filter: none; }
575
+ .toolbar { width: 100%; max-width: 100%; align-self: stretch; box-sizing: border-box; min-height: var(--reader-lane-block, 40px); z-index: 7; border-bottom: 0; backdrop-filter: none; }
536
576
  /* Every sticky lane used to pin to the SAME top: the status row, the folded
537
577
  summary row and the live-fold container all sat at
538
578
  max(toolbar-height, status-height) with z-index 6. Three siblings competing
@@ -540,7 +580,7 @@
540
580
  内容被盖住、看不见" was: the disclosure opened, and the row that followed
541
581
  pinned straight on top of it. Give each lane its own band instead, stacked
542
582
  from the measured top: status first, then the summary, then the fold body. */
543
- .turnProcessSticky { box-sizing: border-box; width: calc(100% - var(--reader-control-width, 148px)); top: max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)); min-height: 40px; padding-right: 0; z-index: 6; border-bottom: 1px solid transparent; background-clip: padding-box; backdrop-filter: none; -webkit-backdrop-filter: none; }
583
+ .turnProcessSticky { box-sizing: border-box; width: calc(100% - var(--reader-control-width, 148px)); top: var(--reader-toolbar-height, var(--reader-lane-block, 40px)); min-height: var(--reader-lane-block, 40px); padding-right: 0; z-index: 6; border-bottom: 1px solid transparent; background-clip: padding-box; backdrop-filter: none; }
544
584
  /* A pinned lane's in-flow gap stays visible as an uncovered scrollport strip
545
585
  while the lane itself paints opaque. Cast each plate upward over its strip
546
586
  (the .turn 14px gap / the prior cell's 16px flowCellInner padding) so the
@@ -549,11 +589,17 @@
549
589
  toolbar's control group, which leaves the same kind of strip there; paint
550
590
  it too. The toolbar is z-index 7, above the lane's 6, so its buttons still
551
591
  render on top of the extension. */
552
- .turnProcessSticky { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base), var(--reader-control-width, 148px) 0 0 var(--dsw-alias-bg-base); }
592
+ .turnProcessSticky { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base), var(--reader-control-width, 148px) 0 0 var(--dsw-alias-bg-base), 0 -14px 0 var(--dsw-alias-bg-layer-1, transparent), var(--reader-control-width, 148px) 0 0 var(--dsw-alias-bg-layer-1, transparent), 0 -14px 0 Canvas, var(--reader-control-width, 148px) 0 0 Canvas; }
593
+ .turnStickyLanes .turnProcessSticky, .turnStickyLanes .closedProcessSummary { position: static; top: auto; z-index: auto; }
553
594
  /* The same summary row stays in flow until it reaches the measured top lane. */
554
- .flowCell[data-flow-summary], .closedProcessSummary { position: sticky; top: calc(max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)) + var(--reader-sticky-lane, 40px)); z-index: 5; overflow: visible; box-sizing: border-box; min-height: 40px; padding-block: 4px; background: var(--dsw-alias-bg-base); border-bottom: 0; }
555
- .flowCell[data-flow-summary] { box-shadow: 0 -16px 0 var(--dsw-alias-bg-base); }
556
- .closedProcessSummary { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base); }
595
+ .flowCell[data-flow-summary], .closedProcessSummary { position: sticky; top: calc(var(--reader-toolbar-height, var(--reader-lane-block, 40px)) + var(--reader-status-height, var(--reader-lane-block, 40px))); z-index: 5; overflow: visible; box-sizing: border-box; min-height: var(--reader-lane-block, 40px); padding-block: 4px; background: var(--dsw-alias-bg-base); border-bottom: 0; }
596
+ /* Keep the official CodeBlock banner's internal z-index inside its wrapper so Reader lanes stay above it. */
597
+ .root :global(.md-code-block) { z-index: 0; }
598
+ /* Keep native code actions below the pinned Reader lanes. */
599
+ .turnStickyRegion:has(> .turnStickyLanes > .closedProcessSummary), .turnStickyRegion:has(.flowCell[data-flow-summary]) { --reader-code-summary-height: var(--reader-summary-height, var(--reader-lane-block, 40px)); }
600
+ .root :global(.md-code-block > :has(+ [data-code-block-content])) { top: calc(max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 0px)) + var(--reader-status-height, 0px) + var(--reader-code-summary-height, 0px)); }
601
+ .flowCell[data-flow-summary] { box-shadow: 0 -16px 0 var(--dsw-alias-bg-base), 0 -16px 0 var(--dsw-alias-bg-layer-1, transparent), 0 -16px 0 Canvas; }
602
+ .closedProcessSummary { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base), 0 -14px 0 var(--dsw-alias-bg-layer-1, transparent), 0 -14px 0 Canvas; }
557
603
  .foldControl { display: inline-flex; align-items: center; gap: 0; padding: 2px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform); }
558
604
  .foldStop { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); padding: 2px 8px; border-radius: 6px; white-space: nowrap; font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }
559
605
  .foldStop[data-on] { background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); }
@@ -567,6 +613,19 @@
567
613
  .thinkSizer { white-space: normal; overflow-wrap: anywhere; }
568
614
  .thinkText { white-space: normal; overflow-wrap: anywhere; overflow: visible; text-overflow: clip; }
569
615
 
616
+ /* Theme layers sit on an opaque page surface. A cleared token, or one at
617
+ 10% opacity, composites over Canvas instead of the text colour, and the
618
+ heading underneath cannot show through. */
619
+ .toolbar,
620
+ .turnStickyLanes,
621
+ .turnProcessSticky,
622
+ .liveFoldContainer,
623
+ .flowCell[data-flow-summary],
624
+ .closedProcessSummary {
625
+ background-color: Canvas;
626
+ background-image: var(--reader-lane-plate);
627
+ background-repeat: no-repeat;
628
+ }
570
629
  /* Skin mode is opt-in, and it has one job: some readers run a skinned host
571
630
  (wallpaper / theme), and the reader's own opaque rows hide that skin. On =
572
631
  every surface the reader paints becomes a thin token-derived wash and the
@@ -578,11 +637,34 @@
578
637
  .root[data-reader-glass] .liveFoldContainer {
579
638
  background: transparent;
580
639
  backdrop-filter: blur(16px);
581
- -webkit-backdrop-filter: blur(16px);
582
640
  border: 0;
583
641
  border-radius: 0;
584
642
  box-shadow: none;
585
643
  }
644
+ /* Keep the skin visible between controls without changing the sticky lanes. */
645
+ .root[data-reader-glass] .toolbar,
646
+ .root[data-reader-glass] .turnProcessSticky {
647
+ backdrop-filter: none;
648
+ }
649
+ .root[data-reader-glass] .toolbar {
650
+ pointer-events: none;
651
+ }
652
+ .root[data-reader-glass] .toolbar > .textButton,
653
+ .root[data-reader-glass] .turnProcessSticky .disclosureButton {
654
+ pointer-events: auto;
655
+ padding: 2px 10px;
656
+ border-radius: 999px;
657
+ background: color-mix(in srgb, var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, .7)) 80%, transparent);
658
+ backdrop-filter: blur(12px);
659
+ }
660
+ .root[data-reader-glass] .turnProcessSticky .disclosureButton {
661
+ margin-left: 0;
662
+ max-width: 100%;
663
+ }
664
+ .root[data-reader-glass] .toolbar > .textButton:hover,
665
+ .root[data-reader-glass] .turnProcessSticky .disclosureButton:hover {
666
+ background: var(--dsw-alias-interactive-bg-hover);
667
+ }
586
668
  /* The z-index ladder is mode-independent: skin mode only drops the plate, so
587
669
  a glass reader keeps exactly the same footer precedence as an opaque one. */
588
670
  .root[data-reader-glass] .flowCell[data-flow-summary],
@@ -597,11 +679,13 @@
597
679
  padding-left: 10px;
598
680
  border-radius: 0;
599
681
  }
682
+ .root[data-reader-glass] .turnStickyLanes { display: contents; position: static; top: auto; z-index: auto; }
683
+ .root[data-reader-glass] .turnStickyRegion { --reader-code-summary-height: 0px; }
684
+ .root[data-reader-glass] .turnStickyLanes .turnProcessSticky { position: sticky; top: var(--reader-toolbar-height, var(--reader-lane-block, 40px)); z-index: 6; }
600
685
  /* User bubble becomes a sleek translucent frosted pill in skin mode */
601
686
  .root[data-reader-glass] .user {
602
687
  background: color-mix(in srgb, var(--dsw-specific-bubble, rgba(0,0,0,.08)) 25%, transparent);
603
688
  backdrop-filter: blur(12px);
604
- -webkit-backdrop-filter: blur(12px);
605
689
  border: 1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.3)) 30%, transparent);
606
690
  }
607
691
  /* Skin mode paints no plates of the reader's own: cards, panels and frames go
@@ -625,7 +709,6 @@
625
709
  .root[data-reader-glass] .diffOverlay {
626
710
  background: color-mix(in srgb, var(--dsw-alias-bg-module-platform, rgba(127,127,127,.1)) 20%, transparent);
627
711
  backdrop-filter: blur(16px);
628
- -webkit-backdrop-filter: blur(16px);
629
712
  }
630
713
  .root[data-reader-glass] .diffScrollArea {
631
714
  background: transparent;
@@ -642,7 +725,6 @@
642
725
  background: color-mix(in srgb, var(--dsw-alias-markdown-code-block, rgba(0,0,0,.06)) 25%, transparent) !important;
643
726
  border: 1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.2)) 30%, transparent);
644
727
  backdrop-filter: blur(12px);
645
- -webkit-backdrop-filter: blur(12px);
646
728
  }
647
729
  /* The fold control rides a sticky row: it must not paint a plate of its own. */
648
730
  .root[data-reader-glass] .foldControl {