dsh-better-display 0.3.7 → 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 +11 -1
- package/docs/official-rendering-bridge.md +12 -0
- package/lib/client.js +221 -105
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +297 -53
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +43 -12
- 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/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/Reader.module.css +41 -9
- package/src/client/Reader.tsx +56 -21
- package/src/client/ToolActivity.tsx +9 -5
- 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/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 +49 -1
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
|
+
}
|
|
@@ -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(
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { externalClientBundle } from '../tools/client-build.js';
|
|
5
|
+
|
|
6
|
+
// Exercise the actual build plugin so options cannot drift from production.
|
|
7
|
+
async function readerCss(): Promise<string> {
|
|
8
|
+
const configs = externalClientBundle('dsh-better-display', [], { clientEntry: 'src/client/index.tsx' });
|
|
9
|
+
const client = configs.find(config => config.name === 'dsh-better-display/client');
|
|
10
|
+
const plugin = client?.plugins.find(plugin => plugin.name === 'dshx-css-modules-inline');
|
|
11
|
+
assert.ok(plugin);
|
|
12
|
+
const source = await plugin.load.call({}, `\0dshx-css-module:${resolve('src/client/Reader.module.css')}.mjs`);
|
|
13
|
+
const literal = /^const css = (.+);$/m.exec(source)?.[1];
|
|
14
|
+
assert.ok(literal, 'the build emits an inline CSS string');
|
|
15
|
+
const css: unknown = JSON.parse(literal);
|
|
16
|
+
assert.equal(typeof css, 'string');
|
|
17
|
+
if (typeof css !== 'string') throw new Error('Expected CSS text');
|
|
18
|
+
return css;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('built Reader CSS retains Chromium blur and the older Safari fallback', async () => {
|
|
22
|
+
const css = await readerCss();
|
|
23
|
+
assert.match(css, /(?<![\w-])backdrop-filter:blur\(12px\)/);
|
|
24
|
+
assert.match(css, /-webkit-backdrop-filter:blur\(12px\)/);
|
|
25
|
+
const prefixed = [...css.matchAll(/-webkit-backdrop-filter:([^;}]+)/g)].map(match => match[1]);
|
|
26
|
+
const standard = [...css.matchAll(/(?<![\w-])backdrop-filter:([^;}]+)/g)].map(match => match[1]);
|
|
27
|
+
assert.deepEqual(standard, prefixed, 'every Safari fallback also has a working standard declaration');
|
|
28
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { homedir, tmpdir } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
|
|
10
|
+
// Needs the Harness source tree and a local pinned Chromium; CI (which builds the
|
|
11
|
+
// release) has neither browser runtime, so the test skips there instead of failing.
|
|
12
|
+
const harnessPointer = join(homedir(), '.config/dshx/harness');
|
|
13
|
+
const harness = process.env.DSHX_HARNESS?.trim()
|
|
14
|
+
|| (existsSync(harnessPointer) ? readFileSync(harnessPointer, 'utf8').trim() : '');
|
|
15
|
+
const browserRuntime = join(homedir(), '.codex/playwright-runtime/runtime.mjs');
|
|
16
|
+
const skip = !harness ? 'DSH Harness source not available'
|
|
17
|
+
: !existsSync(browserRuntime) ? 'pinned Chromium runtime not available' : false;
|
|
18
|
+
|
|
19
|
+
test('headless Reader users retain official renderers, anchors and empty-key fallback', { skip }, async () => {
|
|
20
|
+
const root = resolve(import.meta.dirname, '..');
|
|
21
|
+
const require = createRequire(join(root, 'package.json'));
|
|
22
|
+
const { build } = createRequire(require.resolve('tsx'))('esbuild');
|
|
23
|
+
const webRequire = createRequire(join(harness, 'packages/client/web/package.json'));
|
|
24
|
+
const out = mkdtempSync(join(tmpdir(), 'reader-official-users-'));
|
|
25
|
+
try {
|
|
26
|
+
const { launchPinnedChromium } = await import(pathToFileURL(browserRuntime).href);
|
|
27
|
+
await build({
|
|
28
|
+
entryPoints: [join(root, 'tests/fixtures/reader-official-users.tsx')],
|
|
29
|
+
outfile: join(out, 'fixture.js'), bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
|
|
30
|
+
alias: {
|
|
31
|
+
'@fixture/registry': join(harness, 'packages/client/ui-renderer/src/client/registry.ts'),
|
|
32
|
+
'@fixture/renderer': join(harness, 'packages/client/ui-renderer/src/client/scoped-slots.tsx'),
|
|
33
|
+
'@deepseek-ai/dsh-client-ui-primitives': join(harness, 'packages/client/ui-primitives/src/index.ts'),
|
|
34
|
+
'@deepseek-ai/dsh-client-ui-slots': join(harness, 'packages/client/ui-slots/src/index.ts'),
|
|
35
|
+
// The published store lib leaves zustand/immer to the host; the source
|
|
36
|
+
// package resolves them from the Harness workspace.
|
|
37
|
+
'@deepseek-ai/dsh-client-store': join(harness, 'packages/client/store/src/index.ts'),
|
|
38
|
+
react: dirname(require.resolve('react/package.json')),
|
|
39
|
+
'react-dom': dirname(webRequire.resolve('react-dom/package.json')),
|
|
40
|
+
},
|
|
41
|
+
loader: { '.css': 'empty', '.woff2': 'empty', '.woff': 'empty', '.ttf': 'empty', '.svg': 'empty' },
|
|
42
|
+
logLevel: 'silent',
|
|
43
|
+
});
|
|
44
|
+
const server = createServer((request, response) => {
|
|
45
|
+
if (request.url === '/easyrewrite.js' && process.env.DSH_EASYREWRITE_CLIENT) {
|
|
46
|
+
response.setHeader('Content-Type', 'text/javascript');
|
|
47
|
+
response.end(readFileSync(process.env.DSH_EASYREWRITE_CLIENT));
|
|
48
|
+
} else if (request.url === '/fixture.js') {
|
|
49
|
+
response.setHeader('Content-Type', 'text/javascript');
|
|
50
|
+
response.end(readFileSync(join(out, 'fixture.js')));
|
|
51
|
+
} else {
|
|
52
|
+
response.setHeader('Content-Type', 'text/html');
|
|
53
|
+
const easyrewrite = process.env.DSH_EASYREWRITE_CLIENT
|
|
54
|
+
? '<script>window.__ModuleLoader__={load:m=>window.easyrewriteModule=m}</script><script src="/easyrewrite.js"></script>' : '';
|
|
55
|
+
response.end(`<!doctype html><div id="app"></div>${easyrewrite}<script type="module" src="/fixture.js"></script>`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
59
|
+
let browser;
|
|
60
|
+
try {
|
|
61
|
+
browser = await launchPinnedChromium();
|
|
62
|
+
const page = await browser.newPage();
|
|
63
|
+
const errors: string[] = [];
|
|
64
|
+
page.on('pageerror', (error: Error) => errors.push(String(error)));
|
|
65
|
+
page.on('console', (message: { type(): string; text(): string }) => {
|
|
66
|
+
if (message.type() === 'error') errors.push(message.text());
|
|
67
|
+
});
|
|
68
|
+
const address = server.address();
|
|
69
|
+
assert.ok(address && typeof address === 'object');
|
|
70
|
+
await page.goto(`http://127.0.0.1:${address.port}`);
|
|
71
|
+
await page.waitForFunction(() => 'readerUserResult' in window, undefined, { timeout: 10000 });
|
|
72
|
+
assert.equal(await page.evaluate(() => Reflect.get(window, 'readerUserResult')), 'passed');
|
|
73
|
+
assert.deepEqual(errors, []);
|
|
74
|
+
} finally {
|
|
75
|
+
await browser?.close();
|
|
76
|
+
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(out, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|