dsh-better-display 0.2.1 → 0.3.2

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 (98) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.en.md +5 -3
  3. package/README.md +5 -3
  4. package/compat/harness-rc2.json +97 -0
  5. package/docs/official-rendering-bridge.md +65 -0
  6. package/lib/client.js +6476 -5977
  7. package/lib/client.js.map +1 -1
  8. package/lib/dsh-better-display.js +4 -2
  9. package/lib/types/client/Blocks.d.ts +2 -1
  10. package/lib/types/client/Blocks.d.ts.map +1 -1
  11. package/lib/types/client/Blocks.js +2 -2
  12. package/lib/types/client/Blocks.js.map +1 -1
  13. package/lib/types/client/OfficialContent.d.ts +25 -0
  14. package/lib/types/client/OfficialContent.d.ts.map +1 -0
  15. package/lib/types/client/OfficialContent.js +53 -0
  16. package/lib/types/client/OfficialContent.js.map +1 -0
  17. package/lib/types/client/Reader.d.ts.map +1 -1
  18. package/lib/types/client/Reader.js +42 -11
  19. package/lib/types/client/Reader.js.map +1 -1
  20. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  21. package/lib/types/client/ToolActivity.js +23 -19
  22. package/lib/types/client/ToolActivity.js.map +1 -1
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +23 -33
  25. package/lib/types/client/index.js.map +1 -1
  26. package/lib/types/client/markdown/MarkdownText.d.ts +3 -1
  27. package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -1
  28. package/lib/types/client/markdown/MarkdownText.js +14 -7
  29. package/lib/types/client/markdown/MarkdownText.js.map +1 -1
  30. package/lib/types/client/markdown/render.d.ts +2 -0
  31. package/lib/types/client/markdown/render.d.ts.map +1 -1
  32. package/lib/types/client/markdown/render.js +22 -6
  33. package/lib/types/client/markdown/render.js.map +1 -1
  34. package/lib/types/client/motion.d.ts.map +1 -1
  35. package/lib/types/client/motion.js +22 -13
  36. package/lib/types/client/motion.js.map +1 -1
  37. package/lib/types/client/native/ContextInjectionRow.d.ts +12 -3
  38. package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
  39. package/lib/types/client/native/ContextInjectionRow.js +7 -4
  40. package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
  41. package/lib/types/client/native/ReferenceIcon.js +3 -3
  42. package/lib/types/client/native/ReferenceIcon.js.map +1 -1
  43. package/lib/types/client/native/tool-call-model.js +1 -1
  44. package/lib/types/client/native/tool-call-model.js.map +1 -1
  45. package/lib/types/client/official-slots.d.ts +71 -0
  46. package/lib/types/client/official-slots.d.ts.map +1 -0
  47. package/lib/types/client/official-slots.js +183 -0
  48. package/lib/types/client/official-slots.js.map +1 -0
  49. package/lib/types/client/platform-media.d.ts +6 -0
  50. package/lib/types/client/platform-media.d.ts.map +1 -0
  51. package/lib/types/client/platform-media.js +13 -0
  52. package/lib/types/client/platform-media.js.map +1 -0
  53. package/lib/types/client/primitive-labels.d.ts.map +1 -1
  54. package/lib/types/client/primitive-labels.js +3 -2
  55. package/lib/types/client/primitive-labels.js.map +1 -1
  56. package/lib/types/client/settings.js.map +1 -1
  57. package/lib/types/client/store.d.ts +1 -0
  58. package/lib/types/client/store.d.ts.map +1 -1
  59. package/lib/types/client/store.js +1 -0
  60. package/lib/types/client/store.js.map +1 -1
  61. package/lib/types/client/tool-activity.d.ts +1 -1
  62. package/lib/types/client/tool-activity.d.ts.map +1 -1
  63. package/lib/types/client/tool-activity.js +11 -4
  64. package/lib/types/client/tool-activity.js.map +1 -1
  65. package/lib/types/client/types.d.ts +18 -8
  66. package/lib/types/client/types.d.ts.map +1 -1
  67. package/lib/types/client/word-motion.d.ts.map +1 -1
  68. package/lib/types/client/word-motion.js +2 -1
  69. package/lib/types/client/word-motion.js.map +1 -1
  70. package/package.json +23 -17
  71. package/scripts/check-harness-compat.mjs +71 -0
  72. package/scripts/link-harness-dependencies.mjs +1 -0
  73. package/scripts/verify-auto-fold.mjs +114 -0
  74. package/scripts/verify-official-bridge.mjs +121 -0
  75. package/src/client/Blocks.tsx +2 -1
  76. package/src/client/OfficialContent.tsx +69 -0
  77. package/src/client/Reader.module.css +35 -8
  78. package/src/client/Reader.tsx +44 -17
  79. package/src/client/ToolActivity.tsx +24 -29
  80. package/src/client/index.tsx +27 -31
  81. package/src/client/markdown/MarkdownText.tsx +14 -6
  82. package/src/client/markdown/render.tsx +23 -7
  83. package/src/client/motion.tsx +18 -11
  84. package/src/client/native/ContextInjectionRow.tsx +21 -9
  85. package/src/client/native/ReferenceIcon.tsx +3 -3
  86. package/src/client/native/tool-call-model.ts +1 -1
  87. package/src/client/official-slots.tsx +211 -0
  88. package/src/client/platform-media.ts +11 -0
  89. package/src/client/primitive-labels.ts +3 -2
  90. package/src/client/settings.tsx +1 -1
  91. package/src/client/store.ts +2 -0
  92. package/src/client/tool-activity.ts +11 -4
  93. package/src/client/types.ts +14 -8
  94. package/src/client/word-motion.tsx +2 -1
  95. package/tests/browser-auto-fold.tsx +64 -0
  96. package/tests/browser-official-bridge.tsx +152 -0
  97. package/tests/official-slots.test.ts +112 -0
  98. package/tests/platform-media.test.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-display",
3
- "version": "0.2.1",
3
+ "version": "0.3.2",
4
4
  "description": "A calmer reading view for DeepSeek Harness: native process details, live reasoning, and clean final answers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,29 +33,33 @@
