dsh-better-display 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/DESIGN.md +43 -3
  3. package/README.en.md +9 -1
  4. package/README.md +9 -1
  5. package/docs/dependency-update-policy.md +9 -0
  6. package/docs/extension-points.md +172 -0
  7. package/docs/official-rendering-bridge.md +59 -18
  8. package/docs/openapi.yaml +147 -0
  9. package/docs/runbooks/blank-reader.md +39 -0
  10. package/lib/client.js +1629 -586
  11. package/lib/client.js.map +1 -1
  12. package/lib/dsh-better-display.js +535 -57
  13. package/lib/types/client/AssistantNode.d.ts +20 -0
  14. package/lib/types/client/AssistantNode.d.ts.map +1 -0
  15. package/lib/types/client/AssistantNode.js +46 -0
  16. package/lib/types/client/AssistantNode.js.map +1 -0
  17. package/lib/types/client/Blocks.d.ts.map +1 -1
  18. package/lib/types/client/Blocks.js +33 -10
  19. package/lib/types/client/Blocks.js.map +1 -1
  20. package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
  21. package/lib/types/client/ChoreographedFlow.js +2 -0
  22. package/lib/types/client/ChoreographedFlow.js.map +1 -1
  23. package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
  24. package/lib/types/client/ClosedProcessSummary.js +3 -1
  25. package/lib/types/client/ClosedProcessSummary.js.map +1 -1
  26. package/lib/types/client/McpAppFrame.d.ts.map +1 -1
  27. package/lib/types/client/McpAppFrame.js +5 -11
  28. package/lib/types/client/McpAppFrame.js.map +1 -1
  29. package/lib/types/client/OfficialContent.d.ts +17 -3
  30. package/lib/types/client/OfficialContent.d.ts.map +1 -1
  31. package/lib/types/client/OfficialContent.js +33 -7
  32. package/lib/types/client/OfficialContent.js.map +1 -1
  33. package/lib/types/client/Reader.d.ts.map +1 -1
  34. package/lib/types/client/Reader.js +76 -73
  35. package/lib/types/client/Reader.js.map +1 -1
  36. package/lib/types/client/SettingsSection.d.ts.map +1 -1
  37. package/lib/types/client/SettingsSection.js +0 -5
  38. package/lib/types/client/SettingsSection.js.map +1 -1
  39. package/lib/types/client/StickyLane.d.ts +12 -0
  40. package/lib/types/client/StickyLane.d.ts.map +1 -1
  41. package/lib/types/client/StickyLane.js +78 -2
  42. package/lib/types/client/StickyLane.js.map +1 -1
  43. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  44. package/lib/types/client/ToolActivity.js +10 -5
  45. package/lib/types/client/ToolActivity.js.map +1 -1
  46. package/lib/types/client/deliverables.d.ts.map +1 -1
  47. package/lib/types/client/deliverables.js +2 -0
  48. package/lib/types/client/deliverables.js.map +1 -1
  49. package/lib/types/client/index.d.ts +3 -1
  50. package/lib/types/client/index.d.ts.map +1 -1
  51. package/lib/types/client/index.js +15 -1
  52. package/lib/types/client/index.js.map +1 -1
  53. package/lib/types/client/live-turn.d.ts +17 -5
  54. package/lib/types/client/live-turn.d.ts.map +1 -1
  55. package/lib/types/client/live-turn.js +106 -76
  56. package/lib/types/client/live-turn.js.map +1 -1
  57. package/lib/types/client/motion.d.ts +26 -2
  58. package/lib/types/client/motion.d.ts.map +1 -1
  59. package/lib/types/client/motion.js +144 -10
  60. package/lib/types/client/motion.js.map +1 -1
  61. package/lib/types/client/official-slots.d.ts +9 -0
  62. package/lib/types/client/official-slots.d.ts.map +1 -1
  63. package/lib/types/client/official-slots.js +22 -4
  64. package/lib/types/client/official-slots.js.map +1 -1
  65. package/lib/types/client/open-file.d.ts.map +1 -1
  66. package/lib/types/client/open-file.js +1 -6
  67. package/lib/types/client/open-file.js.map +1 -1
  68. package/lib/types/client/projection.d.ts +15 -2
  69. package/lib/types/client/projection.d.ts.map +1 -1
  70. package/lib/types/client/projection.js +16 -4
  71. package/lib/types/client/projection.js.map +1 -1
  72. package/lib/types/client/rewind-cut.d.ts +131 -0
  73. package/lib/types/client/rewind-cut.d.ts.map +1 -0
  74. package/lib/types/client/rewind-cut.js +321 -0
  75. package/lib/types/client/rewind-cut.js.map +1 -0
  76. package/lib/types/client/styles.d.ts +3 -0
  77. package/lib/types/client/styles.d.ts.map +1 -0
  78. package/lib/types/client/styles.js +25 -0
  79. package/lib/types/client/styles.js.map +1 -0
  80. package/lib/types/client/text-transform.d.ts +93 -0
  81. package/lib/types/client/text-transform.d.ts.map +1 -0
  82. package/lib/types/client/text-transform.js +183 -0
  83. package/lib/types/client/text-transform.js.map +1 -0
  84. package/lib/types/client/timeline.d.ts.map +1 -1
  85. package/lib/types/client/timeline.js.map +1 -1
  86. package/lib/types/client/tool-activity.d.ts +11 -4
  87. package/lib/types/client/tool-activity.d.ts.map +1 -1
  88. package/lib/types/client/tool-activity.js +54 -9
  89. package/lib/types/client/tool-activity.js.map +1 -1
  90. package/lib/types/client/turn-slots.d.ts +29 -0
  91. package/lib/types/client/turn-slots.d.ts.map +1 -0
  92. package/lib/types/client/turn-slots.js +62 -0
  93. package/lib/types/client/turn-slots.js.map +1 -0
  94. package/lib/types/client/types.d.ts +18 -0
  95. package/lib/types/client/types.d.ts.map +1 -1
  96. package/lib/types/dsh-better-display.d.ts +4 -1
  97. package/lib/types/dsh-better-display.d.ts.map +1 -1
  98. package/lib/types/dsh-better-display.js +55 -43
  99. package/lib/types/dsh-better-display.js.map +1 -1
  100. package/lib/types/flags.d.ts +7 -0
  101. package/lib/types/flags.d.ts.map +1 -0
  102. package/lib/types/flags.js +37 -0
  103. package/lib/types/flags.js.map +1 -0
  104. package/lib/types/log.d.ts +17 -0
  105. package/lib/types/log.d.ts.map +1 -0
  106. package/lib/types/log.js +122 -0
  107. package/lib/types/log.js.map +1 -0
  108. package/lib/types/metrics.d.ts +18 -0
  109. package/lib/types/metrics.d.ts.map +1 -0
  110. package/lib/types/metrics.js +35 -0
  111. package/lib/types/metrics.js.map +1 -0
  112. package/lib/types/reveal.d.ts +12 -0
  113. package/lib/types/reveal.d.ts.map +1 -0
  114. package/lib/types/reveal.js +147 -0
  115. package/lib/types/reveal.js.map +1 -0
  116. package/lib/types/workspace-path.d.ts +3 -0
  117. package/lib/types/workspace-path.d.ts.map +1 -0
  118. package/lib/types/workspace-path.js +5 -0
  119. package/lib/types/workspace-path.js.map +1 -0
  120. package/package.json +36 -5
  121. package/scripts/changelog.mjs +20 -0
  122. package/scripts/check-agents-md.mjs +67 -0
  123. package/scripts/check-bundle-size.mjs +38 -0
  124. package/scripts/check-dead-flags.mjs +55 -0
  125. package/scripts/check-todo-debt.mjs +69 -0
  126. package/scripts/security-audit-report.mjs +92 -0
  127. package/scripts/verify-commentary-visible.mjs +627 -0
  128. package/src/client/AssistantNode.tsx +72 -0
  129. package/src/client/Blocks.tsx +49 -10
  130. package/src/client/ChoreographedFlow.tsx +3 -1
  131. package/src/client/ClosedProcessSummary.tsx +3 -1
  132. package/src/client/McpAppFrame.tsx +5 -13
  133. package/src/client/OfficialContent.tsx +47 -9
  134. package/src/client/Reader.module.css +103 -21
  135. package/src/client/Reader.tsx +83 -88
  136. package/src/client/SettingsSection.tsx +1 -7
  137. package/src/client/StickyLane.tsx +81 -2
  138. package/src/client/ToolActivity.tsx +10 -6
  139. package/src/client/deliverables.ts +1 -0
  140. package/src/client/index.tsx +18 -2
  141. package/src/client/live-turn.ts +100 -69
  142. package/src/client/motion.tsx +142 -7
  143. package/src/client/official-slots.tsx +23 -4
  144. package/src/client/open-file.ts +2 -8
  145. package/src/client/projection.ts +16 -4
  146. package/src/client/rewind-cut.ts +340 -0
  147. package/src/client/styles.ts +22 -0
  148. package/src/client/text-transform.ts +265 -0
  149. package/src/client/timeline.ts +0 -2
  150. package/src/client/tool-activity.ts +67 -9
  151. package/src/client/turn-slots.ts +80 -0
  152. package/src/client/types.ts +19 -0
  153. package/src/dsh-better-display.ts +57 -42
  154. package/src/flags.ts +40 -0
  155. package/src/log.ts +137 -0
  156. package/src/metrics.ts +50 -0
  157. package/src/reveal.ts +90 -0
  158. package/src/workspace-path.ts +4 -0
  159. package/tests/browser-commentary.tsx +191 -0
  160. package/tests/chat-flow-hook.test.ts +1 -1
  161. package/tests/cloud-gate.test.ts +1 -1
  162. package/tests/context-tool-update.test.ts +1 -1
  163. package/tests/conversation-scroll.test.ts +1 -1
  164. package/tests/deliverables.test.ts +1 -1
  165. package/tests/entry.test.ts +1 -1
  166. package/tests/fixtures/reader-lane-owners.tsx +46 -0
  167. package/tests/fixtures/reader-official-users.tsx +234 -0
  168. package/tests/fixtures/reader-theme-overlap.html +80 -0
  169. package/tests/fixtures/reader-user-bubble.tsx +222 -0
  170. package/tests/flags.test.ts +66 -0
  171. package/tests/fold-choreography.test.ts +32 -8
  172. package/tests/fold-intensity.test.ts +11 -6
  173. package/tests/footer-precedence.test.ts +9 -1
  174. package/tests/history-word-regression.test.ts +1 -1
  175. package/tests/live-turn.test.ts +125 -54
  176. package/tests/log.test.ts +99 -0
  177. package/tests/mcp-apps.test.ts +1 -1
  178. package/tests/message-chrome.test.ts +1 -1
  179. package/tests/metrics.test.ts +52 -0
  180. package/tests/official-slots.test.ts +41 -2
  181. package/tests/open-file.test.ts +1 -1
  182. package/tests/pending-submission.test.ts +12 -1
  183. package/tests/pinned-selection.test.ts +16 -0
  184. package/tests/platform-media.test.ts +1 -1
  185. package/tests/projection.test.ts +66 -3
  186. package/tests/reader-glass-css.test.ts +28 -0
  187. package/tests/reader-official-users.test.ts +91 -0
  188. package/tests/reader-sticky-surface.test.ts +377 -0
  189. package/tests/reader-theme-lanes.test.ts +54 -0
  190. package/tests/reader-theme-overlap.test.ts +151 -0
  191. package/tests/reader-user-bubble.test.ts +137 -0
  192. package/tests/reasoning-follow.test.ts +1 -1
  193. package/tests/retiring-content.test.ts +229 -0
  194. package/tests/reveal.test.ts +128 -0
  195. package/tests/rewind-cut.test.ts +1090 -0
  196. package/tests/settings-copy.test.ts +1 -1
  197. package/tests/skill-status.test.ts +1 -1
  198. package/tests/stock-install.test.ts +9 -1
  199. package/tests/stream-buffer.test.ts +1 -1
  200. package/tests/styles.test.ts +75 -0
  201. package/tests/terminal-input-preview.test.ts +113 -0
  202. package/tests/text-transform.test.ts +272 -0
  203. package/tests/timeline.test.ts +1 -1
  204. package/tests/tool-clock.test.ts +1 -1
  205. package/tests/tool-diff.test.ts +1 -1
  206. package/tests/waiting-clock.test.ts +1 -1
  207. package/tests/word-timeline.test.ts +1 -1
  208. package/tsdown.config.ts +49 -1
