dsh-better-display 0.3.8 → 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 (182) hide show
  1. package/CHANGELOG.md +11 -0
  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 +48 -19
  8. package/docs/openapi.yaml +147 -0
  9. package/docs/runbooks/blank-reader.md +39 -0
  10. package/lib/client.js +1417 -490
  11. package/lib/client.js.map +1 -1
  12. package/lib/dsh-better-display.js +378 -144
  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 +43 -71
  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.js.map +1 -1
  44. package/lib/types/client/index.d.ts +3 -1
  45. package/lib/types/client/index.d.ts.map +1 -1
  46. package/lib/types/client/index.js +6 -0
  47. package/lib/types/client/index.js.map +1 -1
  48. package/lib/types/client/live-turn.d.ts +17 -5
  49. package/lib/types/client/live-turn.d.ts.map +1 -1
  50. package/lib/types/client/live-turn.js +106 -76
  51. package/lib/types/client/live-turn.js.map +1 -1
  52. package/lib/types/client/motion.d.ts +26 -2
  53. package/lib/types/client/motion.d.ts.map +1 -1
  54. package/lib/types/client/motion.js +136 -10
  55. package/lib/types/client/motion.js.map +1 -1
  56. package/lib/types/client/official-slots.d.ts +9 -0
  57. package/lib/types/client/official-slots.d.ts.map +1 -1
  58. package/lib/types/client/official-slots.js +22 -7
  59. package/lib/types/client/official-slots.js.map +1 -1
  60. package/lib/types/client/projection.d.ts +15 -2
  61. package/lib/types/client/projection.d.ts.map +1 -1
  62. package/lib/types/client/projection.js +16 -4
  63. package/lib/types/client/projection.js.map +1 -1
  64. package/lib/types/client/rewind-cut.d.ts +131 -0
  65. package/lib/types/client/rewind-cut.d.ts.map +1 -0
  66. package/lib/types/client/rewind-cut.js +321 -0
  67. package/lib/types/client/rewind-cut.js.map +1 -0
  68. package/lib/types/client/text-transform.d.ts +93 -0
  69. package/lib/types/client/text-transform.d.ts.map +1 -0
  70. package/lib/types/client/text-transform.js +183 -0
  71. package/lib/types/client/text-transform.js.map +1 -0
  72. package/lib/types/client/timeline.d.ts.map +1 -1
  73. package/lib/types/client/timeline.js.map +1 -1
  74. package/lib/types/client/tool-activity.d.ts +9 -2
  75. package/lib/types/client/tool-activity.d.ts.map +1 -1
  76. package/lib/types/client/tool-activity.js +42 -2
  77. package/lib/types/client/tool-activity.js.map +1 -1
  78. package/lib/types/client/turn-slots.d.ts +29 -0
  79. package/lib/types/client/turn-slots.d.ts.map +1 -0
  80. package/lib/types/client/turn-slots.js +62 -0
  81. package/lib/types/client/turn-slots.js.map +1 -0
  82. package/lib/types/client/types.d.ts +18 -0
  83. package/lib/types/client/types.d.ts.map +1 -1
  84. package/lib/types/dsh-better-display.d.ts +4 -1
  85. package/lib/types/dsh-better-display.d.ts.map +1 -1
  86. package/lib/types/dsh-better-display.js +56 -7
  87. package/lib/types/dsh-better-display.js.map +1 -1
  88. package/lib/types/flags.d.ts +7 -0
  89. package/lib/types/flags.d.ts.map +1 -0
  90. package/lib/types/flags.js +37 -0
  91. package/lib/types/flags.js.map +1 -0
  92. package/lib/types/log.d.ts +17 -0
  93. package/lib/types/log.d.ts.map +1 -0
  94. package/lib/types/log.js +122 -0
  95. package/lib/types/log.js.map +1 -0
  96. package/lib/types/metrics.d.ts +18 -0
  97. package/lib/types/metrics.d.ts.map +1 -0
  98. package/lib/types/metrics.js +35 -0
  99. package/lib/types/metrics.js.map +1 -0
  100. package/package.json +36 -5
  101. package/scripts/changelog.mjs +20 -0
  102. package/scripts/check-agents-md.mjs +67 -0
  103. package/scripts/check-bundle-size.mjs +38 -0
  104. package/scripts/check-dead-flags.mjs +55 -0
  105. package/scripts/check-todo-debt.mjs +69 -0
  106. package/scripts/security-audit-report.mjs +92 -0
  107. package/scripts/verify-commentary-visible.mjs +627 -0
  108. package/src/client/AssistantNode.tsx +72 -0
  109. package/src/client/Blocks.tsx +49 -10
  110. package/src/client/ChoreographedFlow.tsx +3 -1
  111. package/src/client/ClosedProcessSummary.tsx +3 -1
  112. package/src/client/McpAppFrame.tsx +5 -13
  113. package/src/client/OfficialContent.tsx +47 -9
  114. package/src/client/Reader.module.css +68 -18
  115. package/src/client/Reader.tsx +36 -76
  116. package/src/client/SettingsSection.tsx +1 -7
  117. package/src/client/StickyLane.tsx +81 -2
  118. package/src/client/ToolActivity.tsx +1 -1
  119. package/src/client/index.tsx +8 -1
  120. package/src/client/live-turn.ts +100 -69
  121. package/src/client/motion.tsx +135 -7
  122. package/src/client/official-slots.tsx +23 -7
  123. package/src/client/projection.ts +16 -4
  124. package/src/client/rewind-cut.ts +340 -0
  125. package/src/client/text-transform.ts +265 -0
  126. package/src/client/timeline.ts +0 -2
  127. package/src/client/tool-activity.ts +49 -2
  128. package/src/client/turn-slots.ts +80 -0
  129. package/src/client/types.ts +19 -0
  130. package/src/dsh-better-display.ts +58 -7
  131. package/src/flags.ts +40 -0
  132. package/src/log.ts +137 -0
  133. package/src/metrics.ts +50 -0
  134. package/tests/browser-commentary.tsx +191 -0
  135. package/tests/chat-flow-hook.test.ts +1 -1
  136. package/tests/cloud-gate.test.ts +1 -1
  137. package/tests/context-tool-update.test.ts +1 -1
  138. package/tests/conversation-scroll.test.ts +1 -1
  139. package/tests/deliverables.test.ts +1 -1
  140. package/tests/entry.test.ts +1 -1
  141. package/tests/fixtures/reader-lane-owners.tsx +46 -0
  142. package/tests/fixtures/reader-official-users.tsx +64 -0
  143. package/tests/fixtures/reader-theme-overlap.html +80 -0
  144. package/tests/fixtures/reader-user-bubble.tsx +222 -0
  145. package/tests/flags.test.ts +66 -0
  146. package/tests/fold-choreography.test.ts +32 -8
  147. package/tests/fold-intensity.test.ts +11 -6
  148. package/tests/footer-precedence.test.ts +1 -1
  149. package/tests/history-word-regression.test.ts +1 -1
  150. package/tests/live-turn.test.ts +125 -54
  151. package/tests/log.test.ts +99 -0
  152. package/tests/mcp-apps.test.ts +1 -1
  153. package/tests/message-chrome.test.ts +1 -1
  154. package/tests/metrics.test.ts +52 -0
  155. package/tests/official-slots.test.ts +41 -2
  156. package/tests/open-file.test.ts +1 -1
  157. package/tests/pending-submission.test.ts +12 -1
  158. package/tests/pinned-selection.test.ts +16 -0
  159. package/tests/platform-media.test.ts +1 -1
  160. package/tests/projection.test.ts +66 -3
  161. package/tests/reader-glass-css.test.ts +1 -1
  162. package/tests/reader-official-users.test.ts +11 -1
  163. package/tests/reader-sticky-surface.test.ts +377 -0
  164. package/tests/reader-theme-lanes.test.ts +54 -0
  165. package/tests/reader-theme-overlap.test.ts +151 -0
  166. package/tests/reader-user-bubble.test.ts +137 -0
  167. package/tests/reasoning-follow.test.ts +1 -1
  168. package/tests/retiring-content.test.ts +98 -6
  169. package/tests/reveal.test.ts +3 -3
  170. package/tests/rewind-cut.test.ts +1090 -0
  171. package/tests/settings-copy.test.ts +1 -1
  172. package/tests/skill-status.test.ts +1 -1
  173. package/tests/stock-install.test.ts +1 -1
  174. package/tests/stream-buffer.test.ts +1 -1
  175. package/tests/styles.test.ts +30 -3
  176. package/tests/terminal-input-preview.test.ts +1 -1
  177. package/tests/text-transform.test.ts +272 -0
  178. package/tests/timeline.test.ts +1 -1
  179. package/tests/tool-clock.test.ts +1 -1
  180. package/tests/tool-diff.test.ts +1 -1
  181. package/tests/waiting-clock.test.ts +1 -1
  182. package/tests/word-timeline.test.ts +1 -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,8 +8,20 @@
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; }
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; }
13
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 段统一定义:这一道永远钉在工具栏车道
@@ -24,12 +36,24 @@
24
36
  .turn { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
25
37
  /* Keep pinned process lanes inside the process/answer block, before turn-tail UI. */
26
38
  .turnStickyRegion { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
27
- .turnStickyLanes { position: sticky; top: max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)); z-index: 6; display: flex; flex-direction: column; min-width: 0; }
28
- .turn + .turn { margin-top: 12px; }
29
- .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; }
30
- .userImages { display: flex; flex-direction: column; align-items: flex-end; max-width: 100%; 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; }
31
55
  .userImages .imageFigure { align-self: flex-end; }