33
33
  }
34
34
  },
35
35
  "scripts": {
36
- "build": "tsc -p tsconfig.json && tsdown",
36
+ "build": "node scripts/check-harness-compat.mjs && tsc -p tsconfig.json && tsdown",
37
+ "check:harness": "node scripts/check-harness-compat.mjs",
38
+ "test:official": "node scripts/verify-official-bridge.mjs",
39
+ "test:auto-fold": "node scripts/verify-auto-fold.mjs",
37
40
  "typecheck": "tsc -p tsconfig.json --noEmit",
38
41
  "test": "node --import tsx/esm --test tests/*.test.ts",
39
42
  "link:harness": "node scripts/link-harness-dependencies.mjs"
40
43
  },
41
44
  "peerDependencies": {
42
- "@deepseek-ai/cordis": "^4.0.2",
43
- "@deepseek-ai/dsh-api-session-controller": "^0.1.2-rc.1",
44
- "@deepseek-ai/dsh-attachment": "^0.1.2-rc.1",
45
- "@deepseek-ai/dsh-client-store": "^0.1.2-rc.1",
46
- "@deepseek-ai/dsh-client-ui-chat": "^0.1.2-rc.1",
47
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
48
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1",
49
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
50
- "@deepseek-ai/dsh-client-ui-session": "^0.1.2-rc.1",
51
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
52
- "@deepseek-ai/dsh-session": "^0.1.2-rc.1",
53
- "@deepseek-ai/dsh-util-workspace-path": "^0.1.2-rc.1",
45
+ "@deepseek-ai/cordis": "~4.0.4",
46
+ "@deepseek-ai/dsh-api-session-controller": ">=0.1.7-rc.1 <0.1.8",
47
+ "@deepseek-ai/dsh-attachment": ">=0.1.7-rc.1 <0.1.8",
48
+ "@deepseek-ai/dsh-client-store": ">=0.1.7-rc.1 <0.1.8",
49
+ "@deepseek-ai/dsh-client-ui-chat": ">=0.1.7-rc.1 <0.1.8",
50
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.7-rc.1 <0.1.8",
51
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.1 <0.1.8",
52
+ "@deepseek-ai/dsh-client-ui-renderer": ">=0.1.7-rc.1 <0.1.8",
53
+ "@deepseek-ai/dsh-client-ui-session": ">=0.1.7-rc.1 <0.1.8",
54
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.7-rc.1 <0.1.8",
55
+ "@deepseek-ai/dsh-client-ui-tool": ">=0.1.7-rc.1 <0.1.8",
56
+ "@deepseek-ai/dsh-session": ">=0.1.7-rc.1 <0.1.8",
57
+ "@deepseek-ai/dsh-util-workspace-path": ">=0.1.7-rc.1 <0.1.8",
54
58
  "react": "^18.2.0"
55
59
  },
56
60
  "devDependencies": {
57
- "@deepseek-ai/dsh-api-session-controller": "0.1.2-rc.1",
58
- "@deepseek-ai/dsh-session-turn-outline": "0.1.2-rc.1",
61
+ "@deepseek-ai/dsh-api-session-controller": "0.1.7-rc.1",
62
+ "@deepseek-ai/dsh-session-turn-outline": "0.1.7-rc.1",
59
63
  "@types/mdast": "^4.0.4",
60
64
  "@types/node": "^22.20.0",
61
65
  "@types/react": "~18.3.1",
@@ -95,7 +99,9 @@
95
99
  "DESIGN.md",
96
100
  "LICENSE",
97
101
  "THIRD_PARTY_NOTICES.md",
98
- "CHANGELOG.md"
102
+ "CHANGELOG.md",
103
+ "compat",
104
+ "docs"
99
105
  ],