@@ -0,0 +1,151 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createServer } from 'node:http';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { inflateSync } from 'node:zlib';
8
+ import { test } from 'vitest';
9
+
10
+ const browserRuntime = join(homedir(), '.codex/playwright-runtime/runtime.mjs');
11
+ const skip = existsSync(browserRuntime) ? false : 'pinned Chromium runtime not available';
12
+
13
+ function paeth(left: number, up: number, upLeft: number) {
14
+ const estimate = left + up - upLeft;
15
+ const leftDistance = Math.abs(estimate - left);
16
+ const upDistance = Math.abs(estimate - up);
17
+ const diagonal = Math.abs(estimate - upLeft);
18
+ if (leftDistance <= upDistance && leftDistance <= diagonal) return left;
19
+ if (upDistance <= diagonal) return up;
20
+ return upLeft;
21
+ }
22
+
23
+ function pngPixel(buffer: Buffer, x: number, y: number) {
24
+ let offset = 8;
25
+ const chunks: Buffer[] = [];
26
+ let width = 0;
27
+ let height = 0;
28
+ let colorType = 0;
29
+ while (offset < buffer.length) {
30
+ const length = buffer.readUInt32BE(offset);
31
+ const type = buffer.toString('ascii', offset + 4, offset + 8);
32
+ const data = buffer.subarray(offset + 8, offset + 8 + length);
33
+ if (type === 'IHDR') {
34
+ width = data.readUInt32BE(0);
35
+ height = data.readUInt32BE(4);
36
+ colorType = data[9] ?? 0;
37
+ } else if (type === 'IDAT') chunks.push(data);
38
+ else if (type === 'IEND') break;
39
+ offset += 12 + length;
40
+ }
41
+ const bytesPerPixel = colorType === 6 ? 4 : 3;
42
+ const raw = inflateSync(Buffer.concat(chunks));
43
+ const stride = width * bytesPerPixel;
44
+ let source = 0;
45
+ let previous = Buffer.alloc(stride);
46
+ const rows: Buffer[] = [];
47
+ for (let rowIndex = 0; rowIndex < height; rowIndex += 1) {
48
+ const filter = raw[source] ?? 0;
49
+ source += 1;
50
+ const row = Buffer.alloc(stride);
51
+ for (let index = 0; index < stride; index += 1) {
52
+ const value = raw[source] ?? 0;
53
+ source += 1;
54
+ const left = index >= bytesPerPixel ? row[index - bytesPerPixel] ?? 0 : 0;
55
+ const up = previous[index] ?? 0;
56
+ const upLeft = index >= bytesPerPixel ? previous[index - bytesPerPixel] ?? 0 : 0;
57
+ let decoded = value;
58
+ if (filter === 1) decoded = value + left;
59
+ else if (filter === 2) decoded = value + up;
60
+ else if (filter === 3) decoded = value + Math.floor((left + up) / 2);
61
+ else if (filter === 4) decoded = value + paeth(left, up, upLeft);
62
+ else if (filter !== 0) throw new Error(`unsupported png filter ${filter}`);
63
+ row[index] = decoded & 255;
64
+ }
65
+ rows.push(row);
66
+ previous = row;
67
+ }
68
+ const pixel = rows[y];
69
+ const start = x * bytesPerPixel;
70
+ return [pixel?.[start] ?? 0, pixel?.[start + 1] ?? 0, pixel?.[start + 2] ?? 0];
71
+ }
72
+
73
+ test('enlarged theme tokens do not let the answer heading share a lane', { skip }, async () => {
74
+ const root = resolve(import.meta.dirname, '..');
75
+ const files = new Map([
76
+ ['/Reader.module.css', readFileSync(join(root, 'src/client/Reader.module.css'))],
77
+ ['/MarkdownText.module.css', readFileSync(join(root, 'src/client/markdown/MarkdownText.module.css'))],
78
+ ['/', readFileSync(join(root, 'tests/fixtures/reader-theme-overlap.html'))],
79
+ ]);
80
+ const server = createServer((request, response) => {
81
+ const body = files.get(request.url ?? '/');
82
+ if (!body) { response.statusCode = 404; response.end(); return; }
83
+ response.setHeader('Content-Type', request.url?.endsWith('.css') ? 'text/css' : 'text/html');
84
+ response.end(body);
85
+ });
86
+ await new Promise<void>(resolveListen => server.listen(0, '127.0.0.1', resolveListen));
87
+ const address = server.address();
88
+ assert.ok(address && typeof address === 'object');
89
+ const { launchPinnedChromium } = await import(pathToFileURL(browserRuntime).href);
90
+ const browser = await launchPinnedChromium();
91
+ try {
92
+ const page = await browser.newPage({ viewport: { width: 1000, height: 320 }, deviceScaleFactor: 1 });
93
+ const errors: string[] = [];
94
+ page.on('pageerror', (error: Error) => errors.push(String(error)));
95
+ await page.goto(`http://127.0.0.1:${address.port}/`);
96
+ await page.waitForFunction(() => Reflect.get(window, '__themeOverlapReady') === true);
97
+ const atRest = await page.evaluate(() => {
98
+ const box = (selector: string) => {
99
+ const rect = document.querySelector(selector)!.getBoundingClientRect();
100
+ return { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left };
101
+ };
102
+ const overlaps = (a: { top: number; right: number; bottom: number; left: number }, b: typeof a) =>
103
+ a.left < b.right - 1 && a.right > b.left + 1 && a.top < b.bottom - 1 && a.bottom > b.top + 1;
104
+ const status = box('[data-reader-lane="status"]');
105
+ const summary = box('#summary');
106
+ const heading = box('#heading');
107
+ return {
108
+ statusOverlapsSummary: overlaps(status, summary),
109
+ headingOverlapsSummary: overlaps(heading, summary),
110
+ headingBelowSummary: heading.top >= summary.bottom - 1,
111
+ };
112
+ });
113
+ assert.equal(atRest.statusOverlapsSummary, false);
114
+ assert.equal(atRest.headingOverlapsSummary, false);
115
+ assert.equal(atRest.headingBelowSummary, true);
116
+
117
+ await page.evaluate(() => {
118
+ const scroll = document.querySelector<HTMLElement>('#scroll')!;
119
+ const summary = document.querySelector('#summary')!.getBoundingClientRect();
120
+ const heading = document.querySelector('#heading')!.getBoundingClientRect();
121
+ scroll.scrollTop += heading.top - summary.top + 8;
122
+ });
123
+ const sample = await page.evaluate(() => {
124
+ const summary = document.querySelector('#summary')!.getBoundingClientRect();
125
+ const diff = document.querySelector('#summary .diffStatRoot')!.getBoundingClientRect();
126
+ return { x: Math.max(summary.left + 8, diff.left - 24), y: summary.top + 2 };
127
+ });
128
+ const shot = await page.screenshot({
129
+ clip: { x: sample.x, y: sample.y, width: 1, height: 1 },
130
+ scale: 'css',
131
+ });
132
+ const [red, green, blue] = pngPixel(shot, 0, 0);
133
+ assert.ok(Math.abs(red - 16) < 12 && Math.abs(green - 32) < 12 && Math.abs(blue - 64) < 12, `lane plate pixel was ${red},${green},${blue}`);
134
+
135
+ await page.evaluate(() => {
136
+ const scroll = document.querySelector<HTMLElement>('#scroll')!;
137
+ scroll.scrollTop = scroll.scrollHeight;
138
+ });
139
+ const stuck = await page.evaluate(() => {
140
+ const status = document.querySelector('#flow-status')!.getBoundingClientRect();
141
+ const summary = document.querySelector('#flow-summary')!.getBoundingClientRect();
142
+ return { statusBottom: status.bottom, summaryTop: summary.top, statusHeight: status.height };
143
+ });
144
+ assert.ok(stuck.statusHeight > 40, `expected the enlarged status row to exceed 40px, got ${stuck.statusHeight}`);
145
+ assert.ok(stuck.summaryTop >= stuck.statusBottom - 1, `summary top ${stuck.summaryTop} overlaps status bottom ${stuck.statusBottom}`);
146
+ assert.deepEqual(errors, []);
147
+ } finally {
148
+ await browser.close();
149
+ await new Promise<void>(resolveClose => server.close(() => resolveClose()));
150
+ }
151
+ });
@@ -0,0 +1,137 @@
1
+ import assert from 'node:assert/strict';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { transform } from 'lightningcss';
9
+ import { test } from 'vitest';
10
+ import { OFFICIAL_SEATS, OFFICIAL_SLOTS } from '../src/client/official-slots.js';
11
+
12
+ // Width is a layout fact of the real Reader and the host user/steering renderer.
13
+ // This skips in CI, which has no pinned Chromium and no Harness tree.
14
+ const harnessPointer = join(homedir(), '.config/dshx/harness');
15
+ const harness = process.env.DSHX_HARNESS?.trim()
16
+ || (existsSync(harnessPointer) ? readFileSync(harnessPointer, 'utf8').trim() : '');
17
+ const browserRuntime = join(homedir(), '.codex/playwright-runtime/runtime.mjs');
18
+ const skip = !harness ? 'DSH Harness source not available'
19
+ : !existsSync(browserRuntime) ? 'pinned Chromium runtime not available' : false;
20
+
21
+ interface Box { w: number; h: number; font: string; line: string }
22
+ interface Row {
23
+ name: string;
24
+ user: Box; steering: Box; pending: Box;
25
+ nativeUser: Box; nativeSteering: Box;
26
+ readerHtml: string;
27
+ }
28
+ interface BubbleResult {
29
+ error?: string;
30
+ readerSlot?: string;
31
+ nativeSlot?: string;
32
+ rows?: Row[];
33
+ }
34
+
35
+ test('reader user and steering bubbles match the host column cap', { skip, timeout: 120_000 }, async () => {
36
+ const root = resolve(import.meta.dirname, '..');
37
+ const require = createRequire(join(root, 'package.json'));
38
+ const { build } = createRequire(require.resolve('tsx'))('esbuild');
39
+ const webRequire = createRequire(join(harness, 'packages/client/web/package.json'));
40
+ const out = mkdtempSync(join(tmpdir(), 'reader-user-bubble-'));
41
+ const cssParts: string[] = [];
42
+ try {
43
+ const { launchPinnedChromium } = await import(pathToFileURL(browserRuntime).href);
44
+ await build({
45
+ entryPoints: [join(root, 'tests/fixtures/reader-user-bubble.tsx')],
46
+ outfile: join(out, 'fixture.js'), bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
47
+ alias: {
48
+ '@fixture/registry': join(harness, 'packages/client/ui-renderer/src/client/registry.ts'),
49
+ '@fixture/renderer': join(harness, 'packages/client/ui-renderer/src/client/scoped-slots.tsx'),
50
+ '@fixture/user-message': join(harness, 'packages/client/ui-chat/src/client/chat/MessageItem.tsx'),
51
+ '@deepseek-ai/dsh-client-ui-primitives': join(harness, 'packages/client/ui-primitives/src/index.ts'),
52
+ '@deepseek-ai/dsh-client-ui-slots': join(harness, 'packages/client/ui-slots/src/index.ts'),
53
+ '@deepseek-ai/dsh-client-store': join(harness, 'packages/client/store/src/index.ts'),
54
+ react: dirname(require.resolve('react/package.json')),
55
+ 'react-dom': dirname(webRequire.resolve('react-dom/package.json')),
56
+ },
57
+ loader: { '.woff2': 'empty', '.woff': 'empty', '.ttf': 'empty', '.png': 'empty', '.jpg': 'empty', '.webp': 'empty' },
58
+ plugins: [{
59
+ name: 'css-modules',
60
+ setup(build: { onLoad: Function; onEnd: Function }) {
61
+ build.onLoad({ filter: /\.module\.css$/ }, (args: { path: string }) => {
62
+ const result = transform({ filename: args.path, code: readFileSync(args.path), cssModules: { pattern: 't[hash]_[local]' } });
63
+ const mapping: Record<string, string> = {};
64
+ for (const [key, value] of Object.entries(result.exports ?? {})) mapping[key] = value.name;
65
+ cssParts.push(Buffer.from(result.code).toString('utf8'));
66
+ return { contents: `export default ${JSON.stringify(mapping)};`, loader: 'js' };
67
+ });
68
+ build.onLoad({ filter: /\.css$/ }, (args: { path: string }) => {
69
+ cssParts.push(readFileSync(args.path, 'utf8'));
70
+ return { contents: 'export default {};', loader: 'js' };
71
+ });
72
+ build.onLoad({ filter: /\.svg$/ }, () => ({ contents: 'export default function Svg(){return null}', loader: 'js' }));
73
+ build.onEnd(() => { writeFileSync(join(out, 'fixture.css'), cssParts.join('\n')); });
74
+ },
75
+ }],
76
+ logLevel: 'silent',
77
+ });
78
+ const server = createServer((request, response) => {
79
+ if (request.url === '/fixture.js') {
80
+ response.setHeader('Content-Type', 'text/javascript');
81
+ response.end(readFileSync(join(out, 'fixture.js')));
82
+ } else if (request.url === '/fixture.css') {
83
+ response.setHeader('Content-Type', 'text/css');
84
+ response.end(readFileSync(join(out, 'fixture.css')));
85
+ } else {
86
+ response.setHeader('Content-Type', 'text/html');
87
+ response.end('<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/fixture.css"><style>html,body{margin:0;font-family:"PingFang SC","Hiragino Sans GB",sans-serif}</style><div id="app"></div><script type="module" src="/fixture.js"></script>');
88
+ }
89
+ });
90
+ await new Promise<void>(resolveListen => server.listen(0, '127.0.0.1', resolveListen));
91
+ let browser;
92
+ try {
93
+ browser = await launchPinnedChromium();
94
+ const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
95
+ const errors: string[] = [];
96
+ page.on('pageerror', (error: Error) => errors.push(String(error)));
97
+ page.on('console', (message: { type(): string; text(): string }) => {
98
+ if (message.type() === 'error') errors.push(message.text());
99
+ });
100
+ const address = server.address();
101
+ assert.ok(address && typeof address === 'object');
102
+ await page.goto(`http://127.0.0.1:${address.port}`);
103
+ await page.waitForFunction(() => 'readerBubbleResult' in window, undefined, { timeout: 20000 });
104
+ const result = await page.evaluate(() => Reflect.get(window, 'readerBubbleResult')) as BubbleResult;
105
+ assert.equal(result.error, undefined, result.error);
106
+ assert.deepEqual(errors, []);
107
+ assert.equal(result.readerSlot, OFFICIAL_SEATS.nodes);
108
+ assert.equal(result.nativeSlot, OFFICIAL_SLOTS.nodes);
109
+ assert.notEqual(result.readerSlot, 'conversation.chat.node');
110
+ for (const row of result.rows ?? []) {
111
+ assert.equal(row.user.w, row.nativeUser.w, `${row.name} user width ${row.user.w} != ${row.nativeUser.w} ${row.readerHtml}`);
112
+ assert.equal(row.user.h, row.nativeUser.h, `${row.name} user height`);
113
+ assert.equal(row.steering.w, row.nativeSteering.w, `${row.name} steering width`);
114
+ assert.equal(row.steering.h, row.nativeSteering.h, `${row.name} steering height`);
115
+ assert.equal(row.pending.w, row.nativeUser.w, `${row.name} pending width`);
116
+ assert.equal(row.pending.h, row.nativeUser.h, `${row.name} pending height`);
117
+ assert.equal(row.pending.font, '14px', `${row.name} pending font`);
118
+ assert.equal(row.pending.line, '22px', `${row.name} pending line`);
119
+ assert.equal(row.nativeUser.font, '14px', `${row.name} native font`);
120
+ assert.equal(row.nativeUser.line, '22px', `${row.name} native line`);
121
+ }
122
+ const byName = Object.fromEntries((result.rows ?? []).map(row => [row.name, row]));
123
+ assert.ok(byName.short.user.h < 50, 'a four-glyph message stays on one line');
124
+ assert.ok(byName.short.user.w < 160, 'a four-glyph message stays content-sized');
125
+ assert.ok(byName.thirty.user.h < 50, 'thirty glyphs fit on one line at the default column');
126
+ assert.ok(Math.abs(byName.long.user.w - 748 * 0.702) < 1, 'the default cap follows 748 * 0.702');
127
+ assert.ok(byName['wide-axis'].user.w > 540, 'a wider content axis is not stuck at 525px');
128
+ assert.ok(Math.abs(byName.narrow.user.w - 480 * 0.82) < 1, 'a narrow column uses 82% of the column');
129
+ assert.ok(byName.short.steering.h < 50, 'a short steering message stays on one line');
130
+ } finally {
131
+ await browser?.close();
132
+ await new Promise<void>(resolveClose => server.close(() => resolveClose()));
133
+ }
134
+ } finally {
135
+ rmSync(out, { recursive: true, force: true });
136
+ }
137
+ });
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
2
+ import { test } from 'vitest';
3
3
  import { reasoningTarget } from '../src/client/reasoning-follow.js';
