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.
Files changed (69) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/docs/official-rendering-bridge.md +12 -0
  3. package/lib/client.js +221 -105
  4. package/lib/client.js.map +1 -1
  5. package/lib/dsh-better-display.js +297 -53
  6. package/lib/types/client/Reader.d.ts.map +1 -1
  7. package/lib/types/client/Reader.js +43 -12
  8. package/lib/types/client/Reader.js.map +1 -1
  9. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  10. package/lib/types/client/ToolActivity.js +10 -5
  11. package/lib/types/client/ToolActivity.js.map +1 -1
  12. package/lib/types/client/deliverables.d.ts.map +1 -1
  13. package/lib/types/client/deliverables.js +2 -0
  14. package/lib/types/client/deliverables.js.map +1 -1
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/index.js +9 -1
  17. package/lib/types/client/index.js.map +1 -1
  18. package/lib/types/client/motion.d.ts.map +1 -1
  19. package/lib/types/client/motion.js +9 -1
  20. package/lib/types/client/motion.js.map +1 -1
  21. package/lib/types/client/official-slots.d.ts.map +1 -1
  22. package/lib/types/client/official-slots.js +6 -3
  23. package/lib/types/client/official-slots.js.map +1 -1
  24. package/lib/types/client/open-file.d.ts.map +1 -1
  25. package/lib/types/client/open-file.js +1 -6
  26. package/lib/types/client/open-file.js.map +1 -1
  27. package/lib/types/client/styles.d.ts +3 -0
  28. package/lib/types/client/styles.d.ts.map +1 -0
  29. package/lib/types/client/styles.js +25 -0
  30. package/lib/types/client/styles.js.map +1 -0
  31. package/lib/types/client/tool-activity.d.ts +2 -2
  32. package/lib/types/client/tool-activity.d.ts.map +1 -1
  33. package/lib/types/client/tool-activity.js +12 -7
  34. package/lib/types/client/tool-activity.js.map +1 -1
  35. package/lib/types/dsh-better-display.d.ts.map +1 -1
  36. package/lib/types/dsh-better-display.js +2 -39
  37. package/lib/types/dsh-better-display.js.map +1 -1
  38. package/lib/types/reveal.d.ts +12 -0
  39. package/lib/types/reveal.d.ts.map +1 -0
  40. package/lib/types/reveal.js +147 -0
  41. package/lib/types/reveal.js.map +1 -0
  42. package/lib/types/workspace-path.d.ts +3 -0
  43. package/lib/types/workspace-path.d.ts.map +1 -0
  44. package/lib/types/workspace-path.js +5 -0
  45. package/lib/types/workspace-path.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/client/Reader.module.css +41 -9
  48. package/src/client/Reader.tsx +56 -21
  49. package/src/client/ToolActivity.tsx +9 -5
  50. package/src/client/deliverables.ts +1 -0
  51. package/src/client/index.tsx +10 -1
  52. package/src/client/motion.tsx +8 -1
  53. package/src/client/official-slots.tsx +6 -3
  54. package/src/client/open-file.ts +2 -8
  55. package/src/client/styles.ts +22 -0
  56. package/src/client/tool-activity.ts +18 -7
  57. package/src/dsh-better-display.ts +2 -38
  58. package/src/reveal.ts +90 -0
  59. package/src/workspace-path.ts +4 -0
  60. package/tests/fixtures/reader-official-users.tsx +170 -0
  61. package/tests/footer-precedence.test.ts +8 -0
  62. package/tests/reader-glass-css.test.ts +28 -0
  63. package/tests/reader-official-users.test.ts +81 -0
  64. package/tests/retiring-content.test.ts +137 -0
  65. package/tests/reveal.test.ts +128 -0
  66. package/tests/stock-install.test.ts +8 -0
  67. package/tests/styles.test.ts +48 -0
  68. package/tests/terminal-input-preview.test.ts +113 -0
  69. package/tsdown.config.ts +49 -1
@@ -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
- export function inputFields(raw: string): Record<string, unknown> {
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 args = inputFields(raw);
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: async (req, res) => {
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,4 @@
1
+ /** Absolute paths accepted by both POSIX and Windows hosts. */
2
+ export function isAbsoluteWorkspacePath(path: string): boolean {
3
+ return /^(?:\/|[A-Za-z]:[/\\]|\\\\)/.test(path);
4
+ }
@@ -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
+ });