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