4
4
 
5
5
  test('reasoning follows ordinary growth in two-line steps without passing the tail', () => {
@@ -0,0 +1,229 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'vitest';
3
+ import { readFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { runInNewContext } from 'node:vm';
6
+ import ts from 'typescript';
7
+
8
+ const source = readFileSync(new URL('../src/client/motion.tsx', import.meta.url), 'utf8');
9
+ const retiring = source.slice(source.indexOf('export function RetiringContent('), source.indexOf('// DOM-only behavior:'));
10
+ const compiled = ts.transpileModule(`const EASING = 'cubic-bezier(.22,1,.36,1)';\n${retiring}`, {
11
+ fileName: 'retiring.tsx',
12
+ compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
13
+ }).outputText;
14
+
15
+ // Drive the component's real effect with deterministic hooks and a compositor
16
+ // that never finishes unless the test explicitly sends onfinish.
17
+ function mount(options?: { containsFocus?: boolean; hidden?: boolean }) {
18
+ let cursor = 0;
19
+ let enabled = true;
20
+ const hooks: unknown[] = [];
21
+ const effects = new Map<number, { deps: unknown[]; cleanup?: () => void }>();
22
+ let pending: Array<{ index: number; run: () => (() => void) | void; deps: unknown[] }> = [];
23
+ let timerId = 0;
24
+ const timers = new Map<number, { run: () => void; duration: number }>();
25
+ const animations: Array<{ onfinish?: () => void; cancelled: number }> = [];
26
+ const cutFocus = { id: 'focus-cut' };
27
+ const outside = { id: 'outside' };
28
+ const listeners = new Set<() => void>();
29
+ const column = {
30
+ id: 'reader-column',
31
+ tabIndex: -1,
32
+ focus() { page.active = column; },
33
+ };
34
+ const page = {
35
+ hidden: options?.hidden ?? false,
36
+ active: (options?.containsFocus ? cutFocus : null) as { id: string } | null,
37
+ };
38
+ const element = {
39
+ contains: (node: unknown) => node === cutFocus,
40
+ closest: (selector: string) => selector === '[data-chat-flow]' ? column : null,
41
+ parentElement: null,
42
+ getBoundingClientRect: () => ({ height: 64 }),
43
+ animate: (_frames: unknown, options: { duration: number; fill: string }) => {
44
+ assert.equal(options.duration, 220);
45
+ assert.equal(options.fill, 'both');
46
+ const animation = { cancelled: 0, cancel() { this.cancelled++; } };
47
+ animations.push(animation);
48
+ return animation;
49
+ },
50
+ };
51
+ const effect = (run: () => (() => void) | void, deps: unknown[]) => {
52
+ const index = cursor++;
53
+ const previous = effects.get(index);
54
+ if (!previous || deps.some((value, at) => value !== previous.deps[at])) pending.push({ index, run, deps });
55
+ };
56
+ const exports: { RetiringContent?: (props: { visible: boolean; children: string }) => unknown } = {};
57
+ runInNewContext(compiled, {
58
+ exports,
59
+ require: createRequire(import.meta.url),
60
+ css: { retiringContent: 'retiring' },
61
+ StreamMotionContext: {},
62
+ HTMLElement: globalThis.HTMLElement,
63
+ Element: globalThis.Element,
64
+ document: {
65
+ get activeElement() { return page.active; },
66
+ get hidden() { return page.hidden; },
67
+ addEventListener: (type: string, listener: () => void) => { if (type === 'visibilitychange') listeners.add(listener); },
68
+ removeEventListener: (type: string, listener: () => void) => { if (type === 'visibilitychange') listeners.delete(listener); },
69
+ },
70
+ window: {
71
+ setTimeout: (run: () => void, duration: number) => { timers.set(++timerId, { run, duration }); return timerId; },
72
+ clearTimeout: (id: number) => { timers.delete(id); },
73
+ },
74
+ useContext: () => ({ enabled }),
75
+ useRef: (initial: unknown) => {
76
+ const index = cursor++;
77
+ if (!(index in hooks)) hooks[index] = { current: initial };
78
+ return hooks[index];
79
+ },
80
+ useState: (initial: unknown) => {
81
+ const index = cursor++;
82
+ if (!(index in hooks)) hooks[index] = initial;
83
+ return [hooks[index], (value: unknown) => { hooks[index] = value; }];
84
+ },
85
+ useLayoutEffect: effect,
86
+ useEffect: effect,
87
+ });
88
+ assert.equal(typeof exports.RetiringContent, 'function');
89
+ type Rendered = { ref: { current: unknown }; props: { onBlur: (event: unknown) => void } };
90
+ let last: Rendered | null = null;
91
+ const render = (visible: boolean) => {
92
+ cursor = 0;
93
+ pending = [];
94
+ const result = exports.RetiringContent!({ visible, children: 'narration' }) as Rendered | null;
95
+ last = result;
96
+ if (result) result.ref.current = element;
97
+ for (const { index, run, deps } of pending) {
98
+ effects.get(index)?.cleanup?.();
99
+ effects.set(index, { deps, cleanup: run() ?? undefined });
100
+ }
101
+ return result;
102
+ };
103
+ render(true);
104
+ return {
105
+ render, timers, animations, listeners,
106
+ focusId: () => page.active?.id ?? '',
107
+ // Move focus out of the cut the way a real blur reports it.
108
+ blur: () => {
109
+ page.active = outside;
110
+ last?.props.onBlur({ currentTarget: element, relatedTarget: outside });
111
+ },
112
+ hide: () => {
113
+ page.hidden = true;
114
+ for (const listener of [...listeners]) listener();
115
+ },
116
+ disableMotion: () => { enabled = false; },
117
+ unmount: () => { for (const effect of effects.values()) effect.cleanup?.(); },
118
+ deadline: () => {
119
+ const [id, timer] = [...timers][0] ?? [];
120
+ assert.ok(timer, 'retiring animation must have a deadline');
121
+ assert.equal(timer.duration, 460);
122
+ timers.delete(id);
123
+ timer.run();
124
+ },
125
+ };
126
+ }
127
+
128
+ test('retiring narration unmounts on its deadline when onfinish never fires', () => {
129
+ const view = mount();
130
+ assert.ok(view.render(false));
131
+ view.deadline();
132
+ assert.equal(view.animations[0].cancelled, 1);
133
+ assert.equal(view.render(false), null);
134
+ view.unmount();
135
+ });
136
+
137
+ test('normal finish settles once and removes the deadline on cleanup', () => {
138
+ const view = mount();
139
+ view.render(false);
140
+ view.animations[0].onfinish?.();
141
+ view.animations[0].onfinish?.();
142
+ assert.equal(view.animations[0].cancelled, 1);
143
+ assert.equal(view.render(false), null);
144
+ view.unmount();
145
+ assert.equal(view.timers.size, 0);
146
+ });
147
+
148
+ test('visibility reversal and unmount cancel the deadline and release animation fill', () => {
149
+ const view = mount();
150
+ view.render(false);
151
+ const staleFinish = view.animations[0].onfinish;
152
+ assert.ok(view.render(true));
153
+ assert.equal(view.timers.size, 0);
154
+ assert.equal(view.animations[0].cancelled, 1);
155
+ staleFinish?.();
156
+ assert.ok(view.render(true));
157
+ view.render(false);
158
+ view.unmount();
159
+ assert.equal(view.timers.size, 0);
160
+ assert.equal(view.animations[1].cancelled, 1);
161
+ });
162
+
163
+ test('disabled motion retires immediately without an animation or timer', () => {
164
+ const view = mount();
165
+ view.disableMotion();
166
+ view.render(false);
167
+ assert.equal(view.render(false), null);
168
+ assert.equal(view.animations.length, 0);
169
+ assert.equal(view.timers.size, 0);
170
+ view.unmount();
171
+ });
172
+
173
+ test('reduced motion moves focus out of cut content before it unmounts', () => {
174
+ const reduced = mount({ containsFocus: true });
175
+ reduced.disableMotion();
176
+ reduced.render(false);
177
+ assert.equal(reduced.render(false), null);
178
+ assert.equal(reduced.focusId(), 'reader-column');
179
+ assert.equal(reduced.animations.length, 0);
180
+ reduced.unmount();
181
+
182
+ const hidden = mount({ containsFocus: true, hidden: true });
183
+ hidden.render(false);
184
+ assert.equal(hidden.render(false), null);
185
+ assert.equal(hidden.focusId(), 'reader-column');
186
+ assert.equal(hidden.animations.length, 0);
187
+ hidden.unmount();
188
+
189
+ // The animated path still defers while focus is inside the cut.
190
+ const playing = mount({ containsFocus: true });
191
+ assert.ok(playing.render(false));
192
+ assert.equal(playing.focusId(), 'focus-cut');
193
+ assert.equal(playing.animations.length, 0);
194
+ playing.unmount();
195
+ });
196
+
197
+ test('a page hidden while focus defers the collapse retires at once and keeps focus', () => {
198
+ const view = mount({ containsFocus: true });
199
+ assert.ok(view.render(false));
200
+ // The focus-held state re-runs the effect; it must keep waiting, not animate.
201
+ assert.ok(view.render(false));
202
+ assert.equal(view.focusId(), 'focus-cut');
203
+ assert.equal(view.animations.length, 0);
204
+ assert.equal(view.listeners.size, 1, 'the focus-held path still listens for a hidden page');
205
+ view.hide();
206
+ assert.equal(view.render(false), null);
207
+ assert.equal(view.focusId(), 'reader-column');
208
+ assert.equal(view.animations.length, 0);
209
+ assert.equal(view.timers.size, 0);
210
+ view.unmount();
211
+ assert.equal(view.listeners.size, 0);
212
+ });
213
+
214
+ test('focus leaving the cut still plays the collapse, bounded by its deadline', () => {
215
+ const view = mount({ containsFocus: true });
216
+ view.render(false);
217
+ view.render(false);
218
+ assert.equal(view.animations.length, 0);
219
+ view.blur();
220
+ assert.ok(view.render(false));
221
+ assert.equal(view.animations.length, 1);
222
+ assert.equal(view.listeners.size, 1);
223
+ view.deadline();
224
+ assert.equal(view.render(false), null);
225
+ assert.equal(view.focusId(), 'outside');
226
+ view.unmount();
227
+ assert.equal(view.listeners.size, 0);
228
+ assert.equal(view.timers.size, 0);
229
+ });
@@ -0,0 +1,128 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'vitest';
3
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { IncomingMessage, ServerResponse } from 'node:http';
5
+ import { Socket } from 'node:net';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import { createRevealHandler, resolveRevealPath, revealPath, revealRoots } from '../src/reveal.js';
10
+ import { isAbsoluteWorkspacePath } from '../src/workspace-path.js';
11
+ import { dirname } from '../src/client/deliverables.js';
12
+
13
+ test('absolute parent predicate accepts POSIX, drive and UNC roots only', () => {
14
+ for (const path of ['/', '/repo', 'C:\\', 'C:/', 'D:\\5- AI\\docs', '\\\\server\\share']) {
15
+ assert.equal(isAbsoluteWorkspacePath(path), true, path);
16
+ }
17
+ for (const path of ['', '.', 'docs', './docs', '../docs', 'C:', 'C:docs', '\\docs']) {
18
+ assert.equal(isAbsoluteWorkspacePath(path), false, path);
19
+ }
20
+ assert.equal(dirname('/a.txt'), '/');
21
+ assert.equal(dirname('C:\\a.txt'), 'C:\\');
22
+ assert.equal(dirname('C:/a.txt'), 'C:/');
23
+ assert.equal(isAbsoluteWorkspacePath(dirname('docs/a.txt')), false);
24
+ });
25
+
26
+ async function workspace(t: { onTestFinished: (cleanup: () => Promise<void>) => void }) {
27
+ const root = await mkdtemp(join(tmpdir(), 'bd-reveal-'));
28
+ t.onTestFinished(() => rm(root, { recursive: true, force: true }));
29
+ await mkdir(join(root, 'docs'));
30
+ await writeFile(join(root, 'docs', 'report.md'), 'report');
31
+ return root;
32
+ }
33
+
34
+ test('relative resolution uses an existing full path, rejects missing and ambiguous matches', async t => {
35
+ const root = await workspace(t);
36
+ const other = await workspace(t);
37
+ assert.equal(resolveRevealPath('docs/report.md', [root]), join(root, 'docs', 'report.md'));
38
+ assert.equal(resolveRevealPath('docs/report.md', ['/missing-root', root, root]), join(root, 'docs', 'report.md'));
39
+ assert.throws(() => resolveRevealPath('docs/missing.md', [root]), /not found/);
40
+ assert.throws(() => resolveRevealPath('docs/report.md', []), /not found/);
41
+ assert.throws(() => resolveRevealPath('docs/report.md', [root, other]), /ambiguous/);
42
+ assert.equal(resolveRevealPath('/absolute/file', []), '/absolute/file');
43
+ });
44
+
45
+ test('relative resolution preserves Windows drive and UNC spelling', () => {
46
+ const exists = (path: string) => ['D:\\5- AI\\docs\\report.md', '\\\\server\\share\\docs\\report.md'].includes(path);
47
+ assert.equal(resolveRevealPath('docs/report.md', ['D:\\5- AI'], exists), 'D:\\5- AI\\docs\\report.md');
48
+ assert.equal(resolveRevealPath('docs/report.md', ['\\\\server\\share'], exists), '\\\\server\\share\\docs\\report.md');
49
+ assert.throws(() => resolveRevealPath('C:report.md', ['D:\\5- AI'], exists), /not workspace-relative/);
50
+ assert.throws(() => resolveRevealPath('\\report.md', ['D:\\5- AI'], exists), /not workspace-relative/);
51
+ });
52
+
53
+ function context(services: Record<string, unknown>): Context {
54
+ return { get: (name: string) => services[name] } as unknown as Context;
55
+ }
56
+
57
+ test('host roots use only the target live session or its registered workspace', async () => {
58
+ const ctx = context({
59
+ sessions: { get: (id: string) => id === 'target' ? { header: { cwd: '/target' } } : undefined },
60
+ workspaceRegistry: { list: () => [
61
+ { path: '/other', sessionIds: ['other'] },
62
+ { path: '/cold', sessionIds: ['cold'] },
63
+ ] },
64
+ });
65
+ assert.deepEqual(await revealRoots(ctx, 'target'), ['/target']);
66
+ assert.deepEqual(await revealRoots(ctx, 'cold'), ['/cold']);
67
+ assert.deepEqual(await revealRoots(ctx, 'unknown'), []);
68
+ assert.deepEqual(await revealRoots(ctx, undefined), ['/other', '/cold']);
69
+ });
70
+
71
+ test('cold session roots are read without projections and the observation is disposed', async () => {
72
+ let disposed = false;
73
+ const ctx = context({ sessionQuery: {
74
+ observeSession: async (id: string, options: unknown) => {
75
+ assert.equal(id, 'cold');
76
+ assert.deepEqual(options, { projectionMode: 'none' });
77
+ return { header: { cwd: '/cold' }, [Symbol.dispose]: () => { disposed = true; } };
78
+ },
79
+ } });
80
+ assert.deepEqual(await revealRoots(ctx, 'cold'), ['/cold']);
81
+ assert.equal(disposed, true);
82
+ });
83
+
84
+ async function request(handler: ReturnType<typeof createRevealHandler>, body: string, method = 'POST') {
85
+ const req = new IncomingMessage(new Socket());
86
+ req.method = method;
87
+ const res = new ServerResponse(req);
88
+ const response = new Promise<{ status: number; body: string }>(resolve => {
89
+ res.end = (chunk: string) => {
90
+ resolve({ status: res.statusCode, body: chunk ?? '' });
91
+ return res;
92
+ };
93
+ });
94
+ handler(req, res);
95
+ req.emit('data', body);
96
+ req.emit('end');
97
+ return response;
98
+ }
99
+
100
+ test('reveal route resolves a session-relative request and never opens a missing path', async t => {
101
+ const root = await workspace(t);
102
+ const opened: string[] = [];
103
+ const handler = createRevealHandler({
104
+ roots: async sessionId => { assert.equal(sessionId, 'target'); return [root]; },
105
+ reveal: path => { opened.push(path); },
106
+ });
107
+ assert.deepEqual(await request(handler, JSON.stringify({ path: 'docs/report.md', sessionId: 'target' })), {
108
+ status: 200, body: '{"ok":true}',
109
+ });
110
+ assert.equal((await request(handler, JSON.stringify({ path: 'docs/missing.md', sessionId: 'target' }))).status, 400);
111
+ assert.deepEqual(opened, [join(root, 'docs', 'report.md')]);
112
+ assert.equal((await request(handler, '{}')).status, 400);
113
+ assert.equal((await request(handler, 'invalid')).status, 400);
114
+ assert.equal((await request(handler, '', 'GET')).status, 405);
115
+ });
116
+
117
+ test('absolute requests bypass root lookup and Explorer receives a quoted verbatim selection', async () => {
118
+ const path = 'D:\\5- AI\\docs\\report.md';
119
+ const calls: unknown[] = [];
120
+ const handler = createRevealHandler({
121
+ roots: async () => { throw new Error('absolute requests do not need roots'); },
122
+ reveal: target => revealPath(target, 'win32', (...args) => { calls.push(args); }),
123
+ });
124
+ assert.equal((await request(handler, JSON.stringify({ path }))).status, 200);
125
+ assert.deepEqual(calls, [['explorer.exe', ['/select,"D:\\5- AI\\docs\\report.md"'], {
126
+ detached: true, stdio: 'ignore', windowsVerbatimArguments: true,
127
+ }]]);
128
+ });