100
106
  "engines": {
101
107
  "node": "^22.19.0 || >=24.0.0"
@@ -0,0 +1,71 @@
1
+ /** Upgrade gate for the public composition adapter and remaining Markdown fork. */
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { createRequire } from 'node:module';
8
+
9
+ const root = resolve(import.meta.dirname, '..');
10
+ const harness = resolve(process.env.DSHX_HARNESS?.trim() || readFileSync(join(homedir(), '.config/dshx/harness'), 'utf8').trim());
11
+ const baselineFile = join(root, 'compat/harness-rc2.json');
12
+ const ts = createRequire(join(root, 'package.json'))('typescript');
13
+ const files = [
14
+ 'packages/client/ui-slots/src/index.ts',
15
+ 'packages/client/ui-slots/src/renderer.ts',
16
+ 'packages/client/ui-renderer/src/client/registry.ts',
17
+ 'packages/client/ui-renderer/src/client/scoped-slots.tsx',
18
+ 'packages/client/ui-chat/src/client/contract/slots.ts',
19
+ 'packages/client/ui-tool/src/client/contract/slots.ts',
20
+ 'packages/client/ui-deliverables/src/client/Deliverables.tsx',
21
+ 'packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx',
22
+ 'packages/client/ui-chat/src/client/chat/MessageItem.tsx',
23
+ 'packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx',
24
+ 'packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx',
25
+ 'packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx',
26
+ 'packages/client/ui-tool/src/client/tool/models/tool-call-model.ts',
27
+ ...['MarkdownText.tsx', 'render.tsx', 'parse.ts', 'incremental.ts', 'katex.tsx'].map(name => `packages/client/ui-primitives/src/markdown/${name}`),
28
+ ];
29
+ const hashes = Object.fromEntries(files.map(file => [file, createHash('sha256').update(readFileSync(join(harness, file))).digest('hex')]));
30
+ const registrations = [];
31
+ for (const path of execFileSync('rg', ['--files', 'packages/client', '-g', '*.ts', '-g', '*.tsx'], { cwd: harness, encoding: 'utf8' }).trim().split('\n')) {
32
+ if (!path.includes('/src/client/')) continue;
33
+ const text = readFileSync(join(harness, path), 'utf8');
34
+ if (!/conversation\.chat\.|tool\.call\.|conversation\.message\./u.test(text)) continue;
35
+ const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
36
+ function visit(node) {
37
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') {
38
+ const options = node.arguments[0];
39
+ if (options && ts.isObjectLiteralExpression(options)) {
40
+ const fields = {};
41
+ for (const p of options.properties) {
42
+ if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.initializer)) fields[p.name.getText(file).replaceAll("'", '').replaceAll('"', '')] = p.initializer.text;
43
+ }
44
+ if (/^(conversation\.chat\.|tool\.call\.|conversation\.message\.)/u.test(fields.name ?? '')) {
45
+ registrations.push(`${fields.name} | ${fields.key ?? fields.id ?? '(chain/single)'} | ${path}`);
46
+ }
47
+ }
48
+ }
49
+ ts.forEachChild(node, visit);
50
+ }
51
+ visit(file);
52
+ }
53
+ registrations.sort();
54
+ const current = {
55
+ harnessVersion: JSON.parse(readFileSync(join(harness, 'packages/client/ui-chat/package.json'), 'utf8')).version,
56
+ hashes, registrations,
57
+ };
58
+ if (process.argv.includes('--record')) {
59
+ writeFileSync(baselineFile, JSON.stringify(current, null, 2) + '\n');
60
+ console.log(`Recorded candidate baseline: ${current.harnessVersion}; run build, fixtures and DSH acceptance before committing it.`);
61
+ } else {
62
+ const baseline = JSON.parse(readFileSync(baselineFile, 'utf8'));
63
+ const changed = files.filter(file => current.hashes[file] !== baseline.hashes[file]);
64
+ const added = registrations.filter(row => !baseline.registrations.includes(row));
65
+ const removed = baseline.registrations.filter(row => !registrations.includes(row));
66
+ if (current.harnessVersion !== baseline.harnessVersion || changed.length || added.length || removed.length) {
67
+ console.error(JSON.stringify({ compatible: 'review-required', target: current.harnessVersion, changedContractsOrForkSources: changed, addedRegistrations: added, removedRegistrations: removed }, null, 2));
68
+ console.error('Review the differences and run the official bridge/visual tests. --record alone is not compatibility proof.');
69
+ process.exitCode = 1;
70
+ } else console.log(`Harness compatibility baseline passed (${current.harnessVersion}; ${registrations.length} official registrations).`);
71
+ }
@@ -17,6 +17,7 @@ const shared = {
17
17
  '@deepseek-ai/dsh-client-ui-renderer': 'packages/client/ui-renderer',
18
18
  '@deepseek-ai/dsh-client-ui-session': 'packages/client/ui-session',
19
19
  '@deepseek-ai/dsh-client-ui-slots': 'packages/client/ui-slots',
20
+ '@deepseek-ai/dsh-client-ui-tool': 'packages/client/ui-tool',
20
21
  '@deepseek-ai/dsh-attachment': 'packages/attachment/attachment',
21
22
  '@deepseek-ai/dsh-session': 'packages/core/session',
22
23
  '@deepseek-ai/dsh-util-workspace-path': 'packages/util/workspace-path',
@@ -0,0 +1,114 @@
1
+ /** Real Reader interaction regression, isolated from the user's Host and data. */
2
+ import assert from 'node:assert/strict';
3
+ import { createRequire } from 'node:module';
4
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
5
+ import { createServer } from 'node:http';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ const root = resolve(import.meta.dirname, '..');
11
+ const harness = process.env.DSHX_HARNESS;
12
+ if (!harness) throw new Error('Set DSHX_HARNESS to the target checkout.');
13
+ const require = createRequire(join(root, 'package.json'));
14
+ const webRequire = createRequire(join(harness, 'packages/client/web/package.json'));
15
+ const { build } = await import(pathToFileURL(createRequire(require.resolve('tsx')).resolve('esbuild')).href);
16
+ const { launchPinnedChromium } = await import(pathToFileURL(join(homedir(), '.codex/playwright-runtime/runtime.mjs')).href);
17
+ const out = join(root, '.evidence/auto-fold');
18
+ await mkdir(out, { recursive: true });
19
+ await build({
20
+ entryPoints: [join(root, 'tests/browser-auto-fold.tsx')], outfile: join(out, 'fixture.js'),
21
+ bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
22
+ alias: {
23
+ '@deepseek-ai/dsh-client-ui-primitives': join(harness, 'packages/client/ui-primitives/src/index.ts'),
24
+ react: dirname(require.resolve('react/package.json')), 'react-dom': dirname(webRequire.resolve('react-dom/package.json')),
25
+ },
26
+ loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.svg': 'dataurl' },
27
+ });
28
+ const server = createServer(async (req, res) => {
29
+ const file = req.url === '/fixture.js' ? 'fixture.js' : req.url === '/fixture.css' ? 'fixture.css' : null;
30
+ if (file) { res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css'); res.end(await readFile(join(out, file))); return; }
31
+ if (req.url !== '/') { res.writeHead(404); res.end(); return; }
32
+ res.setHeader('Content-Type', 'text/html');
33
+ res.end('<!doctype html><html lang="zh"><meta charset="utf-8"><link rel="stylesheet" href="/fixture.css"><style>body{margin:0;font:14px/1.6 system-ui;--dsw-alias-label-primary:#222;--dsw-alias-label-secondary:#666;--dsw-alias-label-tertiary:#888;--dsw-alias-bg-base:#fff;--dsw-alias-border-l2:#ddd}button{cursor:pointer}</style><div id="app"></div><script type="module" src="/fixture.js"></script></html>');
34
+ });
35
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
36
+ let browser;
37
+ const results = [];
38
+ try {
39
+ browser = await launchPinnedChromium();
40
+ for (const reducedMotion of ['no-preference', 'reduce']) {
41
+ const page = await browser.newPage({ viewport: { width: 1200, height: 1000 }, reducedMotion });
42
+ page.setDefaultTimeout(4000);
43
+ const errors = [];
44
+ page.on('pageerror', error => errors.push(String(error)));
45
+ await page.goto(`http://127.0.0.1:${server.address().port}/`);
46
+ const closed = page.locator('[data-reader-turn="1"]');
47
+ const live = page.locator('[data-reader-turn="2"]');
48
+ const completedThought = closed.getByText('已完成思考 0', { exact: true });
49
+ const oldLiveThought = live.getByText('进行中思考 0', { exact: true });
50
+ const latestThought = live.getByText('进行中思考 1', { exact: true });
51
+ const toggle = () => page.getByRole('button', { name: /^自动折叠[开关]$/ }).click();
52
+ await page.getByRole('button', { name: '自动折叠开', exact: true }).waitFor();
53
+ await latestThought.waitFor();
54
+ assert.equal(await completedThought.count(), 0);
55
+
56
+ // Reading while auto-fold is off used to persist a manual expansion forever.
57
+ await toggle();
58
+ await completedThought.click();
59
+ assert.ok(Object.values(await page.evaluate(() => window.autoFoldFixture.state().expanded)).includes(true));
60
+ await toggle();
61
+ await completedThought.waitFor({ state: 'hidden' });
62
+ await latestThought.waitFor();
63
+ await closed.getByText('最终回答始终保留', { exact: true }).waitFor();
64
+
65
+ // A prior-steps disclosure has its own state, separate from the store.
66
+ await live.getByRole('button', { name: '展开此前步骤', exact: true }).click();
67
+ await oldLiveThought.waitFor();
68
+ await toggle();
69
+ await toggle();
70
+ await oldLiveThought.waitFor({ state: 'hidden' });
71
+ await latestThought.waitFor();
72
+
73
+ // Fresh manual choices still work after automatic folding is restored.
74
+ await closed.getByRole('button', { name: '展开思考与过程', exact: true }).click();
75
+ await completedThought.waitFor();
76
+ await page.evaluate(() => window.autoFoldFixture.preference(false));
77
+ await page.getByRole('button', { name: '自动折叠关', exact: true }).waitFor();
78
+ await page.evaluate(() => window.autoFoldFixture.preference(true));
79
+ await completedThought.waitFor({ state: 'hidden' });
80
+
81
+ // Active text selection still protects reading until the user clears it.
82
+ await toggle();
83
+ await completedThought.evaluate(async element => {
84
+ const range = document.createRange();
85
+ range.selectNodeContents(element);
86
+ document.getSelection().removeAllRanges();
87
+ document.getSelection().addRange(range);
88
+ document.dispatchEvent(new Event('selectionchange'));
89
+ await new Promise(requestAnimationFrame);
90
+ });
91
+ await page.evaluate(() => window.autoFoldFixture.preference(true));
92
+ await page.getByRole('button', { name: '自动折叠开', exact: true }).waitFor();
93
+ await closed.getByRole('button', { name: '收起思考与过程', exact: true }).waitFor();
94
+ assert.equal(await page.evaluate(() => document.getSelection().toString()), '已完成思考 0');
95
+ await page.evaluate(() => document.getSelection().removeAllRanges());
96
+ await completedThought.waitFor({ state: 'hidden' });
97
+
98
+ // Rapid reversal must settle with automatic folding enabled.
99
+ for (let i = 0; i < 6; i++) await toggle();
100
+ await completedThought.waitFor({ state: 'hidden' });
101
+ await oldLiveThought.waitFor({ state: 'hidden' });
102
+ await latestThought.waitFor();
103
+ assert.deepEqual(errors, []);
104
+ assert.equal(await page.getByText('此内容暂时无法展示', { exact: false }).count(), 0);
105
+ await page.screenshot({ path: join(out, `${reducedMotion}.png`) });
106
+ results.push({ reducedMotion, passed: true, paths: ['read-while-off', 'prior-steps-override', 'new-manual-choice', 'settings-toggle', 'active-selection', 'rapid-reversal', 'latest-and-final-content'] });
107
+ await page.close();
108
+ }
109
+ await writeFile(join(out, 'result.json'), JSON.stringify({ passed: true, results }, null, 2));
110
+ console.log(JSON.stringify({ passed: true, results }));
111
+ } finally {
112
+ await browser?.close();
113
+ await new Promise(resolve => server.close(resolve));
114
+ }
@@ -0,0 +1,121 @@
1
+ /** Headless fixture verification; never attaches to the user's browser/Host. */
2
+ import { createRequire } from 'node:module';
3
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import assert from 'node:assert/strict';
9
+
10
+ const harness = process.env.DSHX_HARNESS;
11
+ if (!harness) throw new Error('Set DSHX_HARNESS to the target checkout.');
12
+ const root = resolve(import.meta.dirname, '..');
13
+ const require = createRequire(join(root, 'package.json'));
14
+ const esbuild = createRequire(require.resolve('tsx')).resolve('esbuild');
15
+ const { build } = await import(pathToFileURL(esbuild).href);
16
+ const browserRuntime = join(homedir(), '.codex/playwright-runtime/runtime.mjs');
17
+ const { launchPinnedChromium } = await import(pathToFileURL(browserRuntime).href);
18
+ const out = join(root, '.evidence/official-bridge');
19
+ await mkdir(out, { recursive: true });
20
+ const paths = {
21
+ registry: 'ui-renderer/src/client/registry.ts', renderer: 'ui-renderer/src/client/scoped-slots.tsx',
22
+ feedback: 'ui-message-feedback/src/client/index.ts', 'read-row': 'ui-tool/src/client/tool/toolviews/read-row.tsx',
23
+ 'read-image-row': 'ui-tool/src/client/tool/toolviews/read-image-row.tsx', 'tool-locale': 'ui-conversation/src/client/locales.ts',
24
+ deliverables: 'ui-deliverables/src/client/Deliverables.tsx', 'deliverables-locale': 'ui-deliverables/src/client/locales.ts',
25
+ 'open-in-app-action': 'ui-open-in-app/src/client/FileRouteAction.tsx', 'open-in-app-locale': 'ui-open-in-app/src/client/locales.ts',
26
+ };
27
+ const webRequire = createRequire(join(harness, 'packages/client/web/package.json'));
28
+ await build({
29
+ entryPoints: [join(root, 'tests/browser-official-bridge.tsx')], outfile: join(out, 'fixture.js'), bundle: true,
30
+ format: 'esm', platform: 'browser', jsx: 'automatic', sourcemap: true, logLimit: 5,
31
+ alias: {
32
+ ...Object.fromEntries(Object.entries(paths).map(([name, path]) => [`@fixture/${name}`, join(harness, 'packages/client', path)])),
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
+ react: dirname(require.resolve('react/package.json')), 'react-dom': dirname(webRequire.resolve('react-dom/package.json')),
36
+ },
37
+ loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.svg': 'dataurl' },
38
+ });
39
+ const pixel = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lN8AAAAASUVORK5CYII=', 'base64');
40
+ const server = createServer(async (req, res) => {
41
+ if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); res.end('<!doctype html><html lang="zh"><meta charset="utf-8"><link rel="stylesheet" href="/fixture.css"><style>body{font-family:system-ui;margin:24px}button{cursor:pointer}</style><div id="app"></div><script type="module" src="/fixture.js"></script></html>'); return; }
42
+ if (req.url?.startsWith('/api/file?') || req.url === '/pixel.png') { res.setHeader('Content-Type', 'image/png'); res.end(pixel); return; }
43
+ if (req.url?.startsWith('/api/present.open')) { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify([{ id: 'preview-app', name: 'Preview App', default: true, icon: null }, { id: 'other-app', name: 'Other App', default: false, icon: null }])); return; }
44
+ const file = req.url === '/fixture.js' ? 'fixture.js' : req.url === '/fixture.css' ? 'fixture.css' : null;
45
+ if (!file) { res.writeHead(404); res.end(); return; }
46
+ res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css');
47
+ res.end(await readFile(join(out, file)));
48
+ });
49
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
50
+ let browser;
51
+ try {
52
+ browser = await launchPinnedChromium();
53
+ const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
54
+ const errors = [];
55
+ page.setDefaultTimeout(10000);
56
+ page.on('pageerror', error => { errors.push(String(error)); console.error(String(error)); });
57
+ page.on('console', msg => { if (msg.type() === 'error') { errors.push(msg.text()); console.error(msg.text().slice(0, 1200)); } });
58
+ await page.goto(`http://127.0.0.1:${server.address().port}`);
59
+ await page.getByRole('button', { name: '好的回答', exact: true }).waitFor();
60
+ assert.equal(await page.locator('[data-nested]').textContent(), 'nested official child');
61
+ await page.getByRole('button', { name: 'Count 0' }).click();
62
+ await page.getByRole('button', { name: 'Count 1' }).waitFor();
63
+ await page.evaluate(() => window.fixture.update());
64
+ await page.getByText('session-a / updated / turn-context', { exact: true }).waitFor();
65
+ await page.evaluate(() => window.fixture.addAction());
66
+ await page.getByRole('button', { name: 'Future official action' }).waitFor();
67
+ assert.equal(await page.getByRole('button', { name: 'Count 1' }).count(), 1);
68
+ assert.equal(await page.evaluate(() => window.fixture.observations.mounts), 1);
69
+ // Uses the real official dialog/controller with in-memory Remote replies.
70
+ await page.getByRole('button', { name: '好的回答', exact: true }).click();
71
+ await page.getByRole('dialog').waitFor();
72
+ await page.screenshot({ path: join(out, 'feedback-dialog.png') });
73
+ await writeFile(join(out, 'dialog.txt'), await page.getByRole('dialog').innerText());
74
+ const cancel = page.getByRole('button', { name: 'close', exact: true });
75
+ await cancel.click();
76
+ await page.getByRole('dialog').waitFor({ state: 'hidden' });
77
+ await page.getByRole('button', { name: '好的回答', exact: true }).click();
78
+ await page.getByRole('button', { name: '任务结果', exact: true }).click();
79
+ await page.getByRole('textbox', { name: '反馈详情' }).fill('Local fixture only');
80
+ await page.getByRole('button', { name: 'submit', exact: true }).click();
81
+ await page.getByRole('dialog').waitFor({ state: 'hidden' });
82
+ await page.getByRole('button', { name: '取消标记', exact: true }).waitFor();
83
+ const put = await page.evaluate(() => window.fixture.observations.requests.find(([method]) => method === 'put')[1]);
84
+ assert.deepEqual(put, { sessionId: 'session-a', messageId: 'message-1', rating: 'positive', note: 'Local fixture only', category: 'task-result', ifVersion: null });
85
+ await page.getByRole('button', { name: '取消标记', exact: true }).click();
86
+ await page.getByRole('button', { name: '好的回答', exact: true }).waitFor();
87
+ const tool = page.locator('[data-reader-tool]');
88
+ await tool.getByRole('button', { name: 'sample.ts', exact: true }).click();
89
+ const open = await page.evaluate(() => window.fixture.observations.opens[0]);
90
+ assert.deepEqual(open, { path: '/fixture/sample.ts', options: { line: 12 } });
91
+ await tool.locator('[data-disclosure-row]').click();
92
+ await tool.getByText('export const value = 1;', { exact: true }).waitFor();
93
+ assert.equal(await page.locator('[data-presented-file]').count(), 1);
94
+ assert.equal(await page.locator('[data-produced-files-row]').count(), 0);
95
+ await page.getByRole('button', { name: '在侧边栏预览 /fixture/report.pdf', exact: true }).click();
96
+ assert.equal(await page.evaluate(() => window.fixture.observations.opens.at(-1).path), '/fixture/report.pdf');
97
+ await page.getByRole('button', { name: '更多打开方式', exact: true }).click();
98
+ await page.getByRole('menuitem', { name: 'Other App', exact: true }).click();
99
+ assert.deepEqual(await page.evaluate(() => window.fixture.observations.opens.at(-1)), ['presented', 'session-a', 42, 0, 'open', 'other-app']);
100
+ await page.evaluate(() => window.fixture.language());
101
+ await page.getByRole('button', { name: 'Good response', exact: true }).waitFor();
102
+ const image = page.locator('img[alt="本地样例"]');
103
+ assert.match(await image.getAttribute('src'), /\/api\/file\?path=%2Ffixture%2Fimage.png$/);
104
+ assert.equal(await image.evaluate(img => img.complete && img.naturalWidth > 0), true);
105
+ await page.screenshot({ path: join(out, 'reader-controls.png') });
106
+ await writeFile(join(out, 'tool.txt'), await page.locator('[data-reader-tool]').innerText());
107
+ const before = await page.evaluate(() => window.fixture.observations.subscriptions);
108
+ await page.evaluate(() => window.fixture.unmount());
109
+ await page.locator('[data-unmounted]').waitFor();
110
+ const after = await page.evaluate(() => window.fixture.observations.subscriptions);
111
+ assert.ok(after < before, `subscriptions did not release (${before} → ${after})`);
112
+ await page.evaluate(() => window.fixture.mount());
113
+ await page.getByRole('button', { name: 'Good response', exact: true }).waitFor();
114
+ assert.deepEqual(errors, []);
115
+ const result = { passed: true, assertions: ['real official feedback dialog', 'feedback submit/retract through mock Remote', 'official file opener with line', 'official read detail', 'official presented file preview/menu', 'no duplicate produced row', 'memo', 'locale change', 'session injection', 'store actions', 'context hook', 'nested slots', 'new official registration', 'stable mount', 'subscription cleanup', 'Reader remount', 'local image'], observations: await page.evaluate(() => window.fixture.observations) };
116
+ await writeFile(join(out, 'result.json'), JSON.stringify(result, null, 2) + '\n');
117
+ console.log(JSON.stringify(result));
118
+ } finally {
119
+ await browser?.close();
120
+ await new Promise(resolve => server.close(resolve));
121
+ }
@@ -162,7 +162,7 @@ function MessageClock({ time }: { time: number }) {
162
162
  return <time className={css.messageClock} dateTime={new Date(time).toISOString()}>{formatMessageClock(time)}</time>;
163
163
  }