32
- .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; }
33
57
  .user .blocks { gap: 10px; }
34
58
  .answer { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }
35
59
  .readingText { min-width: 0; }
@@ -48,9 +72,9 @@
48
72
  .blocks > :last-child { margin-bottom: 0; }
49
73
  .blocks :global(.md-table-wide) { max-width: 100%; }
50
74
  .disclosure { min-width: 0; }
51
- .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; }
52
76
  .disclosure[data-reader-status-only] { display: flex; align-items: center; min-height: 32px; }
53
- .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); }
54
78
  .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
55
79
  /* Transitions.dev — Thinking states, supplied by the user. Native DSH colors;
56
80
  the hidden sizer shares this font and the short phases share a left edge. */
@@ -109,7 +133,20 @@
109
133
  rail width, same gutter, so a column of nested rows reads as one line. */
110
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; }
111
135
  .processContents > :last-child { margin-bottom: 0; }
112
- .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; }
113
150
  .processFrame { min-width: 0; }
114
151
  .toolActivity { min-width: 0; color: var(--dsw-alias-label-secondary); }
115
152
  .nativeToolRow { min-width: 0; }
@@ -476,7 +513,7 @@
476
513
  .morphDrawer { overflow: hidden; height: 0; transform-origin: top center; will-change: height, opacity, transform; }
