dsh-better-display 0.2.1 → 0.3.0

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 (87) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.en.md +4 -2
  3. package/README.md +4 -2
  4. package/compat/harness-rc2.json +59 -0
  5. package/docs/official-rendering-bridge.md +65 -0
  6. package/lib/client.js +858 -456
  7. package/lib/client.js.map +1 -1
  8. package/lib/types/client/Blocks.d.ts +2 -1
  9. package/lib/types/client/Blocks.d.ts.map +1 -1
  10. package/lib/types/client/Blocks.js +2 -2
  11. package/lib/types/client/Blocks.js.map +1 -1
  12. package/lib/types/client/OfficialContent.d.ts +25 -0
  13. package/lib/types/client/OfficialContent.d.ts.map +1 -0
  14. package/lib/types/client/OfficialContent.js +42 -0
  15. package/lib/types/client/OfficialContent.js.map +1 -0
  16. package/lib/types/client/Reader.d.ts.map +1 -1
  17. package/lib/types/client/Reader.js +38 -6
  18. package/lib/types/client/Reader.js.map +1 -1
  19. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  20. package/lib/types/client/ToolActivity.js +21 -17
  21. package/lib/types/client/ToolActivity.js.map +1 -1
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/index.js +23 -33
  24. package/lib/types/client/index.js.map +1 -1
  25. package/lib/types/client/markdown/MarkdownText.d.ts +3 -1
  26. package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -1
  27. package/lib/types/client/markdown/MarkdownText.js +14 -7
  28. package/lib/types/client/markdown/MarkdownText.js.map +1 -1
  29. package/lib/types/client/markdown/render.d.ts +2 -0
  30. package/lib/types/client/markdown/render.d.ts.map +1 -1
  31. package/lib/types/client/markdown/render.js +22 -6
  32. package/lib/types/client/markdown/render.js.map +1 -1
  33. package/lib/types/client/motion.d.ts.map +1 -1
  34. package/lib/types/client/motion.js +22 -13
  35. package/lib/types/client/motion.js.map +1 -1
  36. package/lib/types/client/native/ContextInjectionRow.d.ts +12 -3
  37. package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
  38. package/lib/types/client/native/ContextInjectionRow.js +6 -3
  39. package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
  40. package/lib/types/client/official-slots.d.ts +71 -0
  41. package/lib/types/client/official-slots.d.ts.map +1 -0
  42. package/lib/types/client/official-slots.js +183 -0
  43. package/lib/types/client/official-slots.js.map +1 -0
  44. package/lib/types/client/platform-media.d.ts +6 -0
  45. package/lib/types/client/platform-media.d.ts.map +1 -0
  46. package/lib/types/client/platform-media.js +13 -0
  47. package/lib/types/client/platform-media.js.map +1 -0
  48. package/lib/types/client/settings.js.map +1 -1
  49. package/lib/types/client/store.d.ts +1 -0
  50. package/lib/types/client/store.d.ts.map +1 -1
  51. package/lib/types/client/store.js +1 -0
  52. package/lib/types/client/store.js.map +1 -1
  53. package/lib/types/client/tool-activity.d.ts +1 -1
  54. package/lib/types/client/tool-activity.d.ts.map +1 -1
  55. package/lib/types/client/tool-activity.js +9 -2
  56. package/lib/types/client/tool-activity.js.map +1 -1
  57. package/lib/types/client/types.d.ts +18 -8
  58. package/lib/types/client/types.d.ts.map +1 -1
  59. package/lib/types/client/word-motion.d.ts.map +1 -1
  60. package/lib/types/client/word-motion.js +2 -1
  61. package/lib/types/client/word-motion.js.map +1 -1
  62. package/package.json +22 -16
  63. package/scripts/check-harness-compat.mjs +71 -0
  64. package/scripts/link-harness-dependencies.mjs +1 -0
  65. package/scripts/verify-auto-fold.mjs +114 -0
  66. package/scripts/verify-official-bridge.mjs +119 -0
  67. package/src/client/Blocks.tsx +2 -1
  68. package/src/client/OfficialContent.tsx +57 -0
  69. package/src/client/Reader.module.css +23 -8
  70. package/src/client/Reader.tsx +37 -9
  71. package/src/client/ToolActivity.tsx +22 -27
  72. package/src/client/index.tsx +27 -31
  73. package/src/client/markdown/MarkdownText.tsx +14 -6
  74. package/src/client/markdown/render.tsx +23 -7
  75. package/src/client/motion.tsx +18 -11
  76. package/src/client/native/ContextInjectionRow.tsx +19 -7
  77. package/src/client/official-slots.tsx +211 -0
  78. package/src/client/platform-media.ts +11 -0
  79. package/src/client/settings.tsx +1 -1
  80. package/src/client/store.ts +2 -0
  81. package/src/client/tool-activity.ts +9 -2
  82. package/src/client/types.ts +14 -8
  83. package/src/client/word-motion.tsx +2 -1
  84. package/tests/browser-auto-fold.tsx +64 -0
  85. package/tests/browser-official-bridge.tsx +144 -0
  86. package/tests/official-slots.test.ts +112 -0
  87. package/tests/platform-media.test.ts +17 -0