164
164
 
165
- export function CopyAnswer({ blocks, onFork, metrics }: { blocks: readonly AssistantBlock[]; onFork?: () => void; metrics?: BlockRenderProps['metrics'] }) {
165
+ export function CopyAnswer({ blocks, onFork, metrics, extraActions }: { blocks: readonly AssistantBlock[]; onFork?: () => void; metrics?: BlockRenderProps['metrics']; extraActions?: ReactNode }) {
166
166
  const { receipt, copy } = useCopyReceipt();
167
167
  const text = blocks.filter((block): block is Extract<AssistantBlock, { kind: 'text' }> => block.kind === 'text').map(block => block.text).join('\n\n');
168
168
  const endedAt = metrics?.endedAt;
@@ -174,6 +174,7 @@ export function CopyAnswer({ blocks, onFork, metrics }: { blocks: readonly Assis
174
174
  <CopyGlyph />
175
175
  </button>
176
176
  )}
177
+ {extraActions}
177
178
  {onFork && (
178
179
  <button
179
180
  type="button"
@@ -0,0 +1,69 @@
1
+ import { useCallback, useState, useSyncExternalStore, type ReactNode } from 'react';
2
+ import type { ChatConversationViewNode, ChatNodeOwnerProps, TurnTailOwnerProps, UseDisclosure } from '@deepseek-ai/dsh-client-ui-chat/client';
3
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
4
+ import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
5
+ import type { AssistantActionOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
6
+ type MessageId = AssistantActionOwnerProps['messageId'];
7
+ import { OFFICIAL_SEATS } from './official-slots.js';
8
+ import type { BlockRenderProps } from './types.js';
9
+
10
+ export function OfficialActions({ official, messageId }: Pick<BlockRenderProps, 'official'> & { messageId?: MessageId }) {
11
+ if (!official || !messageId) return null;
12
+ return <span data-reader-official-actions style={{ display: 'contents' }}>
13
+ {official.renderSlot(OFFICIAL_SEATS.actions, { messageId })}
14
+ </span>;
15
+ }
16
+
17
+ /** Each atomic Tool view owns its disclosure state through this stable Hook prop. */
18
+ const useToolDisclosure: UseDisclosure = () => {
19
+ const [expanded, setExpanded] = useState(false);
20
+ const toggle = useCallback(() => setExpanded(previous => !previous), []);
21
+ return { expanded, setExpanded, toggle };
22
+ };
23
+
24
+ export function OfficialTool({ official, block, toolName, cwd, openFile, fallback }: BlockRenderProps & {
25
+ official: NonNullable<BlockRenderProps['official']>; block: ToolCallBlock; toolName: string; fallback: ReactNode;
26
+ }) {
27
+ const home = useSyncExternalStore(official.officialHost.subscribe, official.officialHost.getSnapshot).home;
28
+ return <div data-reader-tool-official>
29
+ {(official.renderSlot as (key: string, owner: object, options: object) => ReactNode)(OFFICIAL_SEATS.tools, {
30
+ callId: block.callId, toolName,
31
+ phase: 'kind' in block ? 'result' : block.phase, block, cwd, home,
32
+ openFile: openFile ?? (() => {}),
33
+ loadImage: official.officialImageLoader,
34
+ useDisclosure: useToolDisclosure,
35
+ inspect: () => official.openView('trajectory', block.callId),
36
+ }, { entryKey: toolName, hookContext: { callId: block.callId, assistant: undefined }, fallback })}
37
+ </div>;
38
+ }
39
+
40
+ export function OfficialNode({ node, fallback, ...render }: BlockRenderProps & { node: ChatConversationViewNode; fallback: ReactNode }) {
41
+ const official = render.official;
42
+ const [disclosureReset] = useState(() => createSnapshotStore(0));
43
+ if (!official) return fallback;
44
+ const owner: ChatNodeOwnerProps = {
45
+ cwd: render.cwd,
46
+ openFile: render.openFile ?? (() => {}),
47
+ openSkill: () => {},
48
+ forkAt: render.forkAt ?? (() => {}),
49
+ inspectCall: callId => official.openView('trajectory', callId),
50
+ loadImage: official.officialImageLoader,
51
+ renderMessageImages: images => official.renderSlot(OFFICIAL_SEATS.images, {
52
+ ...images, loadImage: official.officialImageLoader,
53
+ }),
54
+ fileMentions: official.officialFileMentions,
55
+ };
56
+ const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined;
57
+ // The runtime node domain is open; the public SlotMap enumerates the known kinds.
58
+ const renderNode = official.renderSlot as unknown as (key: string, owner: object, options: object) => ReactNode;
59
+ return <div data-reader-official-node={node.kind} data-chat-anchor-key={node.key} data-chat-flow-kind={node.kind}>
60
+ {renderNode(OFFICIAL_SEATS.nodes, { ...owner, node }, { entryKey: node.kind, hookContext: { turnData: turn?.data, disclosureReset }, fallback })}
61
+ </div>;
62
+ }
63
+
64
+ export function OfficialTail({ official, owner, produced }: Pick<BlockRenderProps, 'official'> & { owner?: TurnTailOwnerProps; produced: readonly string[] }) {
65
+ const tailOwner = owner && official ? { ...owner, openFile: official.officialPreviewFile, readerProducedPaths: produced } : undefined;
66
+ return official && tailOwner ? <div data-reader-official-tail style={{ display: 'contents' }}>
67
+ {official.renderSlot(OFFICIAL_SEATS.tail, tailOwner)}
68
+ </div> : null;
69
+ }
@@ -100,9 +100,13 @@
100
100
  .chevron { flex: none; stroke: currentColor; fill: none; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; transition: transform 260ms cubic-bezier(.22,1,.36,1); }
101
101
  .chevron[data-open=true] { transform: rotate(90deg); }
102
102
  .disclosureBody { overflow: hidden; height: 0; transform-origin: top center; will-change: height, opacity, transform; }
103
- .processContents { display: flow-root; border-left: 1px solid var(--dsw-alias-border-l2); padding: 3px 0 2px 17px; margin: 0 0 0 6px; color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 24px; }
103
+ /* One rail geometry for every nested lane. These three rules used to sit at
104
+ 6px / 24px / 6px with 1px, 1px and 2px rails, so the vertical guides stepped
105
+ in and out down the page. They now share --reader-rail: same left edge, same
106
+ rail width, same gutter, so a column of nested rows reads as one line. */
107
+ .processContents { display: flow-root; border-left: 1px solid var(--dsw-alias-border-l2); padding: 3px 0 2px var(--reader-gutter, 16px); margin: 0 0 0 var(--reader-rail, 6px); color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 24px; }
104
108
  .processContents > :last-child { margin-bottom: 0; }
105
- .processCommentary { color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 24px; margin: 4px 0 8px 6px; padding-left: 12px; border-left: 2px solid var(--dsw-alias-border-l2); }
109
+ .processCommentary { color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 24px; margin: 4px 0 8px var(--reader-rail, 6px); padding-left: var(--reader-gutter, 16px); border-left: 1px solid var(--dsw-alias-border-l2); }
106
110
  .processFrame { min-width: 0; }
107
111
  .toolActivity { min-width: 0; color: var(--dsw-alias-label-secondary); }
108
112
  .nativeToolRow { min-width: 0; }
@@ -297,8 +301,9 @@
297
301
  .systemPrompt { margin: 0; max-height: 320px; padding: 12px; overflow: auto; border-radius: 8px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-primary); font: 12px/20px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
298
302
  .commandInput { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
299
303
  .toolReceipt .blocks, .toolDocument .blocks { font-size: 13px; line-height: 22px; }
300
- .toolChildren { margin: 0 14px 12px 24px; padding-left: 12px; border-left: 1px solid var(--dsw-alias-border-l2); display: flex; flex-direction: column; gap: 9px; }
301
- @container (max-width: 420px) { .toolHeading { gap: 7px; padding: 11px 10px 6px; grid-template-columns: 16px minmax(0, 1fr) auto; } .toolHeadingState { gap: 4px; } .toolLedger { padding-left: 33px; padding-right: 10px; } .toolPanel { padding: 10px; } .toolChildren { margin-left: 12px; padding-left: 8px; } }
304
+ .toolChildren { margin: 0 14px 12px var(--reader-rail, 6px); padding-left: var(--reader-gutter, 16px); border-left: 1px solid var(--dsw-alias-border-l2); display: flex; flex-direction: column; gap: 9px; }
305
+ @container (max-width: 420px) { .toolHeading { gap: 7px; padding: 11px 10px 6px; grid-template-columns: 16px minmax(0, 1fr) auto; } .toolHeadingState { gap: 4px; } .toolLedger { padding-left: 33px; padding-right: 10px; } .toolPanel { padding: 10px; } /* .toolChildren keeps the shared rail here too: a different left edge under a
306
+ narrow container is what made nested rows read as misplaced lines. */ }
302
307
  @media (pointer: coarse) { .toolTabs > button { min-height: 44px; } }
303
308
  /* Transitions.dev — Reasoning stream (user-supplied). Transform the one real
304
309
  transcript; hand the painted offset to native scrolling for manual reading. */
@@ -465,15 +470,18 @@
465
470
  .morphDrawer { overflow: hidden; height: 0; transform-origin: top center; will-change: height, opacity, transform; }
466
471
  .liveFoldImpact { animation: liveFoldElastic 180ms cubic-bezier(0.2, 0, 0, 1) both; }
467
472
  @keyframes liveFoldElastic { 0% { transform: scale(1); } 50% { transform: scale(1.015); } 100% { transform: scale(1); } }
468
- .liveFoldContainer { position: sticky; top: max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)); z-index: 6; background: var(--dsw-alias-bg-base); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); padding: 4px 0; margin-bottom: 6px; animation: liveFoldPulse 320ms cubic-bezier(0.16, 1, 0.3, 1) both; transform-origin: left center; will-change: transform, opacity; }
473
+ .liveFoldContainer { position: sticky; top: calc(max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)) + var(--reader-sticky-lane, 40px) * 2); z-index: 4; background: var(--dsw-alias-bg-base); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); padding: 4px 0; margin-bottom: 6px; animation: liveFoldPulse 320ms cubic-bezier(0.16, 1, 0.3, 1) both; transform-origin: left center; will-change: transform, opacity; }
469
474
  @keyframes liveFoldPulse { 0% { transform: translateY(-4px) scale(0.98); opacity: 0.8; } 50% { transform: translateY(0) scale(1.02); opacity: 1; } 100% { transform: translateY(0) scale(1); opacity: 1; } }
