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.
- package/CHANGELOG.md +11 -0
- package/README.en.md +4 -2
- package/README.md +4 -2
- package/compat/harness-rc2.json +59 -0
- package/docs/official-rendering-bridge.md +65 -0
- package/lib/client.js +858 -456
- package/lib/client.js.map +1 -1
- package/lib/types/client/Blocks.d.ts +2 -1
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +2 -2
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +25 -0
- package/lib/types/client/OfficialContent.d.ts.map +1 -0
- package/lib/types/client/OfficialContent.js +42 -0
- package/lib/types/client/OfficialContent.js.map +1 -0
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +38 -6
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/ToolActivity.d.ts.map +1 -1
- package/lib/types/client/ToolActivity.js +21 -17
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +23 -33
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/markdown/MarkdownText.d.ts +3 -1
- package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -1
- package/lib/types/client/markdown/MarkdownText.js +14 -7
- package/lib/types/client/markdown/MarkdownText.js.map +1 -1
- package/lib/types/client/markdown/render.d.ts +2 -0
- package/lib/types/client/markdown/render.d.ts.map +1 -1
- package/lib/types/client/markdown/render.js +22 -6
- package/lib/types/client/markdown/render.js.map +1 -1
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +22 -13
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/native/ContextInjectionRow.d.ts +12 -3
- package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
- package/lib/types/client/native/ContextInjectionRow.js +6 -3
- package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +71 -0
- package/lib/types/client/official-slots.d.ts.map +1 -0
- package/lib/types/client/official-slots.js +183 -0
- package/lib/types/client/official-slots.js.map +1 -0
- package/lib/types/client/platform-media.d.ts +6 -0
- package/lib/types/client/platform-media.d.ts.map +1 -0
- package/lib/types/client/platform-media.js +13 -0
- package/lib/types/client/platform-media.js.map +1 -0
- package/lib/types/client/settings.js.map +1 -1
- package/lib/types/client/store.d.ts +1 -0
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/client/store.js +1 -0
- package/lib/types/client/store.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +1 -1
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +9 -2
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/types.d.ts +18 -8
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/client/word-motion.d.ts.map +1 -1
- package/lib/types/client/word-motion.js +2 -1
- package/lib/types/client/word-motion.js.map +1 -1
- package/package.json +22 -16
- package/scripts/check-harness-compat.mjs +71 -0
- package/scripts/link-harness-dependencies.mjs +1 -0
- package/scripts/verify-auto-fold.mjs +114 -0
- package/scripts/verify-official-bridge.mjs +119 -0
- package/src/client/Blocks.tsx +2 -1
- package/src/client/OfficialContent.tsx +57 -0
- package/src/client/Reader.module.css +23 -8
- package/src/client/Reader.tsx +37 -9
- package/src/client/ToolActivity.tsx +22 -27
- package/src/client/index.tsx +27 -31
- package/src/client/markdown/MarkdownText.tsx +14 -6
- package/src/client/markdown/render.tsx +23 -7
- package/src/client/motion.tsx +18 -11
- package/src/client/native/ContextInjectionRow.tsx +19 -7
- package/src/client/official-slots.tsx +211 -0
- package/src/client/platform-media.ts +11 -0
- package/src/client/settings.tsx +1 -1
- package/src/client/store.ts +2 -0
- package/src/client/tool-activity.ts +9 -2
- package/src/client/types.ts +14 -8
- package/src/client/word-motion.tsx +2 -1
- package/tests/browser-auto-fold.tsx +64 -0
- package/tests/browser-official-bridge.tsx +144 -0
- package/tests/official-slots.test.ts +112 -0
- 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
|
+
}
|
package/src/client/Blocks.tsx
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
476
|
-
|
|
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:
|
|
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); }
|
package/src/client/Reader.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
cwd
|
|
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
|
|
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.
|
|
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 }) {
|
package/src/client/index.tsx
CHANGED
|
@@ -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.
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
}
|