477
514
  .liveFoldImpact { animation: liveFoldElastic 180ms cubic-bezier(0.2, 0, 0, 1) both; }
478
515
  @keyframes liveFoldElastic { 0% { transform: scale(1); } 50% { transform: scale(1.015); } 100% { transform: scale(1); } }
479
- .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); 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; }
480
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; } }
481
518
  [data-motion=off] .liveFoldContainer { animation: none !important; }
482
519
  @media (prefers-reduced-motion: reduce) { .liveFoldContainer { animation: none !important; } }
@@ -514,7 +551,7 @@
514
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; }
515
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; } }
516
553
  @media (pointer: coarse) { .disclosureButton { min-height: 44px; } }
517
- @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; } }
518
555
 
519
556
  /* Measured flow owns geometry; steps never change React parents when folded. */
520
557
  .choreographedFlow { display: flex; flex-direction: column; min-width: 0; overflow-anchor: none; }
@@ -535,7 +572,7 @@
535
572
  input card. Only the top toolbar lane may use 7: it pins to the scrollport's
536
573
  first lane and its containing block always ends above the seat, so it can
537
574
  never reach the band, and 7 keeps it above the host's code-block banners. */
538
- .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; }
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; }
539
576
  /* Every sticky lane used to pin to the SAME top: the status row, the folded
540
577
  summary row and the live-fold container all sat at
541
578
  max(toolbar-height, status-height) with z-index 6. Three siblings competing
@@ -543,7 +580,7 @@
543
580
  内容被盖住、看不见" was: the disclosure opened, and the row that followed
544
581
  pinned straight on top of it. Give each lane its own band instead, stacked
545
582
  from the measured top: status first, then the summary, then the fold body. */