470
475
  [data-motion=off] .liveFoldContainer { animation: none !important; }
471
476
  @media (prefers-reduced-motion: reduce) { .liveFoldContainer { animation: none !important; } }
472
477
  .priorFoldDrawer { display: flex; flex-direction: column; gap: 14px; min-width: 0; width: 100%; }
473
478
  .jump { margin-top: -34px; flex: none; width: 34px; height: 34px; display: flex; justify-content: center; align-items: center; padding: 0; border: 0; border-radius: 100px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-button-floating-fill, var(--dsw-alias-bg-base)); box-shadow: var(--dsw-elevation-panel, 0 2px 8px var(--dsw-alias-border-l1)); pointer-events: auto; }
474
479
  .jump:hover { background: var(--dsw-alias-button-floating-hover, var(--dsw-alias-interactive-bg-hover)); }
475
- .foldSummary { display: inline-flex; align-items: baseline; white-space: nowrap !important; max-width: 100%; }
476
- .foldSummaryPart { display: inline-flex; align-items: baseline; white-space: nowrap !important; }
480
+ /* The digest is a single line. nowrap on the parent does not stop the parts
481
+ from wrapping, so each part is pinned too and the run clips with an ellipsis
482
+ when the column is narrow. */
483
+ .foldSummary { display: inline-flex; align-items: baseline; white-space: nowrap !important; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
484
+ .foldSummaryPart { display: inline-flex; align-items: baseline; white-space: nowrap !important; flex: none; }
477
485
  .numberRollRoot { display: inline-flex; position: relative; overflow: hidden; vertical-align: baseline; line-height: inherit; font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; white-space: nowrap !important; flex-shrink: 0; min-width: 1.45em; justify-content: center; text-align: center; }
478
486
  .numberRollSizer { visibility: hidden; user-select: none; pointer-events: none; line-height: inherit; white-space: nowrap; }
479
487
  .numberRollDigit { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; line-height: inherit; white-space: nowrap; }
@@ -522,9 +530,27 @@
522
530
  first lane and its containing block always ends above the seat, so it can
523
531
  never reach the band, and 7 keeps it above the host's code-block banners. */
524
532
  .toolbar { width: 100%; max-width: 100%; align-self: stretch; box-sizing: border-box; min-height: 40px; z-index: 7; border-bottom: 0; backdrop-filter: none; -webkit-backdrop-filter: none; }
533
+ /* Every sticky lane used to pin to the SAME top: the status row, the folded
534
+ summary row and the live-fold container all sat at
535
+ max(toolbar-height, status-height) with z-index 6. Three siblings competing
536
+ for one slot paint over each other in scroll order, which is what "展开以后
537
+ 内容被盖住、看不见" was: the disclosure opened, and the row that followed
538
+ pinned straight on top of it. Give each lane its own band instead, stacked
539
+ from the measured top: status first, then the summary, then the fold body. */
525
540
  .turnProcessSticky { box-sizing: border-box; width: calc(100% - var(--reader-control-width, 148px)); top: max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)); min-height: 40px; padding-right: 0; z-index: 6; border-bottom: 1px solid transparent; background-clip: padding-box; backdrop-filter: none; -webkit-backdrop-filter: none; }