@@ -0,0 +1,119 @@
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
+ };
26
+ const webRequire = createRequire(join(harness, 'packages/client/web/package.json'));
27
+ await build({
28
+ entryPoints: [join(root, 'tests/browser-official-bridge.tsx')], outfile: join(out, 'fixture.js'), bundle: true,
29
+ format: 'esm', platform: 'browser', jsx: 'automatic', sourcemap: true, logLimit: 5,
30
+ alias: {
31
+ ...Object.fromEntries(Object.entries(paths).map(([name, path]) => [`@fixture/${name}`, join(harness, 'packages/client', path)])),
32
+ '@deepseek-ai/dsh-client-ui-primitives': join(harness, 'packages/client/ui-primitives/src/index.ts'),
33
+ '@deepseek-ai/dsh-client-ui-slots': join(harness, 'packages/client/ui-slots/src/index.ts'),
34
+ react: dirname(require.resolve('react/package.json')), 'react-dom': dirname(webRequire.resolve('react-dom/package.json')),
35
+ },
36
+ loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.svg': 'dataurl' },
37
+ });
38
+ const pixel = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lN8AAAAASUVORK5CYII=', 'base64');
39
+ const server = createServer(async (req, res) => {
40
+ 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; }
41
+ if (req.url?.startsWith('/api/file?') || req.url === '/pixel.png') { res.setHeader('Content-Type', 'image/png'); res.end(pixel); return; }
42
+ const file = req.url === '/fixture.js' ? 'fixture.js' : req.url === '/fixture.css' ? 'fixture.css' : null;
43
+ if (!file) { res.writeHead(404); res.end(); return; }
44
+ res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css');
45
+ res.end(await readFile(join(out, file)));
46
+ });
47
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
48
+ let browser;
49
+ try {
50
+ browser = await launchPinnedChromium();
51
+ const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
52
+ const errors = [];
53
+ page.setDefaultTimeout(10000);
54
+ page.on('pageerror', error => { errors.push(String(error)); console.error(String(error)); });
55
+ page.on('console', msg => { if (msg.type() === 'error') { errors.push(msg.text()); console.error(msg.text().slice(0, 1200)); } });
56
+ await page.goto(`http://127.0.0.1:${server.address().port}`);
57
+ await page.getByRole('button', { name: '好的回答', exact: true }).waitFor();
58
+ assert.equal(await page.locator('[data-nested]').textContent(), 'nested official child');
59
+ await page.getByRole('button', { name: 'Count 0' }).click();
60
+ await page.getByRole('button', { name: 'Count 1' }).waitFor();
61
+ await page.evaluate(() => window.fixture.update());
62
+ await page.getByText('session-a / updated / turn-context', { exact: true }).waitFor();
63
+ await page.evaluate(() => window.fixture.addAction());
64
+ await page.getByRole('button', { name: 'Future official action' }).waitFor();
65
+ assert.equal(await page.getByRole('button', { name: 'Count 1' }).count(), 1);
66
+ assert.equal(await page.evaluate(() => window.fixture.observations.mounts), 1);
67
+ // Uses the real official dialog/controller with in-memory Remote replies.
68
+ await page.getByRole('button', { name: '好的回答', exact: true }).click();
69
+ await page.getByRole('dialog').waitFor();
70
+ await page.screenshot({ path: join(out, 'feedback-dialog.png') });
71
+ await writeFile(join(out, 'dialog.txt'), await page.getByRole('dialog').innerText());
72
+ const cancel = page.getByRole('button', { name: 'close', exact: true });
73
+ await cancel.click();
74
+ await page.getByRole('dialog').waitFor({ state: 'hidden' });
75
+ await page.getByRole('button', { name: '好的回答', exact: true }).click();
76
+ await page.getByRole('button', { name: '任务结果', exact: true }).click();
77
+ await page.getByRole('textbox', { name: '反馈详情' }).fill('Local fixture only');
78
+ await page.getByRole('button', { name: 'submit', exact: true }).click();
79
+ await page.getByRole('dialog').waitFor({ state: 'hidden' });
80
+ await page.getByRole('button', { name: '取消标记', exact: true }).waitFor();
81
+ const put = await page.evaluate(() => window.fixture.observations.requests.find(([method]) => method === 'put')[1]);
82
+ assert.deepEqual(put, { sessionId: 'session-a', messageId: 'message-1', rating: 'positive', note: 'Local fixture only', category: 'task-result', ifVersion: null });
83
+ await page.getByRole('button', { name: '取消标记', exact: true }).click();
84
+ await page.getByRole('button', { name: '好的回答', exact: true }).waitFor();
85
+ const tool = page.locator('[data-reader-tool]');
86
+ await tool.getByRole('button', { name: 'sample.ts', exact: true }).click();
87
+ const open = await page.evaluate(() => window.fixture.observations.opens[0]);
88
+ assert.deepEqual(open, { path: '/fixture/sample.ts', options: { line: 12 } });
89
+ await tool.locator('[data-disclosure-row]').click();
90
+ await tool.getByText('export const value = 1;', { exact: true }).waitFor();
91
+ assert.equal(await page.locator('[data-presented-file]').count(), 1);
92
+ assert.equal(await page.locator('[data-produced-files-row]').count(), 0);
93
+ await page.getByRole('button', { name: '在侧边栏打开 /fixture/report.pdf', exact: true }).click();
94
+ assert.equal(await page.evaluate(() => window.fixture.observations.opens.at(-1).path), '/fixture/report.pdf');
95
+ await page.getByRole('button', { name: '/fixture/report.pdf 的更多文件操作', exact: true }).click();
96
+ await page.getByRole('menuitem', { name: '用默认应用打开', exact: true }).click();
97
+ assert.deepEqual(await page.evaluate(() => window.fixture.observations.opens.at(-1)), ['presented', 'session-a', 42, 0, 'open']);
98
+ await page.evaluate(() => window.fixture.language());
99
+ await page.getByRole('button', { name: 'Good response', exact: true }).waitFor();
100
+ const image = page.locator('img[alt="本地样例"]');
101
+ assert.match(await image.getAttribute('src'), /\/api\/file\?path=%2Ffixture%2Fimage.png$/);
102
+ assert.equal(await image.evaluate(img => img.complete && img.naturalWidth > 0), true);
103
+ await page.screenshot({ path: join(out, 'reader-controls.png') });
104
+ await writeFile(join(out, 'tool.txt'), await page.locator('[data-reader-tool]').innerText());
105
+ const before = await page.evaluate(() => window.fixture.observations.subscriptions);
106
+ await page.evaluate(() => window.fixture.unmount());
107
+ await page.locator('[data-unmounted]').waitFor();
108
+ const after = await page.evaluate(() => window.fixture.observations.subscriptions);
109
+ assert.ok(after < before, `subscriptions did not release (${before} → ${after})`);
110
+ await page.evaluate(() => window.fixture.mount());
111
+ await page.getByRole('button', { name: 'Good response', exact: true }).waitFor();
112
+ assert.deepEqual(errors, []);
113
+ 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) };
114
+ await writeFile(join(out, 'result.json'), JSON.stringify(result, null, 2) + '\n');
115
+ console.log(JSON.stringify(result));
116
+ } finally {
117
+ await browser?.close();
118
+ await new Promise(resolve => server.close(resolve));
119
+ }
@@ -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,57 @@
1
+ import { useSyncExternalStore, type ReactNode } from 'react';
2
+ import type { ChatConversationViewNode, ChatNodeOwnerProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
3
+ import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
4
+ import type { AssistantActionOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
5
+ type MessageId = AssistantActionOwnerProps['messageId'];
6
+ import { OFFICIAL_SEATS } from './official-slots.js';
7
+ import type { BlockRenderProps } from './types.js';
8
+
9
+ export function OfficialActions({ official, messageId }: Pick<BlockRenderProps, 'official'> & { messageId?: MessageId }) {
10
+ if (!official || !messageId) return null;
11
+ return <span data-reader-official-actions style={{ display: 'contents' }}>
12
+ {official.renderSlot(OFFICIAL_SEATS.actions, { messageId })}
13
+ </span>;
14
+ }
15
+
16
+ export function OfficialTool({ official, block, toolName, cwd, openFile, fallback }: BlockRenderProps & {
17
+ official: NonNullable<BlockRenderProps['official']>; block: ToolCallBlock; toolName: string; fallback: ReactNode;
18
+ }) {
19
+ const home = useSyncExternalStore(official.officialHost.subscribe, official.officialHost.getSnapshot).home;
20
+ return <div data-reader-tool-official>
21
+ {official.renderSlot(OFFICIAL_SEATS.tools, {
22
+ callId: block.callId, toolName, block, cwd, home,
23
+ openFile: openFile ?? (() => {}),
24
+ loadImage: official.officialImageLoader,
25
+ inspect: () => official.openView('trajectory', block.callId),
26
+ }, { entryKey: toolName, fallback })}
27
+ </div>;
28
+ }
29
+
30
+ export function OfficialNode({ node, fallback, ...render }: BlockRenderProps & { node: ChatConversationViewNode; fallback: ReactNode }) {
31
+ const official = render.official;
32
+ if (!official) return fallback;
33
+ const owner: ChatNodeOwnerProps = {
34
+ cwd: render.cwd,
35
+ openFile: render.openFile ?? (() => {}),
36
+ forkAt: render.forkAt ?? (() => {}),
37
+ inspectCall: callId => official.openView('trajectory', callId),
38
+ loadImage: official.officialImageLoader,
39
+ renderMessageImages: images => official.renderSlot(OFFICIAL_SEATS.images, {
40
+ ...images, loadImage: official.officialImageLoader,
41
+ }),
42
+ fileMentions: official.officialFileMentions,
43
+ };
44
+ const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined;
45
+ // The runtime node domain is open; the public SlotMap enumerates the known kinds.
46
+ const renderNode = official.renderSlot as unknown as (key: string, owner: object, options: object) => ReactNode;
47
+ return <div data-reader-official-node={node.kind} data-chat-anchor-key={node.key} data-chat-flow-kind={node.kind}>
48
+ {renderNode(OFFICIAL_SEATS.nodes, { ...owner, node }, { entryKey: node.kind, hookContext: turn?.data, fallback })}
49
+ </div>;
50
+ }
51
+
52
+ export function OfficialTail({ official, owner, produced }: Pick<BlockRenderProps, 'official'> & { owner?: TurnTailOwnerProps; produced: readonly string[] }) {
53
+ const tailOwner = owner && official ? { ...owner, openFile: official.officialPreviewFile, readerProducedPaths: produced } : undefined;
54
+ return official && tailOwner ? <div data-reader-official-tail style={{ display: 'contents' }}>
55
+ {official.renderSlotChain(OFFICIAL_SEATS.tail, tailOwner)}
56
+ </div> : null;
57
+ }
@@ -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,16 @@
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; }
526
541
  /* 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; }
542
+ .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; }
528
543
  .foldControl { display: inline-flex; align-items: center; gap: 0; padding: 2px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform); }
529
544
  .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
545
  .foldStop[data-on] { background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); }
@@ -6,6 +6,7 @@ import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives';
6
6
  import { BlockBoundary, Blocks, contentBlocks, CopyAnswer, UserMessageActions } from './Blocks.js';
7
7
  import { ReasoningCard } from './ReasoningCard.js';
8
8
  import { ToolActivity, ToolMedia } from './ToolActivity.js';
9
+ import { OfficialActions, OfficialNode, OfficialTail } from './OfficialContent.js';
9
10
  import { preparingLabel, readerFlow } from './tool-activity.js';
10
11
  import { Disclosure, ProcessFragment, RetiringContent, StatusText, useMotionAllowed, usePinnedSelection, useReadingScroll } from './motion.js';
11
12
  import { StreamMotionContext } from './streaming.js';
@@ -181,7 +182,7 @@ const AssistantNode = memo(function AssistantNode({ useChat, nodeKey, boundary,
181
182
  // of its own; passing it would fork the whole session instead.
182
183
  const anchor = forkAnchorSeq([data.finalNode, { seq: render.forkSeq }]);
183
184
  return render.forkAt && anchor !== undefined ? () => render.forkAt!(anchor) : undefined;
184
- })()} metrics={render.metrics} />
185
+ })()} metrics={render.metrics} extraActions={<OfficialActions official={render.official} messageId={data.finalNode?.messageId} />} />
185
186
  )}
186
187
  </article>
187
188
  </RetiringContent>;
@@ -226,6 +227,7 @@ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, pr
226
227
  if (isNode(node, 'turn-max-tokens')) return <div className={css.notice}>已到达输出长度限制,回答尚未完整。</div>;
227
228
  if (isNode(node, 'model-retry')) return node.data.current.retryState === 'scheduled'
228
229
  ? <div className={css.notice} role="status">模型请求未成功,正在等待重试。详情保留在执行过程中。</div> : null;
230
+ if (isNode(node, 'command') && render.official) return <OfficialNode {...render} node={node} fallback={<JsonBlock label="命令记录" payload={node.data} truncatedLabel={truncatedJsonLabel} />} />;
229
231
  if (isNode(node, 'command')) {
230
232
  if (node.data.outcome?.kind === 'error') return <div className={css.error} role="alert">
231
233
  <svg className={css.errorIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
@@ -245,10 +247,11 @@ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, pr
245
247
  // host (it appears nowhere in the installed packages), so a slash command arrives
246
248
  // as 'command' above and is rendered there. Keeping the branch only made the file
247
249
  // look like it handled a case that cannot occur.
248
- return <div className={css.unknown} data-reader-anchor>
250
+ return <OfficialNode {...render} node={node} fallback={<div className={css.unknown} data-reader-anchor>
249
251
  <p>此记录类型暂未接入阅读页:{node.kind}</p>
252
+ {render.official && <button type="button" className={css.textButton} onClick={() => render.official!.openView('chat', node.key)}>在对话中查看</button>}
250
253
  <JsonBlock label="查看原始记录" payload={node.data} truncatedLabel={truncatedJsonLabel} />
251
- </div>;
254
+ </div>} />;
252
255
  });
253
256
 
254
257
  /**
@@ -504,11 +507,11 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
504
507
  : item.key === key || ('nodeKey' in item.step && item.step.nodeKey === key)));
505
508
  const expanded = !autoFold || holdingSelection || processExpanded(expansionChoice, boundary);
506
509
  const [foldOpenByKey, setFoldOpenByKey] = useState<Record<string, boolean>>({});
510
+ useLayoutEffect(() => {
511
+ // Restoring automatic folding also releases live "prior steps" disclosures.
512
+ if (autoFold) setFoldOpenByKey(current => Object.keys(current).length ? {} : current);
513
+ }, [autoFold]);
507
514
  const deliverables = useMemo(() => getTurnDeliverables(turn, flow), [turn, flow]);
508
- const fileMentions = useMemo(
509
- () => deliverables.length > 0 && props.openFile ? createProducedFileMentions(deliverables, props.openFile) : undefined,
510
- [deliverables, props.openFile],
511
- );
512
515
  const tailData = useMemo(() => {
513
516
  for (const key of group.keys) {
514
517
  const n = nodes.get(key);
@@ -516,6 +519,21 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
516
519
  }
517
520
  return undefined;
518
521
  }, [group.keys, nodes]);
522
+ const tailOwner = useMemo(() => turn && tailData ? {
523
+ turn, seq: tailData.closing?.finalNode.seq ?? tailData.seq, openFile: props.openFile,
524
+ } : undefined, [turn, tailData, props.openFile]);
525
+ const fileMentions = useMemo(() => {
526
+ const official = tailOwner ? props.officialFileMentions(tailOwner) : undefined;
527
+ const reader = deliverables.length > 0 ? createProducedFileMentions(deliverables, props.openFile) : undefined;
528
+ return official || reader ? { resolve: (value: string) => official?.resolve(value) ?? reader?.resolve(value) } : undefined;
529
+ }, [tailOwner, props.officialFileMentions, deliverables, props.openFile]);
530
+ const official = useMemo(() => ({
531
+ renderSlot: props.renderSlot, renderSlotChain: props.renderSlotChain,
532
+ officialImageLoader: props.officialImageLoader, officialFileMentions: props.officialFileMentions,
533
+ officialPreviewFile: props.officialPreviewFile,
534
+ officialHost: props.officialHost, openView: props.openView,
535
+ }), [props.renderSlot, props.renderSlotChain, props.officialImageLoader, props.officialFileMentions, props.officialPreviewFile, props.officialHost, props.openView]);
536
+ const cwd = props.useSessions(state => state.byId[props.sessionId]?.cwd);
519
537
  const runMs = turn?.start && turn?.end ? Math.max(0, turn.end.time - turn.start.time) : undefined;
520
538
  const metrics = useMemo(() => ({
521
539
  usage: tailData?.tokenUsage,
@@ -545,7 +563,8 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
545
563
  forkSeq,
546
564
  fileMentions,
547
565
  metrics,
548
- getToolView: props.getToolView,
566
+ official,
567
+ cwd,
549
568
  };
550
569
  const terminal = terminalLabel(boundary.reason);
551
570
  const hasTurnError = flow.some(item => item.kind === 'node' && nodes.get(item.nodeKey)?.kind === 'turn-error');
@@ -594,6 +613,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
594
613
  <GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} useSessionPendingInteraction={props.useSessionPendingInteraction} motion={motion} />
595
614
  </div>}
596
615
  {showDeliverablesRow(boundary.status, deliverables) && <DeliverablesRow deliverables={deliverables} openFile={props.openFile} revealFile={props.revealFile} openMode={openMode} />}
616
+ {boundary.status === 'closed' && <BlockBoundary><OfficialTail official={official} owner={tailOwner} produced={deliverables} /></BlockBoundary>}
597
617
  {showTerminalNotice && <div className={css.notice} data-reader-terminal>{terminal}</div>}
598
618
  </section>;
599
619
  });
@@ -622,6 +642,14 @@ export function Reader(props: ReaderProps) {
622
642
  );
623
643
  const storeAutoFold = props.useStore(state => state.autoFold);
624
644
  const autoFold = prefsSnap?.autoFold ?? (prefsSnap?.foldIntensity !== undefined ? prefsSnap.foldIntensity !== 0 : undefined) ?? storeAutoFold ?? true;
645
+ const previousAutoFold = useRef(autoFold);
646
+ useLayoutEffect(() => {
647
+ const restored = autoFold && !previousAutoFold.current;
648
+ previousAutoFold.current = autoFold;
649
+ // Watch the effective preference so toolbar and Settings changes behave alike.
650
+ // Reading while folding is off must not pin completed process content forever.
651
+ if (restored) props.actions.resetExpanded();
652
+ }, [autoFold, props.actions]);
625
653
  const frostedGlass = frostedGlassOf(prefsSnap);
626
654
  const streamMotion = useMemo(() => ({ enabled: motion, activatedAt: activatedAt.current }), [motion]);
627
655
  const groups = useMemo(() => groupNodes(order, key => nodes.get(key)), [order, nodes, timeline]);
@@ -788,7 +816,7 @@ export function Reader(props: ReaderProps) {
788
816
 
789
817
  // ChatView publishes data-chat-flow="" on its column. Skins treat a
790
818
  // scrollport without that hook as inspect-only and hide [data-composer-seat].
791
- return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-dsh-better-display="0.2.1" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
819
+ return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.0-preview.1" data-dsh-better-display="0.3.0-preview.1" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
792
820
  <TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
793
821
  <div className={css.column} data-chat-flow="">
794
822
  <StickyLane kind="toolbar" className={css.toolbar}>
@@ -4,6 +4,7 @@ import type { DiffHunk, ReadBlockLine, SearchFileGroup } from '@deepseek-ai/dsh-
4
4
  import { DiffBlock, DisclosureRow, JsonTree, ReadBlock, SearchBlock, TerminalBlock, WebBlock,
5
5
  IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconSkillOutline16, IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives';
6
6
  import { Blocks, contentBlocks } from './Blocks.js';
7
+ import { OfficialTool } from './OfficialContent.js';
7
8
  import { ProcessFragment } from './motion.js';
8
9
  import { activityPhase, activitySummary, callDiffHunks, diffTotals, executionFacts, objectValue, toolIdentity } from './tool-activity.js';
9
10
  import type { ToolActivityEntry, ToolCategory, ToolPhase } from './tool-activity.js';
@@ -14,7 +15,7 @@ import { diffBlockLabels, jsonTreeLabels, readBlockLabels, searchBlockLabels, te
14
15
  import css from './Reader.module.css';
15
16
 
16
17
  const LABEL: Record<ToolPhase, string> = { preparing: '输入生成中', running: '执行中', returned: '已返回', succeeded: '已完成', failed: '失败', interrupted: '已中断' };
17
- const ICONS = { write: IconEditOutline16, read: IconBrowseOutline16, terminal: IconApiOutline14, search: IconSearchOutline16, web: IconSearchOutline16, other: IconSparkle16 } satisfies Record<ToolCategory, unknown>;
18
+ const ICONS = { write: IconEditOutline16, read: IconBrowseOutline16, terminal: IconApiOutline14, search: IconSearchOutline16, web: IconSearchOutline16, code: IconApiOutline14, other: IconSparkle16 } satisfies Record<ToolCategory, unknown>;
18
19
  const number = new Intl.NumberFormat('zh-CN');
19
20
  const language = (path: string | undefined) => path?.split('.').at(-1);
20
21
  const duration = (ms: number) => ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} 秒`;
@@ -80,28 +81,14 @@ function searchFiles(value: unknown): SearchFileGroup[] | null {
80
81
  return files;
81
82
  }
82
83
 
83
- function CustomToolWrapper({ Component, block, toolName, cwd, openFile }: {
84
- Component: any;
85
- block: any;
86
- toolName: string;
87
- cwd?: string;
88
- openFile?: (path: string) => Promise<void> | void;
89
- }) {
90
- const containerRef = useRef<HTMLDivElement>(null);
91
- useEffect(() => {
92
- // If the component renders a collapsed disclosure row (like DiffCard with role="button" and aria-expanded="false"),
93
- // expand it automatically once so details are immediately visible inside ResultView.
94
- const row = containerRef.current?.querySelector<HTMLElement>('[role="button"][aria-expanded="false"]');
95
- if (row) row.click();
96
- }, []);
97
- return (
98
- <div ref={containerRef} data-reader-tool-custom>
99
- <Component block={block} toolName={toolName} cwd={cwd} openFile={openFile} />
100
- </div>
101
- );
84
+ function ResultView(props: BlockRenderProps & { entry: ToolActivityEntry; model: ReturnType<typeof activitySummary>; phase: ToolPhase }) {
85
+ const { official, entry, model } = props;
86
+ const fallback = <ResultFallback {...props} />;
87
+ if (!official || !entry.block || model.name === 'render_ui' || model.name === 'show_widget') return fallback;
88
+ return <OfficialTool {...props} official={official} block={entry.block} toolName={model.name} cwd={props.cwd ?? model.cwd} fallback={fallback} />;
102
89
  }
103
90
 
104
- function ResultView({ entry, model, phase, ...render }: BlockRenderProps & { entry: ToolActivityEntry; model: ReturnType<typeof activitySummary>; phase: ToolPhase }) {
91
+ function ResultFallback({ entry, model, phase, ...render }: BlockRenderProps & { entry: ToolActivityEntry; model: ReturnType<typeof activitySummary>; phase: ToolPhase }) {
105
92
  if ((model.name === 'render_ui' || model.name === 'show_widget') && typeof model.args?.html === 'string') {
106
93
  return <McpAppFrame html={model.args.html as string} title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} fillComposer={render.fillComposer} />;
107
94
  }
@@ -114,11 +101,6 @@ function ResultView({ entry, model, phase, ...render }: BlockRenderProps & { ent
114
101
  const text = block.content.filter(item => item.type === 'text').map(item => item.text).join('\n');
115
102
  if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model} preparing={false} fillComposer={render.fillComposer} /><pre className={css.toolRaw}>{text}</pre></>;
116
103
 
117
- const CustomToolView = render.getToolView?.(model.name);
118
- if (CustomToolView) {
119
- return <CustomToolWrapper Component={CustomToolView} block={block} toolName={model.name} cwd={model.cwd} openFile={render.openFile} />;
120
- }
121
-
122
104
  if (model.category === 'terminal') {
123
105
  const facts = executionFacts(block);
124
106
  const output = text.replace(/\n\[(?:exit code: \d+|killed by signal: [^\]\n]+)\]$/, '');
@@ -154,6 +136,19 @@ function ResultView({ entry, model, phase, ...render }: BlockRenderProps & { ent
154
136
  {generatedInput(model.content, model.target, false)}
155
137
  </>;
156
138
  const content: ToolResultNode['content'] = block.content;
139
+ // A code interpreter's product is its result, not prose. Without this branch
140
+ // the output fell through to the generic document path below, which emits it
141
+ // as reader answer text — so a run_code card sat in the transcript outside
142
+ // every fold, the exact block the reader wanted to be able to close.
143
+ if (model.category === 'code') {
144
+ const output = content.filter(item => item.type === 'text').map(item => item.text).join('\n');
145
+ const facts = executionFacts(block);
146
+ if (output.trim()) return <div data-reader-tool-code>
147
+ <TerminalBlock command={model.command ?? model.name} cwd={model.cwd} output={output}
148
+ exitCode={facts.exitCode} signal={facts.signal} maxLines={18} labels={terminalBlockLabels} />
149
+ </div>;
150
+ return <p className={css.toolDetailNote}>代码已执行,没有可展示的输出。</p>;
151
+ }
157
152
  if (content.some(item => item.type === 'text')) return <div className={css.toolDocument}><Blocks {...render} blocks={contentBlocks(content).filter(item => item.kind === 'text')} source="tool" /></div>;
158
153
  if (content.length) return <p className={css.toolDetailNote}>图片或扩展内容已在对话中单独展示。</p>;
159
154
  return <p className={css.toolDetailNote}>工具没有返回可展示的内容。</p>;
@@ -265,7 +260,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
265
260
  && previous.entry.draft === next.entry.draft && previous.entry.step === next.entry.step
266
261
  && previous.motion === next.motion && previous.turnClosed === next.turnClosed && previous.depth === next.depth
267
262
  && previous.onRead === next.onRead && previous.renderSlotChain === next.renderSlotChain && previous.loadImage === next.loadImage && previous.fillComposer === next.fillComposer
268
- && previous.getToolView === next.getToolView);
263
+ && previous.official === next.official && previous.cwd === next.cwd && previous.openFile === next.openFile);
269
264
 
270
265
  /** Rich media (images, MCP widgets) rendered outside the folded tool ledger. */
271
266
  export function ToolMedia({ block, depth = 0, ...render }: BlockRenderProps & { block: ToolCallBlock; depth?: number }) {
@@ -13,6 +13,8 @@ import { installBetterDisplaySettings } from './settings.js';
13
13
  import { fillComposerDom } from './mcp-app.js';
14
14
  import { fileAddressFor, modeFromSnapshot, openDeliverableFile } from './open-file.js';
15
15
  import type { ReaderInjected } from './types.js';
16
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
17
+ import { installOfficialSlots, officialChildren, type CompositionRegistry } from './official-slots.js';
16
18
 
17
19
  /** Structural face of the sanctioned per-session composer writer. */
18
20
  interface ComposerShell {
@@ -51,7 +53,7 @@ async function openWorkspacePath(
51
53
  export type { ReaderBlockOwner } from './types.js';
52
54
  export { McpAppFrame } from './McpAppFrame.js';
53
55
  export const name = 'dsh-better-display-client';
54
- export const inject = ['slots', 'sessions', 'conversation', 'remote', 'remote.session'];
56
+ export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'remote', 'remote.session'];
55
57
 
56
58
  export function apply(ctx: Context): void {
57
59
  const store = createReaderStore();
@@ -60,14 +62,17 @@ export function apply(ctx: Context): void {
60
62
  // intensity, and open-mode on the unsuffixed `dsh.reader.v1` key.
61
63
  const prefs = store.create();
62
64
  installBetterDisplaySettings(ctx, prefs);
63
- ctx.slots.inject('conversation.view', function* () {
65
+ ctx.slots.inject('conversation.chat.node', function* () {
64
66
  yield ctx.slots.register({
65
67
  name: 'conversation.view',
66
68
  id: 'reader',
67
69
  order: -5,
68
70
  label: () => '阅读',
69
71
  locale: 'chat',
70
- children: { 'dsh-better-display.block': { kind: 'chain', scope: 'session' } },
72
+ children: {
73
+ 'dsh-better-display.block': { kind: 'chain', scope: 'session' },
74
+ ...officialChildren(ctx.slots as unknown as CompositionRegistry),
75
+ },
71
76
  store,
72
77
  inject: (sessionId: SessionId): ReaderInjected => {
73
78
  const session = () => {
@@ -77,13 +82,28 @@ export function apply(ctx: Context): void {
77
82
  };
78
83
  return {
79
84
  openPrefs: prefs,
85
+ officialImageLoader: Object.assign(
86
+ (attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
87
+ { peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) },
88
+ ),
89
+ officialFileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
90
+ officialPreviewFile: path => {
91
+ const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd;
92
+ const sidebar = ctx.get('sidebarRight') as SidebarRightFace | undefined;
93
+ if (!sidebar?.openResource) throw new Error('文件预览面板不可用。');
94
+ sidebar.openResource((officialFileAddressFor ?? fileAddressFor)(sessionId, cwd, path));
95
+ },
96
+ officialHost: {
97
+ getSnapshot: () => ctx.remote.$host,
98
+ subscribe: listener => ctx.on('connection/reset', listener),
99
+ },
80
100
  loadOlder: async () => { await session().loadOlder(); },
81
101
  loadImage: async attachment => {
82
102
  const receipt = await session().readAttachment(attachment.attachmentId);
83
103
  if (!receipt.ok) throw new Error(receipt.error.message);
84
104
  return { data: Uint8Array.from(receipt.value.data), mediaType: receipt.value.attachment.mediaType };
85
105
  },
86
- openFile: async (path: string) => {
106
+ openFile: async (path, options) => {
87
107
  try {
88
108
  const cwd = ctx.sessions?.list?.getSnapshot?.()?.byId[sessionId]?.cwd;
89
109
  const sidebar = (
@@ -98,7 +118,7 @@ export function apply(ctx: Context): void {
98
118
  resolveWorkspacePath,
99
119
  openExternal: async (absolutePath) => { await openWorkspacePath(ctx, absolutePath); },
100
120
  openSidebar: typeof sidebar?.openResource === 'function'
101
- ? (address) => { sidebar.openResource!(address); }
121
+ ? (address) => { sidebar.openResource!(address, options?.line === undefined ? undefined : { params: { line: options.line } }); }
102
122
  : undefined,
103
123
  fileAddressFor: officialFileAddressFor ?? fileAddressFor,
104
124
  warn: (message, extra) => { console.warn(message, extra); },
@@ -190,35 +210,11 @@ export function apply(ctx: Context): void {
190
210
  return false;
191
211
  }
192
212
  },
193
- getToolView: (toolName: string) => {
194
- try {
195
- const slotsService = ctx.slots as unknown as { entriesOfSlot?: (name: string) => unknown[] };
196
- const entries = slotsService?.entriesOfSlot?.('tool.call.toolview') ?? [];
197
- const matches = entries.filter((e: any) => {
198
- const key = e?.options?.key ?? e?.key;
199
- const comp = e?.component ?? e?.view ?? e?.render ?? (typeof e === 'function' ? e : null);
200
- return key === toolName && typeof comp === 'function';
201
- });
202
- if (matches.length === 0) return null;
203
- matches.sort((a: any, b: any) => {
204
- const prioA = a?.options?.priority ?? a?.priority ?? 0;
205
- const prioB = b?.options?.priority ?? b?.priority ?? 0;
206
- return prioA - prioB;
207
- });
208
- const best = matches[0] as any;
209
- const prio = best?.options?.priority ?? best?.priority ?? 0;
210
- const comp = best?.component ?? best?.view ?? best?.render ?? (typeof best === 'function' ? best : null);
211
- if (prio < 0 || (toolName !== 'edit' && toolName !== 'write')) {
212
- return comp;
213
- }
214
- return null;
215
- } catch {
216
- return null;
217
- }
218
- },
213
+
219
214
  };
220
215
  },
221
216
  }, Reader);
217
+ yield installOfficialSlots(ctx);
222
218
  yield installReaderEntry(ctx);
223
219
  });
224
220
  }