546
- .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; }
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; }
547
584
  /* A pinned lane's in-flow gap stays visible as an uncovered scrollport strip
548
585
  while the lane itself paints opaque. Cast each plate upward over its strip
549
586
  (the .turn 14px gap / the prior cell's 16px flowCellInner padding) so the
@@ -552,17 +589,17 @@
552
589
  toolbar's control group, which leaves the same kind of strip there; paint
553
590
  it too. The toolbar is z-index 7, above the lane's 6, so its buttons still
554
591
  render on top of the extension. */
555
- .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; }
556
593
  .turnStickyLanes .turnProcessSticky, .turnStickyLanes .closedProcessSummary { position: static; top: auto; z-index: auto; }
557
594
  /* The same summary row stays in flow until it reaches the measured top lane. */
558
- .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; }
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; }
559
596
  /* Keep the official CodeBlock banner's internal z-index inside its wrapper so Reader lanes stay above it. */
560
597
  .root :global(.md-code-block) { z-index: 0; }
561
598
  /* Keep native code actions below the pinned Reader lanes. */
562
- .turnStickyRegion:has(> .turnStickyLanes > .closedProcessSummary), .turnStickyRegion:has(.flowCell[data-flow-summary]) { --reader-code-summary-height: var(--reader-sticky-lane, 40px); }
599
+ .turnStickyRegion:has(> .turnStickyLanes > .closedProcessSummary), .turnStickyRegion:has(.flowCell[data-flow-summary]) { --reader-code-summary-height: var(--reader-summary-height, var(--reader-lane-block, 40px)); }
563
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)); }
564
- .flowCell[data-flow-summary] { box-shadow: 0 -16px 0 var(--dsw-alias-bg-base); }
565
- .closedProcessSummary { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base); }
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; }
566
603
  .foldControl { display: inline-flex; align-items: center; gap: 0; padding: 2px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform); }
567
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; }
568
605
  .foldStop[data-on] { background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); }
@@ -576,6 +613,19 @@
576
613
  .thinkSizer { white-space: normal; overflow-wrap: anywhere; }
577
614
  .thinkText { white-space: normal; overflow-wrap: anywhere; overflow: visible; text-overflow: clip; }
578
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
+ }
579
629
  /* Skin mode is opt-in, and it has one job: some readers run a skinned host
580
630
  (wallpaper / theme), and the reader's own opaque rows hide that skin. On =
581
631
  every surface the reader paints becomes a thin token-derived wash and the
@@ -631,7 +681,7 @@
631
681
  }
632
682
  .root[data-reader-glass] .turnStickyLanes { display: contents; position: static; top: auto; z-index: auto; }
633
683
  .root[data-reader-glass] .turnStickyRegion { --reader-code-summary-height: 0px; }
634
- .root[data-reader-glass] .turnStickyLanes .turnProcessSticky { position: sticky; top: max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)); z-index: 6; }
684
+ .root[data-reader-glass] .turnStickyLanes .turnProcessSticky { position: sticky; top: var(--reader-toolbar-height, var(--reader-lane-block, 40px)); z-index: 6; }
635
685
  /* User bubble becomes a sleek translucent frosted pill in skin mode */
636
686
  .root[data-reader-glass] .user {
637
687
  background: color-mix(in srgb, var(--dsw-specific-bubble, rgba(0,0,0,.08)) 25%, transparent);