541
+ /* A pinned lane's in-flow gap stays visible as an uncovered scrollport strip
542
+ while the lane itself paints opaque. Cast each plate upward over its strip
543
+ (the .turn 14px gap / the prior cell's 16px flowCellInner padding) so the
544
+ lanes read as one band instead of solid plates with notches between them.
545
+ The status lane reserves --reader-control-width on its right for the
546
+ toolbar's control group, which leaves the same kind of strip there; paint
547
+ it too. The toolbar is z-index 7, above the lane's 6, so its buttons still
548
+ render on top of the extension. */
549
+ .turnProcessSticky { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base), var(--reader-control-width, 148px) 0 0 var(--dsw-alias-bg-base); }
526
550
  /* The same summary row stays in flow until it reaches the measured top lane. */
527
- .flowCell[data-flow-summary], .closedProcessSummary { position: sticky; top: max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)); z-index: 6; overflow: visible; box-sizing: border-box; min-height: 40px; padding-block: 4px; background: var(--dsw-alias-bg-base); border-bottom: 0; }
551
+ .flowCell[data-flow-summary], .closedProcessSummary { position: sticky; top: calc(max(var(--reader-toolbar-height, 40px), var(--reader-status-height, 40px)) + var(--reader-sticky-lane, 40px)); z-index: 5; overflow: visible; box-sizing: border-box; min-height: 40px; padding-block: 4px; background: var(--dsw-alias-bg-base); border-bottom: 0; }
552
+ .flowCell[data-flow-summary] { box-shadow: 0 -16px 0 var(--dsw-alias-bg-base); }
553
+ .closedProcessSummary { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base); }
528
554
  .foldControl { display: inline-flex; align-items: center; gap: 0; padding: 2px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform); }
529
555
  .foldStop { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); padding: 2px 8px; border-radius: 6px; white-space: nowrap; font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }
530
556
  .foldStop[data-on] { background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); }
@@ -558,6 +584,7 @@
558
584
  a glass reader keeps exactly the same footer precedence as an opaque one. */
559
585
  .root[data-reader-glass] .flowCell[data-flow-summary],
560
586
  .root[data-reader-glass] .closedProcessSummary {
587
+ box-shadow: none;
561
588
  position: static;
562
589
  z-index: auto;
563
590
  min-height: 32px;