dsh-better-display 0.3.6 → 0.3.8
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 +16 -0
- package/docs/official-rendering-bridge.md +16 -0
- package/lib/client.js +564 -394
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +139 -48
- package/lib/types/client/OfficialContent.d.ts.map +1 -1
- package/lib/types/client/OfficialContent.js +2 -1
- 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 +96 -20
- package/lib/types/client/Reader.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/chat-seat.d.ts +3 -0
- package/lib/types/client/chat-seat.d.ts.map +1 -0
- package/lib/types/client/chat-seat.js +9 -0
- package/lib/types/client/chat-seat.js.map +1 -0
- 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.map +1 -1
- package/lib/types/client/index.js +9 -1
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +9 -1
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/official-slots.d.ts.map +1 -1
- package/lib/types/client/official-slots.js +6 -3
- 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/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/tool-activity.d.ts +2 -2
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +12 -7
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/dsh-better-display.d.ts.map +1 -1
- package/lib/types/dsh-better-display.js +2 -39
- package/lib/types/dsh-better-display.js.map +1 -1
- 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 +1 -1
- package/src/client/OfficialContent.tsx +2 -1
- package/src/client/Reader.module.css +41 -9
- package/src/client/Reader.tsx +108 -32
- package/src/client/ToolActivity.tsx +9 -5
- package/src/client/chat-seat.ts +10 -0
- package/src/client/deliverables.ts +1 -0
- package/src/client/index.tsx +10 -1
- package/src/client/motion.tsx +8 -1
- package/src/client/official-slots.tsx +6 -3
- package/src/client/open-file.ts +2 -8
- package/src/client/styles.ts +22 -0
- package/src/client/tool-activity.ts +18 -7
- package/src/dsh-better-display.ts +2 -38
- package/src/reveal.ts +90 -0
- package/src/workspace-path.ts +4 -0
- package/tests/chat-flow-hook.test.ts +13 -0
- package/tests/fixtures/reader-official-users.tsx +170 -0
- package/tests/footer-precedence.test.ts +8 -0
- package/tests/reader-glass-css.test.ts +28 -0
- package/tests/reader-official-users.test.ts +81 -0
- package/tests/retiring-content.test.ts +137 -0
- package/tests/reveal.test.ts +128 -0
- package/tests/stock-install.test.ts +8 -0
- package/tests/styles.test.ts +48 -0
- package/tests/terminal-input-preview.test.ts +113 -0
- package/tsdown.config.ts +55 -0
|
@@ -17,6 +17,7 @@ import css from './Reader.module.css';
|
|
|
17
17
|
const LABEL: Record<ToolPhase, string> = { preparing: '输入生成中', running: '执行中', returned: '已返回', succeeded: '已完成', failed: '失败', interrupted: '已中断' };
|
|
18
18
|
const ICONS = { write: IconEditOutlineRegular, read: IconBrowseOutlineRegular, terminal: IconApiOutlineRegular, search: IconSearchOutlineRegular, web: IconSearchOutlineRegular, code: IconApiOutlineRegular, other: IconSparkleRegular } satisfies Record<ToolCategory, unknown>;
|
|
19
19
|
const number = new Intl.NumberFormat('zh-CN');
|
|
20
|
+
const preparingTerminalLabels = { ...terminalBlockLabels, running: '正在生成命令' };
|
|
20
21
|
const language = (path: string | undefined) => path?.split('.').at(-1);
|
|
21
22
|
const duration = (ms: number) => ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} 秒`;
|
|
22
23
|
|
|
@@ -28,14 +29,17 @@ function generatedInput(content: string, target: string | undefined, preparing:
|
|
|
28
29
|
</div>;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function InputView({ model,
|
|
32
|
+
function InputView({ model, phase, fillComposer }: { model: ReturnType<typeof activitySummary>; phase: ToolPhase; fillComposer: BlockRenderProps['fillComposer'] }) {
|
|
33
|
+
const preparing = phase === 'preparing';
|
|
34
|
+
const inProgress = preparing || phase === 'running';
|
|
32
35
|
if ((model.name === 'render_ui' || model.name === 'show_widget') && typeof model.args?.html === 'string') {
|
|
33
36
|
return preparing
|
|
34
37
|
? <StreamingMcpAppPlaceholder title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} />
|
|
35
38
|
: <McpAppFrame html={model.args.html as string} title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} fillComposer={fillComposer} />;
|
|
36
39
|
}
|
|
37
40
|
if (model.content) return generatedInput(model.content, model.target, preparing);
|
|
38
|
-
if (model.command) return <div data-reader-tool-terminal><p className={css.toolDetailNote}>{preparing ? '正在生成命令 · 尚未执行' : '提交的命令'}</p><TerminalBlock command={model.command} cwd={model.cwd} labels={terminalBlockLabels} /></div>;
|
|
41
|
+
if (model.command) return <div data-reader-tool-terminal><p className={css.toolDetailNote}>{preparing ? '正在生成命令 · 尚未执行' : '提交的命令'}</p><TerminalBlock command={model.command} cwd={model.cwd} running={inProgress} labels={preparing ? preparingTerminalLabels : terminalBlockLabels} /></div>;
|
|
42
|
+
if (model.category === 'terminal' && preparing && model.argsState === 'partial') return <p className={css.toolDetailNote} data-reader-tool-command-pending>命令仍在生成,收到可识别的命令文本后会显示预览。</p>;
|
|
39
43
|
return <JsonTree data={model.args} label={preparing ? '已收到的输入字段' : '工具输入'} labels={jsonTreeLabels} />;
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -95,11 +99,11 @@ function ResultFallback({ entry, model, phase, ...render }: BlockRenderProps & {
|
|
|
95
99
|
const block = entry.block;
|
|
96
100
|
if (!block || !('kind' in block)) return <>
|
|
97
101
|
<p className={css.toolDetailNote}>{phase === 'interrupted' ? '已中断,没有工具结果。已生成的输入仍可查看。' : phase === 'preparing' ? '模型正在生成工具输入,工具还未开始执行。' : '工具已开始执行,正在等待结果。'}</p>
|
|
98
|
-
<InputView model={model}
|
|
102
|
+
<InputView model={model} phase={phase} fillComposer={render.fillComposer} />
|
|
99
103
|
</>;
|
|
100
104
|
const meta = objectValue(block.meta);
|
|
101
105
|
const text = block.content.filter(item => item.type === 'text').map(item => item.text).join('\n');
|
|
102
|
-
if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model}
|
|
106
|
+
if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model} phase={phase} fillComposer={render.fillComposer} /><pre className={css.toolRaw}>{text}</pre></>;
|
|
103
107
|
|
|
104
108
|
if (model.category === 'terminal') {
|
|
105
109
|
const facts = executionFacts(block);
|
|
@@ -247,7 +251,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
|
|
|
247
251
|
<div ref={panel} id={`${detailId}-panel`} className={css.toolPanel} role="tabpanel" aria-labelledby={`${detailId}-${tab}`} tabIndex={0}>
|
|
248
252
|
{selected && <p className={css.toolDetailNote}>为保留选区,预览暂停更新;当前状态见卡片标题。</p>}
|
|
249
253
|
{tab === 'result' && <ResultView {...render} {...preview} />}
|
|
250
|
-
{tab === 'input' && <><InputView model={preview.model}
|
|
254
|
+
{tab === 'input' && <><InputView model={preview.model} phase={preview.phase} fillComposer={render.fillComposer} /><details className={css.detail}><summary>全部输入字段</summary><JsonTree data={preview.model.args} label="输入字段" labels={jsonTreeLabels} /></details></>}
|
|
251
255
|
{tab === 'raw' && <><p className={css.toolDetailNote}>完整记录 · 只读 · 不执行其中的代码</p><h4 className={css.toolRawLabel}>工具输入</h4><pre className={css.toolRaw}>{preview.model.raw || '输入尚未到达'}</pre>{rawResult && <><h4 className={css.toolRawLabel}>工具结果</h4><pre className={css.toolRaw}>{rawResult}</pre></>}</>}
|
|
252
256
|
</div>
|
|
253
257
|
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ChatSeatKind = 'user' | 'steering';
|
|
2
|
+
|
|
3
|
+
export function chatSeatProps(kind: ChatSeatKind, key: string): Record<string, string> {
|
|
4
|
+
return {
|
|
5
|
+
'data-chat-anchor-key': key,
|
|
6
|
+
'data-chat-flow-key': key,
|
|
7
|
+
'data-chat-node-key': key,
|
|
8
|
+
'data-chat-flow-kind': kind,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -23,6 +23,7 @@ export function basename(path: string): string {
|
|
|
23
23
|
export function dirname(path: string): string {
|
|
24
24
|
const normalized = path.replace(/[/\\]+$/, '');
|
|
25
25
|
const at = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'));
|
|
26
|
+
if (at === 0 || (at === 2 && /^[A-Za-z]:/.test(normalized))) return normalized.slice(0, at + 1);
|
|
26
27
|
return at === -1 ? '.' : normalized.slice(0, at) || '.';
|
|
27
28
|
}
|
|
28
29
|
|
package/src/client/index.tsx
CHANGED
|
@@ -6,6 +6,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
|
6
6
|
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path';
|
|
7
7
|
import * as workspacePathPkg from '@deepseek-ai/dsh-util-workspace-path';
|
|
8
8
|
import { dirname } from './deliverables.js';
|
|
9
|
+
import { isAbsoluteWorkspacePath } from '../workspace-path.js';
|
|
10
|
+
import { ensureStyles } from './styles.js';
|
|
9
11
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client';
|
|
10
12
|
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
|
|
11
13
|
import { Reader } from './Reader.js';
|
|
@@ -57,6 +59,9 @@ export { McpAppFrame } from './McpAppFrame.js';
|
|
|
57
59
|
export const name = 'dsh-better-display-client';
|
|
58
60
|
export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'remote', 'remote.session'];
|
|
59
61
|
|
|
62
|
+
// All CSS imports have injected their nodes before this module's body runs.
|
|
63
|
+
ensureStyles();
|
|
64
|
+
|
|
60
65
|
export function apply(ctx: Context): void {
|
|
61
66
|
const store = createReaderStore();
|
|
62
67
|
// conversation.view is session-scoped, so session persist keys are
|
|
@@ -138,7 +143,7 @@ export function apply(ctx: Context): void {
|
|
|
138
143
|
const res = await fetch('/better-display/reveal', {
|
|
139
144
|
method: 'POST',
|
|
140
145
|
headers: { 'Content-Type': 'application/json' },
|
|
141
|
-
body: JSON.stringify({ path: targetPath }),
|
|
146
|
+
body: JSON.stringify({ path: targetPath, sessionId }),
|
|
142
147
|
});
|
|
143
148
|
if (res.ok) {
|
|
144
149
|
const data = await res.json();
|
|
@@ -150,6 +155,10 @@ export function apply(ctx: Context): void {
|
|
|
150
155
|
|
|
151
156
|
// 2. Fallback to official opener with parent directory
|
|
152
157
|
const parentDir = dirname(targetPath);
|
|
158
|
+
if (!isAbsoluteWorkspacePath(parentDir)) {
|
|
159
|
+
console.warn('[dsh-better-display] reveal failed; refusing to open a relative parent:', targetPath);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
153
162
|
const remote = ctx.remote as unknown as { session?: { openWorkspacePath: (arg: { path: string }) => Promise<{ ok: boolean; error?: { message: string } }> } } | undefined;
|
|
154
163
|
const remoteSession = remote?.session
|
|
155
164
|
?? (ctx.get?.('remote.session') as unknown as { openWorkspacePath: (arg: { path: string }) => Promise<{ ok: boolean; error?: { message: string } }> } | undefined);
|
package/src/client/motion.tsx
CHANGED
|
@@ -191,7 +191,14 @@ export function RetiringContent({ visible, children }: { visible: boolean; child
|
|
|
191
191
|
if (!enabled || from < 1) { setPresent(false); return; }
|
|
192
192
|
const next = element.animate([{ height: `${from}px`, opacity: 1 }, { height: '0px', opacity: 0 }], { duration: 220, easing: EASING, fill: 'both' });
|
|
193
193
|
animation.current = next;
|
|
194
|
-
|
|
194
|
+
const settle = () => { if (animation.current === next) { animation.current = null; next.cancel(); setPresent(false); } };
|
|
195
|
+
next.onfinish = settle;
|
|
196
|
+
const deadline = window.setTimeout(settle, 220 + 240);
|
|
197
|
+
return () => {
|
|
198
|
+
window.clearTimeout(deadline);
|
|
199
|
+
if (animation.current === next) animation.current = null;
|
|
200
|
+
next.cancel();
|
|
201
|
+
};
|
|
195
202
|
}, [visible, enabled, focusHeld]);
|
|
196
203
|
useEffect(() => () => animation.current?.cancel(), []);
|
|
197
204
|
if (!visible && !present) return null;
|
|
@@ -57,8 +57,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// Existing Reader presentations own these rows. All other node kinds, including
|
|
60
|
-
// future registrations, reach the official renderer through the
|
|
61
|
-
|
|
60
|
+
// future registrations, reach the official renderer through the mirror seat.
|
|
61
|
+
// 'user' and 'steering' are mirrored too, so third-party renderers registered
|
|
62
|
+
// on the official node slot (e.g. message-recall buttons) keep working inside
|
|
63
|
+
// the Reader instead of being silently dropped.
|
|
64
|
+
const READER_OWNED_NODES = new Set(['assistant-step', 'tool-call', 'turn-tail', 'turn-process']);
|
|
62
65
|
const EXPECTED: Record<OfficialFamily, Spec> = {
|
|
63
66
|
actions: { kind: 'list', scope: 'session' },
|
|
64
67
|
tools: { kind: 'keyed', scope: 'session' },
|
|
@@ -198,7 +201,7 @@ export function installOfficialSlots(ctx: Context): () => void {
|
|
|
198
201
|
throw new Error(`Official slot contract changed: ${source}`);
|
|
199
202
|
}
|
|
200
203
|
return mirrorOfficialSlot(slots, source, target, `dsh-better-display.official.${family}`,
|
|
201
|
-
family === 'nodes' ? entry => !
|
|
204
|
+
family === 'nodes' ? entry => !READER_OWNED_NODES.has(entry.options.key ?? '') : undefined);
|
|
202
205
|
}));
|
|
203
206
|
}
|
|
204
207
|
} 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
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const plugin = 'dsh-better-display';
|
|
2
|
+
const styles = new Map<string, string>();
|
|
3
|
+
|
|
4
|
+
/** Retain injected CSS across host cleanup and restore it before Reader paints. */
|
|
5
|
+
export function ensureStyles(): void {
|
|
6
|
+
if (typeof document === 'undefined') return;
|
|
7
|
+
const live = new Set<string>();
|
|
8
|
+
for (const tag of document.querySelectorAll<HTMLStyleElement>(`style[data-plugin="${plugin}"][data-plugin-css]`)) {
|
|
9
|
+
const id = tag.dataset.pluginCss;
|
|
10
|
+
if (id === undefined) continue;
|
|
11
|
+
styles.set(id, tag.textContent ?? '');
|
|
12
|
+
live.add(id);
|
|
13
|
+
}
|
|
14
|
+
for (const [id, text] of styles) {
|
|
15
|
+
if (live.has(id)) continue;
|
|
16
|
+
const tag = document.createElement('style');
|
|
17
|
+
tag.dataset.plugin = plugin;
|
|
18
|
+
tag.dataset.pluginCss = id;
|
|
19
|
+
tag.textContent = text;
|
|
20
|
+
document.head.appendChild(tag);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -53,10 +53,15 @@ export function stringValue(record: Record<string, unknown> | null, ...keys: str
|
|
|
53
53
|
return undefined;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Top-level fields from complete or in-flight JSON tool arguments. */
|
|
57
|
+
type ParsedInputFields =
|
|
58
|
+
| { kind: 'complete'; fields: Record<string, unknown> }
|
|
59
|
+
| { kind: 'partial'; fields: Record<string, unknown> };
|
|
60
|
+
|
|
56
61
|
/** Read only top-level JSON string values, including an unfinished final string.
|
|
57
62
|
* This never executes input or mistakes escaped/nested content for a path field. */
|
|
58
|
-
|
|
59
|
-
try { return objectValue(JSON.parse(raw)) ?? {}; } catch { /* an in-flight argument is normally incomplete */ }
|
|
63
|
+
function parseInputFields(raw: string): ParsedInputFields {
|
|
64
|
+
try { return { kind: 'complete', fields: objectValue(JSON.parse(raw)) ?? {} }; } catch { /* an in-flight argument is normally incomplete */ }
|
|
60
65
|
const fields: Record<string, unknown> = Object.create(null);
|
|
61
66
|
const prefix = raw.slice(0, 262144);
|
|
62
67
|
let depth = 0;
|
|
@@ -88,14 +93,19 @@ export function inputFields(raw: string): Record<string, unknown> {
|
|
|
88
93
|
if (closed && /^\s*:/.test(prefix.slice(index + 1))) key = value;
|
|
89
94
|
else if (key !== undefined) { fields[key] = value; key = undefined; }
|
|
90
95
|
}
|
|
91
|
-
return fields;
|
|
96
|
+
return { kind: 'partial', fields };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function inputFields(raw: string): Record<string, unknown> {
|
|
100
|
+
return parseInputFields(raw).fields;
|
|
92
101
|
}
|
|
93
102
|
|
|
103
|
+
/** Name and raw arguments of any call block, whether it has landed or is pending. */
|
|
94
104
|
export function toolIdentity(entry: Pick<ToolActivityEntry, 'block' | 'draft'>) {
|
|
95
105
|
const block = entry.block;
|
|
96
106
|
return {
|
|
97
107
|
name: block ? 'kind' in block ? block.call?.name ?? entry.draft?.name ?? '工具调用' : block.name : entry.draft?.name ?? '工具调用',
|
|
98
|
-
raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : ('argsRaw' in block ? block.argsRaw : '') : entry.draft?.argsRaw ?? '',
|
|
108
|
+
raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : ('argsRaw' in block ? block.argsRaw : entry.draft?.argsRaw ?? '') : entry.draft?.argsRaw ?? '',
|
|
99
109
|
};
|
|
100
110
|
}
|
|
101
111
|
|
|
@@ -254,7 +264,7 @@ export function foldDiffHunks(steps: readonly { kind: string; entry?: ToolActivi
|
|
|
254
264
|
|
|
255
265
|
export function activityPhase(entry: Pick<ToolActivityEntry, 'block' | 'draft'>, turnClosed = false): ToolPhase {
|
|
256
266
|
if (!entry.block) return turnClosed ? 'interrupted' : 'preparing';
|
|
257
|
-
if (!('kind' in entry.block)) return turnClosed ? 'interrupted' : 'running';
|
|
267
|
+
if (!('kind' in entry.block)) return turnClosed ? 'interrupted' : entry.block.phase === 'preparing' ? 'preparing' : 'running';
|
|
258
268
|
// RC1 publishes canonical cancellation as an error result with a typed code.
|
|
259
269
|
if (entry.block.error?.code === 'ABORTED' || entry.block.error?.code === 'interrupted') return 'interrupted';
|
|
260
270
|
const facts = executionFacts(entry.block);
|
|
@@ -308,7 +318,8 @@ export function forgetCallClock(callId: string): void {
|
|
|
308
318
|
|
|
309
319
|
export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'>) {
|
|
310
320
|
const { name, raw } = toolIdentity(entry);
|
|
311
|
-
const
|
|
321
|
+
const parsed = parseInputFields(raw);
|
|
322
|
+
const args = parsed.fields;
|
|
312
323
|
const target = stringValue(args, 'file_path', 'path', 'filename', 'filePath');
|
|
313
324
|
const command = stringValue(args, 'command', 'cmd', 'script');
|
|
314
325
|
const description = stringValue(args, 'description');
|
|
@@ -331,7 +342,7 @@ export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'
|
|
|
331
342
|
: category === 'web' ? name === 'web_search' ? '搜索网页' : '读取网页'
|
|
332
343
|
: category === 'code' ? (description || '运行代码')
|
|
333
344
|
: name;
|
|
334
|
-
return { name, raw, args, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
|
|
345
|
+
return { name, raw, args, argsState: parsed.kind, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
|
|
335
346
|
cwd: stringValue(args, 'workdir', 'cwd'), content: stringValue(args, 'content', 'new_string', 'newText', 'file_text') };
|
|
336
347
|
}
|
|
337
348
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
2
|
import type { Context } from '@deepseek-ai/cordis';
|
|
4
3
|
import { scanGenerativeMcpappsStatus } from './skill-roots.js';
|
|
5
4
|
import { skillsFromListResult, toPublicSkillStatus } from './skill-status.js';
|
|
5
|
+
import { createRevealHandler, revealRoots } from './reveal.js';
|
|
6
6
|
|
|
7
7
|
declare module '@deepseek-ai/cordis' {
|
|
8
8
|
interface Context {
|
|
@@ -39,43 +39,7 @@ export function apply(ctx: Context): void {
|
|
|
39
39
|
const disposeReveal = ctx.webServer!.register({
|
|
40
40
|
kind: 'exact',
|
|
41
41
|
path: '/better-display/reveal',
|
|
42
|
-
handler:
|
|
43
|
-
if (req.method !== 'POST') {
|
|
44
|
-
res.statusCode = 405;
|
|
45
|
-
res.end();
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
let body = '';
|
|
49
|
-
req.on('data', chunk => { body += chunk; });
|
|
50
|
-
req.on('end', () => {
|
|
51
|
-
try {
|
|
52
|
-
const data = JSON.parse(body);
|
|
53
|
-
const targetPath = typeof data.path === 'string' ? data.path.trim() : '';
|
|
54
|
-
if (!targetPath) {
|
|
55
|
-
res.statusCode = 400;
|
|
56
|
-
res.end(JSON.stringify({ ok: false, error: 'Empty path' }));
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (process.platform === 'darwin') {
|
|
61
|
-
// Exact file reveal in macOS Finder with selection highlight
|
|
62
|
-
spawn('open', ['-R', targetPath], { detached: true, stdio: 'ignore' });
|
|
63
|
-
} else if (process.platform === 'win32') {
|
|
64
|
-
// Exact file selection in Windows Explorer
|
|
65
|
-
spawn('explorer.exe', [`/select,${targetPath}`], { detached: true, stdio: 'ignore' });
|
|
66
|
-
} else {
|
|
67
|
-
spawn('xdg-open', [targetPath], { detached: true, stdio: 'ignore' });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
res.setHeader('Content-Type', 'application/json');
|
|
71
|
-
res.statusCode = 200;
|
|
72
|
-
res.end(JSON.stringify({ ok: true }));
|
|
73
|
-
} catch (err) {
|
|
74
|
-
res.statusCode = 400;
|
|
75
|
-
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
},
|
|
42
|
+
handler: createRevealHandler({ roots: sessionId => revealRoots(ctx, sessionId) }),
|
|
79
43
|
});
|
|
80
44
|
const disposeSkill = ctx.webServer!.register({
|
|
81
45
|
kind: 'exact',
|
package/src/reveal.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { spawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
+
import { posix, win32 } from 'node:path';
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import { isAbsoluteWorkspacePath } from './workspace-path.js';
|
|
7
|
+
|
|
8
|
+
export async function revealRoots(ctx: Context, sessionId: string | undefined): Promise<readonly string[]> {
|
|
9
|
+
if (sessionId !== undefined) {
|
|
10
|
+
const sessions = ctx.get?.('sessions') as { get: (id: string) => { header: { cwd?: string } } | undefined } | undefined;
|
|
11
|
+
const cwd = sessions?.get(sessionId)?.header.cwd;
|
|
12
|
+
if (cwd) return [cwd];
|
|
13
|
+
const query = ctx.get?.('sessionQuery') as {
|
|
14
|
+
observeSession: (id: string, options: { projectionMode: 'none' }) => Promise<{ header: { cwd?: string } } & Disposable>;
|
|
15
|
+
} | undefined;
|
|
16
|
+
if (query) {
|
|
17
|
+
using observation = await query.observeSession(sessionId, { projectionMode: 'none' });
|
|
18
|
+
if (observation.header.cwd) return [observation.header.cwd];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const registry = ctx.get?.('workspaceRegistry') as {
|
|
22
|
+
list: () => readonly { path: string; sessionIds: readonly string[] }[];
|
|
23
|
+
} | undefined;
|
|
24
|
+
return (registry?.list() ?? [])
|
|
25
|
+
.filter(workspace => sessionId === undefined || workspace.sessionIds.includes(sessionId))
|
|
26
|
+
.map(workspace => workspace.path);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveRevealPath(path: string, roots: readonly string[], exists = existsSync): string {
|
|
30
|
+
if (isAbsoluteWorkspacePath(path)) return path;
|
|
31
|
+
// Drive-relative and root-relative Windows paths do not identify a workspace.
|
|
32
|
+
if (/^[A-Za-z]:|^\\/.test(path)) throw new Error('Path is not workspace-relative');
|
|
33
|
+
const matches = new Set<string>();
|
|
34
|
+
for (const root of roots) {
|
|
35
|
+
if (!isAbsoluteWorkspacePath(root)) continue;
|
|
36
|
+
const paths = /^[A-Za-z]:[/\\]|^\\\\/.test(root) ? win32 : posix;
|
|
37
|
+
const candidate = paths.resolve(root, path);
|
|
38
|
+
if (exists(candidate)) matches.add(candidate);
|
|
39
|
+
}
|
|
40
|
+
if (matches.size !== 1) throw new Error(matches.size === 0 ? 'Workspace path not found' : 'Workspace path is ambiguous');
|
|
41
|
+
return [...matches][0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function revealPath(
|
|
45
|
+
path: string,
|
|
46
|
+
platform = process.platform,
|
|
47
|
+
spawnProcess: (command: string, args: string[], options: SpawnOptions) => unknown = spawn,
|
|
48
|
+
): void {
|
|
49
|
+
const options = { detached: true, stdio: 'ignore' } satisfies SpawnOptions;
|
|
50
|
+
if (platform === 'darwin') {
|
|
51
|
+
spawnProcess('open', ['-R', path], options);
|
|
52
|
+
} else if (platform === 'win32') {
|
|
53
|
+
spawnProcess('explorer.exe', [`/select,"${path}"`], { ...options, windowsVerbatimArguments: true });
|
|
54
|
+
} else {
|
|
55
|
+
spawnProcess('xdg-open', [path], options);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createRevealHandler({ roots, reveal = revealPath }: {
|
|
60
|
+
roots: (sessionId: string | undefined) => Promise<readonly string[]>;
|
|
61
|
+
reveal?: (path: string) => void;
|
|
62
|
+
}): (req: IncomingMessage, res: ServerResponse) => void {
|
|
63
|
+
return (req, res) => {
|
|
64
|
+
if (req.method !== 'POST') {
|
|
65
|
+
res.statusCode = 405;
|
|
66
|
+
res.end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let body = '';
|
|
70
|
+
req.on('data', chunk => { body += chunk; });
|
|
71
|
+
req.on('end', async () => {
|
|
72
|
+
res.setHeader('Content-Type', 'application/json');
|
|
73
|
+
try {
|
|
74
|
+
const data: unknown = JSON.parse(body);
|
|
75
|
+
const path = typeof data === 'object' && data !== null && 'path' in data && typeof data.path === 'string'
|
|
76
|
+
? data.path.trim() : '';
|
|
77
|
+
if (!path) throw new Error('Empty path');
|
|
78
|
+
const sessionId = typeof data === 'object' && data !== null && 'sessionId' in data && typeof data.sessionId === 'string'
|
|
79
|
+
? data.sessionId : undefined;
|
|
80
|
+
const targetPath = resolveRevealPath(path, isAbsoluteWorkspacePath(path) ? [] : await roots(sessionId));
|
|
81
|
+
reveal(targetPath);
|
|
82
|
+
res.statusCode = 200;
|
|
83
|
+
res.end(JSON.stringify({ ok: true }));
|
|
84
|
+
} catch (err) {
|
|
85
|
+
res.statusCode = 400;
|
|
86
|
+
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -45,6 +45,19 @@ test('committed client bundle publishes data-chat-flow for git installs', () =>
|
|
|
45
45
|
assert.match(compiled, /"data-chat-flow":\s*""/);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test('Reader exposes rewind chat seats and pending steering markers', () => {
|
|
49
|
+
const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
50
|
+
const seat = readFileSync(resolve(root, 'src/client/chat-seat.ts'), 'utf8');
|
|
51
|
+
assert.match(seat, /data-chat-anchor-key/);
|
|
52
|
+
assert.match(seat, /data-chat-flow-key/);
|
|
53
|
+
assert.match(seat, /data-chat-node-key/);
|
|
54
|
+
assert.match(source, /data-pending-steering/);
|
|
55
|
+
assert.match(source, /data-pending-attachment-count/);
|
|
56
|
+
const client = readFileSync(resolve(root, 'lib/client.js'), 'utf8');
|
|
57
|
+
assert.match(client, /data-chat-anchor-key/);
|
|
58
|
+
assert.match(client, /data-pending-steering/);
|
|
59
|
+
});
|
|
60
|
+
|
|
48
61
|
test('skin empty-state hide stays off when Reader mounts inside the scrollport', () => {
|
|
49
62
|
const inspectOnly: AttrNode = {
|
|
50
63
|
attrs: { 'data-conversation-scroll': '' },
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import * as Primitives from '@deepseek-ai/dsh-client-ui-primitives';
|
|
4
|
+
import { createRoot } from 'react-dom/client';
|
|
5
|
+
import { flushSync } from 'react-dom';
|
|
6
|
+
import { SlotRegistry } from '@fixture/registry';
|
|
7
|
+
import { createSlotRenderer } from '@fixture/renderer';
|
|
8
|
+
import { Reader } from '../../src/client/Reader.js';
|
|
9
|
+
import { createReaderStore } from '../../src/client/store.js';
|
|
10
|
+
import { installOfficialSlots, officialChildren, OFFICIAL_SEATS } from '../../src/client/official-slots.js';
|
|
11
|
+
import type { ReaderProps } from '../../src/client/types.js';
|
|
12
|
+
|
|
13
|
+
// Real Reader, registry, scope binding and slot renderer. Only session data is synthetic.
|
|
14
|
+
function equal(actual: unknown, expected: unknown) {
|
|
15
|
+
if (!Object.is(actual, expected)) throw new Error(`Expected ${String(expected)}, got ${String(actual)}`);
|
|
16
|
+
}
|
|
17
|
+
const assert = {
|
|
18
|
+
equal,
|
|
19
|
+
ok(value: unknown) { equal(Boolean(value), true); },
|
|
20
|
+
deepEqual(actual: unknown, expected: unknown) { equal(JSON.stringify(actual), JSON.stringify(expected)); },
|
|
21
|
+
};
|
|
22
|
+
async function verify() {
|
|
23
|
+
const root = new Context();
|
|
24
|
+
await root.plugin(SlotRegistry);
|
|
25
|
+
let run = async () => {};
|
|
26
|
+
await root.plugin({ name: 'reader-user-test', inject: ['slots'], async apply(ctx: Context) {
|
|
27
|
+
const slots = ctx.slots as any;
|
|
28
|
+
slots.install(createSlotRenderer());
|
|
29
|
+
const source = <T,>(value: T) => ({ getSnapshot: () => value, subscribe: () => () => {} });
|
|
30
|
+
const binding = { key: 'session-users', ctx: new Context(), hooks: {}, keyedHooks: {}, props: { sessionId: 'session-users' } };
|
|
31
|
+
slots.installScope('session', {
|
|
32
|
+
current: source(binding), bindingSource: () => source(binding), renderArea: (_: unknown, props: any) => props.children,
|
|
33
|
+
});
|
|
34
|
+
slots.installLocale({ ...source({}), bind: () => (key: string) => key });
|
|
35
|
+
const turn = { turn: 1, status: 'closed', start: { seq: 1, time: 1 },
|
|
36
|
+
end: { time: 2, data: { reason: { kind: 'completed' } } }, data: new Map(), steps: [] };
|
|
37
|
+
const nodes = new Map(['user', 'steering', 'hidden'].map((kind, index) => {
|
|
38
|
+
const key = `${kind}-key`;
|
|
39
|
+
return [key, { key, kind: kind === 'hidden' ? 'user' : kind, anchorSeq: index + 1,
|
|
40
|
+
visibility: kind === 'hidden' ? 'hidden' : 'visible', location: { kind: 'turn', turn },
|
|
41
|
+
data: { seq: index + 1, time: 1, content: [{ type: 'text', text: `${kind} content` }] } }];
|
|
42
|
+
}));
|
|
43
|
+
const chat = { order: [...nodes.keys()], nodes, timeline: { turns: new Map([[1, turn]]) } };
|
|
44
|
+
const session = { running: false, openState: 'ready', pendingSubmissions: [], hasMore: false, loadingOlder: false };
|
|
45
|
+
const store = createReaderStore().create('session-users');
|
|
46
|
+
const props = {
|
|
47
|
+
sessionId: 'session-users', useChat: (select: any) => select(chat),
|
|
48
|
+
useSession: (select: any) => select(session), useSessions: (select: any) => select({ byId: { 'session-users': { cwd: '/fixture' } } }),
|
|
49
|
+
useSessionStatus: (select: any) => select(new Map()), useStore: (select: any) => select(store.getSnapshot()), actions: store.actions,
|
|
50
|
+
t: (key: string) => key, loadImage: async () => ({ data: new Uint8Array(), mediaType: 'image/png' }),
|
|
51
|
+
officialImageLoader: Object.assign(async () => null, { peek: () => null }),
|
|
52
|
+
officialFileMentions: () => undefined, officialPreviewFile: () => {},
|
|
53
|
+
officialHost: source({ home: '/fixture' }), fillComposer: () => true,
|
|
54
|
+
openFile: () => {}, loadOlder: async () => {}, openView: () => {},
|
|
55
|
+
};
|
|
56
|
+
slots.register({ name: 'root', children: {
|
|
57
|
+
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: { hooks: {
|
|
58
|
+
turnData: (_: unknown, context: any) => () => context.turnData,
|
|
59
|
+
disclosure: (_: unknown, context: any) => () => context.disclosureReset,
|
|
60
|
+
} } },
|
|
61
|
+
'fixture.reader': { kind: 'single', scope: 'session' },
|
|
62
|
+
} }, ({ SessionProvider, renderSlot }: any) => <SessionProvider>{renderSlot('fixture.reader', {})}</SessionProvider>);
|
|
63
|
+
slots.register({ name: 'fixture.reader', children: { ...officialChildren(slots), 'dsh-better-display.block': { kind: 'chain', scope: 'session' } } }, ({ renderSlot, renderSlotChain }: any) =>
|
|
64
|
+
<Reader {...{ ...props, renderSlot, renderSlotChain } as ReaderProps} />);
|
|
65
|
+
const stopMirror = installOfficialSlots(ctx);
|
|
66
|
+
run = async () => {
|
|
67
|
+
const container = document.getElementById('app')!;
|
|
68
|
+
const reactRoot = createRoot(container);
|
|
69
|
+
const render = () => {
|
|
70
|
+
flushSync(() => reactRoot.render(slots.renderSlot('root', {})));
|
|
71
|
+
return container.innerHTML;
|
|
72
|
+
};
|
|
73
|
+
const count = (html: string, text: string) => html.split(text).length - 1;
|
|
74
|
+
const assertSeats = (html: string) => {
|
|
75
|
+
for (const kind of ['user', 'steering']) {
|
|
76
|
+
assert.equal(count(html, `data-reader-key="${kind}-key"`), 1);
|
|
77
|
+
assert.equal(count(html, `data-chat-anchor-key="${kind}-key"`), 1);
|
|
78
|
+
assert.equal(count(html, `data-chat-flow-key="${kind}-key"`), 1);
|
|
79
|
+
assert.equal(count(html, `data-chat-node-key="${kind}-key"`), 1);
|
|
80
|
+
assert.equal(count(html, `data-chat-flow-kind="${kind}"`), 1);
|
|
81
|
+
}
|
|
82
|
+
assert.equal(count(html, 'data-reader-anchor="true"'), 2);
|
|
83
|
+
assert.ok(!html.includes('hidden content'));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// The real renderSlot returns an outlet element for an unoccupied key.
|
|
87
|
+
let html = render();
|
|
88
|
+
assertSeats(html);
|
|
89
|
+
assert.equal(count(html, 'user content'), 1);
|
|
90
|
+
assert.equal(count(html, 'steering content'), 1);
|
|
91
|
+
assert.ok(html.includes('补充消息'));
|
|
92
|
+
const seen: any[] = [];
|
|
93
|
+
const official = ({ node, sessionId, injectedSession, useTurnData, useDisclosure }: any) => {
|
|
94
|
+
assert.equal(sessionId, 'session-users');
|
|
95
|
+
assert.equal(injectedSession, 'session-users');
|
|
96
|
+
assert.equal(useTurnData(), turn.data);
|
|
97
|
+
assert.equal(useDisclosure().getSnapshot(), 0);
|
|
98
|
+
assert.equal(node, nodes.get(node.key));
|
|
99
|
+
seen.push(node.kind);
|
|
100
|
+
return <div data-official-user={node.kind}>{node.data.content[0].text}<button>Official recall</button></div>;
|
|
101
|
+
};
|
|
102
|
+
const removeUser = slots.register({ name: 'conversation.chat.node', key: 'user',
|
|
103
|
+
inject: (sessionId: string) => ({ injectedSession: sessionId }), locale: 'chat' }, official);
|
|
104
|
+
const removeSteering = slots.register({ name: 'conversation.chat.node', key: 'steering',
|
|
105
|
+
inject: (sessionId: string) => ({ injectedSession: sessionId }), locale: 'chat' }, official);
|
|
106
|
+
const removeAssistant = slots.register({ name: 'conversation.chat.node', key: 'assistant-step' }, () => <span>Duplicate assistant</span>);
|
|
107
|
+
await new Promise(resolve => queueMicrotask(resolve));
|
|
108
|
+
assert.deepEqual(slots.entriesOfSlot(OFFICIAL_SEATS.nodes).map((entry: any) => entry.options.key).sort(), ['steering', 'user']);
|
|
109
|
+
html = render();
|
|
110
|
+
assertSeats(html);
|
|
111
|
+
assert.equal(count(html, 'Official recall'), 2);
|
|
112
|
+
assert.equal(count(html, 'user content'), 1);
|
|
113
|
+
assert.equal(count(html, 'steering content'), 1);
|
|
114
|
+
assert.deepEqual(seen, ['user', 'steering']);
|
|
115
|
+
|
|
116
|
+
// A winner intentionally returning null must keep its row hidden.
|
|
117
|
+
const removeWinner = slots.register({ name: 'conversation.chat.node', key: 'user', priority: -1 }, () => null);
|
|
118
|
+
await new Promise(resolve => queueMicrotask(resolve));
|
|
119
|
+
html = render();
|
|
120
|
+
assertSeats(html);
|
|
121
|
+
assert.equal(count(html, 'user content'), 0);
|
|
122
|
+
assert.equal(count(html, 'Official recall'), 1);
|
|
123
|
+
removeWinner();
|
|
124
|
+
await new Promise(resolve => queueMicrotask(resolve));
|
|
125
|
+
assert.equal(count(render(), 'Official recall'), 2);
|
|
126
|
+
removeUser(); removeSteering(); removeAssistant();
|
|
127
|
+
await new Promise(resolve => queueMicrotask(resolve));
|
|
128
|
+
html = render();
|
|
129
|
+
assertSeats(html);
|
|
130
|
+
assert.equal(count(html, 'user content'), 1);
|
|
131
|
+
assert.equal(count(html, 'steering content'), 1);
|
|
132
|
+
assert.equal(count(html, 'Official recall'), 0);
|
|
133
|
+
const module = Reflect.get(window, 'easyrewriteModule');
|
|
134
|
+
if (module) {
|
|
135
|
+
const plugin = module.factory((name: string) => {
|
|
136
|
+
if (name === 'react') return React;
|
|
137
|
+
if (name === '@deepseek-ai/dsh-client-ui-primitives') return Primitives;
|
|
138
|
+
throw new Error(`Unexpected easyrewrite dependency: ${name}`);
|
|
139
|
+
});
|
|
140
|
+
const disposers: Array<() => void> = [];
|
|
141
|
+
plugin.apply({
|
|
142
|
+
effect: (effect: () => () => void) => disposers.push(effect()),
|
|
143
|
+
slots: {
|
|
144
|
+
inject: (key: string, effect: () => () => void) => key === 'conversation.chat.node' ? effect() : () => {},
|
|
145
|
+
register: (options: object, component: unknown) => slots.register(options, component),
|
|
146
|
+
},
|
|
147
|
+
sessions: { list: source({ current: 'session-users' }), scope: () => ({ get: () => undefined }) },
|
|
148
|
+
workspaces: {},
|
|
149
|
+
});
|
|
150
|
+
await new Promise(resolve => queueMicrotask(resolve));
|
|
151
|
+
html = render();
|
|
152
|
+
assertSeats(html);
|
|
153
|
+
assert.equal(count(html, 'data-dsh-easyrewrite="user"'), 1);
|
|
154
|
+
assert.equal(count(html, 'data-dsh-easyrewrite="recall-key"'), 1);
|
|
155
|
+
assert.equal(count(html, 'user content'), 1);
|
|
156
|
+
assert.equal(count(html, 'steering content'), 1);
|
|
157
|
+
const recall = container.querySelector<HTMLButtonElement>('[data-dsh-easyrewrite="recall-key"]');
|
|
158
|
+
assert.ok(recall);
|
|
159
|
+
flushSync(() => recall!.click());
|
|
160
|
+
assert.equal(container.querySelectorAll('[data-dsh-easyrewrite="confirm-capsule"]').length, 1);
|
|
161
|
+
for (const dispose of disposers) dispose();
|
|
162
|
+
}
|
|
163
|
+
stopMirror();
|
|
164
|
+
reactRoot.unmount();
|
|
165
|
+
Object.assign(window, { readerUserResult: 'passed' });
|
|
166
|
+
};
|
|
167
|
+
} });
|
|
168
|
+
await run();
|
|
169
|
+
}
|
|
170
|
+
verify().catch(error => { console.error(error); Object.assign(window, { readerUserResult: `${error.stack}\n${document.getElementById('app')?.innerHTML}` }); });
|
|
@@ -21,6 +21,7 @@ const HOST_FOOTER_Z = 7;
|
|
|
21
21
|
|
|
22
22
|
/** Lanes a scroll can clamp into the footer band. */
|
|
23
23
|
const CLAMPED_LANES = [
|
|
24
|
+
'.turnStickyLanes',
|
|
24
25
|
'.turnProcessSticky',
|
|
25
26
|
'.liveFoldContainer',
|
|
26
27
|
'.flowCell[data-flow-summary]',
|
|
@@ -85,6 +86,13 @@ test('the top toolbar lane stays at 7 and pins to the scrollport top', () => {
|
|
|
85
86
|
assert.equal(declarations.get('top'), '0');
|
|
86
87
|
});
|
|
87
88
|
|
|
89
|
+
test('official code block banners stay beneath Reader sticky lanes', () => {
|
|
90
|
+
const blockZ = Number(mergedDeclarations(css, '.root :global(.md-code-block)').get('z-index'));
|
|
91
|
+
const summaryZ = Number(mergedDeclarations(css, '.flowCell[data-flow-summary]').get('z-index'));
|
|
92
|
+
assert.ok(Number.isFinite(blockZ), 'Reader scopes official markdown code blocks into a stacking context');
|
|
93
|
+
assert.ok(blockZ < summaryZ, `code block banner context (${blockZ}) must stay below the summary lane (${summaryZ})`);
|
|
94
|
+
});
|
|
95
|
+
|
|
88
96
|
test('the live status lane pins below the toolbar lane instead of over it', () => {
|
|
89
97
|
const declarations = mergedDeclarations(css, '.turnProcessSticky');
|
|
90
98
|
assert.match(
|