dsh-better-display 0.3.0 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.en.md +13 -15
  3. package/README.md +13 -15
  4. package/compat/harness-rc2.json +58 -18
  5. package/docs/official-rendering-bridge.md +1 -1
  6. package/lib/client.js +2227 -4059
  7. package/lib/client.js.map +1 -1
  8. package/lib/dsh-better-display.js +4 -2
  9. package/lib/types/client/OfficialContent.d.ts.map +1 -1
  10. package/lib/types/client/OfficialContent.js +16 -5
  11. package/lib/types/client/OfficialContent.js.map +1 -1
  12. package/lib/types/client/Reader.d.ts.map +1 -1
  13. package/lib/types/client/Reader.js +6 -7
  14. package/lib/types/client/Reader.js.map +1 -1
  15. package/lib/types/client/ToolActivity.js +3 -3
  16. package/lib/types/client/ToolActivity.js.map +1 -1
  17. package/lib/types/client/index.d.ts.map +1 -1
  18. package/lib/types/client/index.js.map +1 -1
  19. package/lib/types/client/markdown/render.d.ts.map +1 -1
  20. package/lib/types/client/markdown/render.js +2 -1
  21. package/lib/types/client/markdown/render.js.map +1 -1
  22. package/lib/types/client/native/ContextInjectionRow.d.ts +2 -2
  23. package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
  24. package/lib/types/client/native/ContextInjectionRow.js +16 -4
  25. package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
  26. package/lib/types/client/native/ReferenceIcon.js +3 -3
  27. package/lib/types/client/native/ReferenceIcon.js.map +1 -1
  28. package/lib/types/client/native/tool-call-model.js +1 -1
  29. package/lib/types/client/native/tool-call-model.js.map +1 -1
  30. package/lib/types/client/native/tool-update.d.ts +23 -0
  31. package/lib/types/client/native/tool-update.d.ts.map +1 -0
  32. package/lib/types/client/native/tool-update.js +22 -0
  33. package/lib/types/client/native/tool-update.js.map +1 -0
  34. package/lib/types/client/official-slots.js +1 -1
  35. package/lib/types/client/official-slots.js.map +1 -1
  36. package/lib/types/client/platform-media.d.ts +1 -1
  37. package/lib/types/client/platform-media.d.ts.map +1 -1
  38. package/lib/types/client/platform-media.js +9 -4
  39. package/lib/types/client/platform-media.js.map +1 -1
  40. package/lib/types/client/primitive-labels.d.ts.map +1 -1
  41. package/lib/types/client/primitive-labels.js +3 -2
  42. package/lib/types/client/primitive-labels.js.map +1 -1
  43. package/lib/types/client/tool-activity.js +2 -2
  44. package/lib/types/client/tool-activity.js.map +1 -1
  45. package/package.json +22 -18
  46. package/scripts/link-harness-dependencies.mjs +2 -0
  47. package/scripts/verify-official-bridge.mjs +6 -4
  48. package/scripts/verify-tool-shimmer.mjs +62 -0
  49. package/src/client/OfficialContent.tsx +19 -7
  50. package/src/client/Reader.module.css +17 -2
  51. package/src/client/Reader.tsx +8 -9
  52. package/src/client/ToolActivity.tsx +3 -3
  53. package/src/client/index.tsx +2 -0
  54. package/src/client/markdown/render.tsx +2 -1
  55. package/src/client/native/ContextInjectionRow.module.css +4 -0
  56. package/src/client/native/ContextInjectionRow.tsx +32 -10
  57. package/src/client/native/ReferenceIcon.tsx +3 -3
  58. package/src/client/native/tool-call-model.ts +1 -1
  59. package/src/client/native/tool-update.ts +32 -0
  60. package/src/client/official-slots.tsx +1 -1
  61. package/src/client/platform-media.ts +9 -3
  62. package/src/client/primitive-labels.ts +3 -2
  63. package/src/client/tool-activity.ts +2 -2
  64. package/tests/browser-auto-fold.tsx +1 -1
  65. package/tests/browser-official-bridge.tsx +17 -9
  66. package/tests/context-tool-update.test.ts +33 -0
  67. package/tests/platform-media.test.ts +6 -0
  68. package/tests/stock-install.test.ts +17 -0
  69. package/tsdown.config.ts +7 -5
@@ -22,6 +22,7 @@ const paths = {
22
22
  feedback: 'ui-message-feedback/src/client/index.ts', 'read-row': 'ui-tool/src/client/tool/toolviews/read-row.tsx',
23
23
  'read-image-row': 'ui-tool/src/client/tool/toolviews/read-image-row.tsx', 'tool-locale': 'ui-conversation/src/client/locales.ts',
24
24
  deliverables: 'ui-deliverables/src/client/Deliverables.tsx', 'deliverables-locale': 'ui-deliverables/src/client/locales.ts',
25
+ 'open-in-app-action': 'ui-open-in-app/src/client/FileRouteAction.tsx', 'open-in-app-locale': 'ui-open-in-app/src/client/locales.ts',
25
26
  };
26
27
  const webRequire = createRequire(join(harness, 'packages/client/web/package.json'));
27
28
  await build({
@@ -39,6 +40,7 @@ const pixel = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQ
39
40
  const server = createServer(async (req, res) => {
40
41
  if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); res.end('<!doctype html><html lang="zh"><meta charset="utf-8"><link rel="stylesheet" href="/fixture.css"><style>body{font-family:system-ui;margin:24px}button{cursor:pointer}</style><div id="app"></div><script type="module" src="/fixture.js"></script></html>'); return; }
41
42
  if (req.url?.startsWith('/api/file?') || req.url === '/pixel.png') { res.setHeader('Content-Type', 'image/png'); res.end(pixel); return; }
43
+ if (req.url?.startsWith('/api/present.open')) { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify([{ id: 'preview-app', name: 'Preview App', default: true, icon: null }, { id: 'other-app', name: 'Other App', default: false, icon: null }])); return; }
42
44
  const file = req.url === '/fixture.js' ? 'fixture.js' : req.url === '/fixture.css' ? 'fixture.css' : null;
43
45
  if (!file) { res.writeHead(404); res.end(); return; }
44
46
  res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css');
@@ -90,11 +92,11 @@ try {
90
92
  await tool.getByText('export const value = 1;', { exact: true }).waitFor();
91
93
  assert.equal(await page.locator('[data-presented-file]').count(), 1);
92
94
  assert.equal(await page.locator('[data-produced-files-row]').count(), 0);
93
- await page.getByRole('button', { name: '在侧边栏打开 /fixture/report.pdf', exact: true }).click();
95
+ await page.getByRole('button', { name: '在侧边栏预览 /fixture/report.pdf', exact: true }).click();
94
96
  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']);
97
+ await page.getByRole('button', { name: '更多打开方式', exact: true }).click();
98
+ await page.getByRole('menuitem', { name: 'Other App', exact: true }).click();
99
+ assert.deepEqual(await page.evaluate(() => window.fixture.observations.opens.at(-1)), ['presented', 'session-a', 42, 0, 'open', 'other-app']);
98
100
  await page.evaluate(() => window.fixture.language());
99
101
  await page.getByRole('button', { name: 'Good response', exact: true }).waitFor();
100
102
  const image = page.locator('img[alt="本地样例"]');
@@ -0,0 +1,62 @@
1
+ /** Offline pixel regression for the shipped tool-summary CSS; no Host or model. */
2
+ import assert from 'node:assert/strict';
3
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ const root = resolve(import.meta.dirname, '..');
9
+ const bundle = resolve(process.argv[2] ?? join(root, 'lib/client.js'));
10
+ const out = join(root, '.evidence/tool-shimmer');
11
+ await mkdir(out, { recursive: true });
12
+ const js = await readFile(bundle, 'utf8');
13
+ const line = js.split('\n').find(line => /const css\$\d+ = "\.\w+_root\{/.test(line));
14
+ assert.ok(line, 'Reader CSS must be present in the shipped bundle');
15
+ const css = JSON.parse(line.slice(line.indexOf('=') + 1).trim().replace(/;$/, ''));
16
+ const summary = css.match(/\.(\w+_nativeToolSummary)\[data-running\]/)?.[1];
17
+ assert.ok(summary);
18
+ const { launchPinnedChromium } = await import(pathToFileURL(join(homedir(), '.codex/playwright-runtime/runtime.mjs')).href);
19
+ const browser = await launchPinnedChromium();
20
+ const results = [];
21
+ try {
22
+ for (const theme of ['dark', 'light']) for (const width of [180, 800]) {
23
+ const page = await browser.newPage({ viewport: { width: 900, height: 120 }, deviceScaleFactor: 2 });
24
+ await page.setContent(`<style>${css}body{margin:20px;background:${theme === 'dark' ? '#141414' : '#fff'};--dsw-alias-label-tertiary:${theme === 'dark' ? '#aaa' : '#666'};font:14px system-ui}.row{display:flex;width:${width}px}</style><div class="row" data-motion="on"><span data-running class="${summary}">Re-run acceptance criteria verification</span></div>`);
25
+ const target = page.locator(`.${summary}`);
26
+ const capture = async time => {
27
+ await target.evaluate((el, time) => {
28
+ const animations = el.getAnimations();
29
+ if (animations.length !== 1) throw new Error(`Expected one animation, found ${animations.length}`);
30
+ animations[0].pause(); animations[0].currentTime = time;
31
+ }, time);
32
+ return target.screenshot();
33
+ };
34
+ const timing = await target.evaluate(el => el.getAnimations()[0].effect.getTiming().duration);
35
+ const first = await capture(0);
36
+ const middle = await capture(timing * .25);
37
+ const beforeWrap = await capture(timing - 1);
38
+ const afterWrap = await capture(timing);
39
+ assert.notDeepEqual(middle, first, 'The highlight must visibly travel across the text');
40
+ assert.deepEqual(beforeWrap, first, 'The sweep must fully leave the text before wrapping');
41
+ assert.deepEqual(afterWrap, first, 'The cycle boundary must preserve identical text pixels');
42
+ await writeFile(join(out, `${theme}-${width}-start.png`), first);
43
+ await writeFile(join(out, `${theme}-${width}-middle.png`), middle);
44
+ await writeFile(join(out, `${theme}-${width}-end.png`), beforeWrap);
45
+ const runningStyle = await target.evaluate(el => ({ repeat: getComputedStyle(el).backgroundRepeat, animationCount: el.getAnimations().length }));
46
+ assert.equal(runningStyle.repeat, 'no-repeat');
47
+ await page.locator('[data-motion]').evaluate(el => { el.dataset.motion = 'off'; });
48
+ assert.equal(await target.evaluate(el => el.getAnimations().length), 0, 'Reader motion-off must stop shimmer');
49
+ await page.locator('[data-motion]').evaluate(el => { el.dataset.motion = 'on'; });
50
+ await page.emulateMedia({ reducedMotion: 'reduce' });
51
+ const reduced = await target.evaluate(el => ({ count: el.getAnimations().length, fill: getComputedStyle(el).webkitTextFillColor }));
52
+ assert.equal(reduced.count, 0, 'System reduced motion must stop shimmer');
53
+ assert.notEqual(reduced.fill, 'rgba(0, 0, 0, 0)', 'Stopped animation must keep text readable');
54
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
55
+ await target.evaluate(el => el.removeAttribute('data-running'));
56
+ assert.equal(await target.evaluate(el => el.getAnimations().length), 0, 'Settled tools must not shimmer');
57
+ results.push({ theme, width, loopPixelsContinuous: true, ...runningStyle, motionOff: true, reducedMotion: true, settled: true });
58
+ await page.close();
59
+ }
60
+ await writeFile(join(out, 'result.json'), JSON.stringify({ bundle, results }, null, 2));
61
+ console.log(JSON.stringify({ passed: true, results }));
62
+ } finally { await browser.close(); }
@@ -1,5 +1,6 @@
1
- import { useSyncExternalStore, type ReactNode } from 'react';
2
- import type { ChatConversationViewNode, ChatNodeOwnerProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
1
+ import { useCallback, useState, useSyncExternalStore, type ReactNode } from 'react';
2
+ import type { ChatConversationViewNode, ChatNodeOwnerProps, TurnTailOwnerProps, UseDisclosure } from '@deepseek-ai/dsh-client-ui-chat/client';
3
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
3
4
  import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
4
5
  import type { AssistantActionOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
5
6
  type MessageId = AssistantActionOwnerProps['messageId'];
@@ -13,26 +14,37 @@ export function OfficialActions({ official, messageId }: Pick<BlockRenderProps,
13
14
  </span>;
14
15
  }
15
16
 
17
+ /** Each atomic Tool view owns its disclosure state through this stable Hook prop. */
18
+ const useToolDisclosure: UseDisclosure = () => {
19
+ const [expanded, setExpanded] = useState(false);
20
+ const toggle = useCallback(() => setExpanded(previous => !previous), []);
21
+ return { expanded, setExpanded, toggle };
22
+ };
23
+
16
24
  export function OfficialTool({ official, block, toolName, cwd, openFile, fallback }: BlockRenderProps & {
17
25
  official: NonNullable<BlockRenderProps['official']>; block: ToolCallBlock; toolName: string; fallback: ReactNode;
18
26
  }) {
19
27
  const home = useSyncExternalStore(official.officialHost.subscribe, official.officialHost.getSnapshot).home;
20
28
  return <div data-reader-tool-official>
21
- {official.renderSlot(OFFICIAL_SEATS.tools, {
22
- callId: block.callId, toolName, block, cwd, home,
29
+ {(official.renderSlot as (key: string, owner: object, options: object) => ReactNode)(OFFICIAL_SEATS.tools, {
30
+ callId: block.callId, toolName,
31
+ phase: 'kind' in block ? 'result' : block.phase, block, cwd, home,
23
32
  openFile: openFile ?? (() => {}),
24
33
  loadImage: official.officialImageLoader,
34
+ useDisclosure: useToolDisclosure,
25
35
  inspect: () => official.openView('trajectory', block.callId),
26
- }, { entryKey: toolName, fallback })}
36
+ }, { entryKey: toolName, hookContext: { callId: block.callId, assistant: undefined }, fallback })}
27
37
  </div>;
28
38
  }
29
39
 
30
40
  export function OfficialNode({ node, fallback, ...render }: BlockRenderProps & { node: ChatConversationViewNode; fallback: ReactNode }) {
31
41
  const official = render.official;
42
+ const [disclosureReset] = useState(() => createSnapshotStore(0));
32
43
  if (!official) return fallback;
33
44
  const owner: ChatNodeOwnerProps = {
34
45
  cwd: render.cwd,
35
46
  openFile: render.openFile ?? (() => {}),
47
+ openSkill: () => {},
36
48
  forkAt: render.forkAt ?? (() => {}),
37
49
  inspectCall: callId => official.openView('trajectory', callId),
38
50
  loadImage: official.officialImageLoader,
@@ -45,13 +57,13 @@ export function OfficialNode({ node, fallback, ...render }: BlockRenderProps & {
45
57
  // The runtime node domain is open; the public SlotMap enumerates the known kinds.
46
58
  const renderNode = official.renderSlot as unknown as (key: string, owner: object, options: object) => ReactNode;
47
59
  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 })}
60
+ {renderNode(OFFICIAL_SEATS.nodes, { ...owner, node }, { entryKey: node.kind, hookContext: { turnData: turn?.data, disclosureReset }, fallback })}
49
61
  </div>;
50
62
  }
51
63
 
52
64
  export function OfficialTail({ official, owner, produced }: Pick<BlockRenderProps, 'official'> & { owner?: TurnTailOwnerProps; produced: readonly string[] }) {
53
65
  const tailOwner = owner && official ? { ...owner, openFile: official.officialPreviewFile, readerProducedPaths: produced } : undefined;
54
66
  return official && tailOwner ? <div data-reader-official-tail style={{ display: 'contents' }}>
55
- {official.renderSlotChain(OFFICIAL_SEATS.tail, tailOwner)}
67
+ {official.renderSlot(OFFICIAL_SEATS.tail, tailOwner)}
56
68
  </div> : null;
57
69
  }
@@ -250,13 +250,16 @@
250
250
  currentColor 0%, currentColor 35%,
251
251
  color-mix(in srgb, currentColor 35%, transparent) 50%,
252
252
  currentColor 65%, currentColor 100%);
253
- background-size: 220% 100%;
253
+ /* Keep the travelling band outside the text at both loop endpoints. A single
254
+ tile covers the row throughout, so wrapping cannot start a second sweep. */
255
+ background-size: 300% 100%;
256
+ background-repeat: no-repeat;
254
257
  background-position: 100% 0;
255
258
  -webkit-background-clip: text; background-clip: text;
256
259
  -webkit-text-fill-color: transparent;
257
260
  animation: readerStepShimmer 1.6s linear infinite;
258
261
  }
259
- @keyframes readerStepShimmer { to { background-position: -120% 0; } }
262
+ @keyframes readerStepShimmer { to { background-position: 0% 0; } }
260
263
  /* The duration stays on a step that ran long, so a slow command keeps its number;
261
264
  a fast one shows nothing once it returns. */
262
265
  .toolElapsed[data-slow] { opacity: 1; font-weight: 500;
@@ -538,8 +541,19 @@
538
541
  pinned straight on top of it. Give each lane its own band instead, stacked
539
542
  from the measured top: status first, then the summary, then the fold body. */
540
543
  .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; }
544
+ /* A pinned lane's in-flow gap stays visible as an uncovered scrollport strip
545
+ while the lane itself paints opaque. Cast each plate upward over its strip
546
+ (the .turn 14px gap / the prior cell's 16px flowCellInner padding) so the
547
+ lanes read as one band instead of solid plates with notches between them.
548
+ The status lane reserves --reader-control-width on its right for the
549
+ toolbar's control group, which leaves the same kind of strip there; paint
550
+ it too. The toolbar is z-index 7, above the lane's 6, so its buttons still
551
+ render on top of the extension. */
552
+ .turnProcessSticky { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base), var(--reader-control-width, 148px) 0 0 var(--dsw-alias-bg-base); }
541
553
  /* The same summary row stays in flow until it reaches the measured top lane. */
542
554
  .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; }
555
+ .flowCell[data-flow-summary] { box-shadow: 0 -16px 0 var(--dsw-alias-bg-base); }
556
+ .closedProcessSummary { box-shadow: 0 -14px 0 var(--dsw-alias-bg-base); }
543
557
  .foldControl { display: inline-flex; align-items: center; gap: 0; padding: 2px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform); }
544
558
  .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; }
545
559
  .foldStop[data-on] { background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); }
@@ -573,6 +587,7 @@
573
587
  a glass reader keeps exactly the same footer precedence as an opaque one. */
574
588
  .root[data-reader-glass] .flowCell[data-flow-summary],
575
589
  .root[data-reader-glass] .closedProcessSummary {
590
+ box-shadow: none;
576
591
  position: static;
577
592
  z-index: auto;
578
593
  min-height: 32px;
@@ -272,8 +272,7 @@ function subagentCount(snapshot: { nodes: { get(key: string): ChatConversationVi
272
272
  return 0;
273
273
  }
274
274
 
275
- function GroupStatus({ group, sessionId, useChat, useSessionPendingInteraction, motion }: Pick<ReaderProps, 'sessionId' | 'useChat' | 'useSessionPendingInteraction'> & { group: ReaderGroup; motion: boolean }) {
276
- const pending = useSessionPendingInteraction(snapshot => snapshot.get(sessionId));
275
+ function GroupStatus({ group, sessionId, useChat, pending, motion }: Pick<ReaderProps, 'sessionId' | 'useChat'> & { group: ReaderGroup; motion: boolean; pending: { kind?: string } | undefined }) {
277
276
  const text = useChat(snapshot => {
278
277
  const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
279
278
  if (turn?.status === 'closed') {
@@ -477,7 +476,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
477
476
  const snapshot = props.useChat(snapshot => snapshot);
478
477
  const nodes = snapshot.nodes;
479
478
  const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
480
- const interaction = props.useSessionPendingInteraction(snapshot => snapshot.get(props.sessionId));
479
+ const interaction = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
481
480
  const sessionRunning = props.useSession(snapshot => snapshot.running);
482
481
  const boundary = useMemo(() => boundaryOf(turn), [turn]);
483
482
  const choiceKey = processChoiceKey(group.key, boundary);
@@ -538,8 +537,8 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
538
537
  const metrics = useMemo(() => ({
539
538
  usage: tailData?.tokenUsage,
540
539
  runMs,
541
- tokensPerSecond: tailData?.tokensPerSecond,
542
- ttftMs: tailData?.ttftMs,
540
+ tokensPerSecond: (tailData as { tokensPerSecond?: number } | undefined)?.tokensPerSecond,
541
+ ttftMs: (tailData as { ttftMs?: number } | undefined)?.ttftMs,
543
542
  endedAt: tailData?.closing?.time ?? turn?.end?.time,
544
543
  }), [tailData, runMs, turn?.end?.time]);
545
544
  const forkSeq = forkAnchorSeq([tailData?.closing?.finalNode]);
@@ -594,7 +593,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
594
593
  {startsWithUser && <BlockBoundary><MainNode {...shared} useChat={props.useChat} boundary={boundary} nodeKey={group.keys[0]} /></BlockBoundary>}
595
594
  {hasProcess && !isAwaitingModel && <StickyLane kind="status" className={css.turnProcessSticky}>
596
595
  <Disclosure open={expanded} onChange={setExpanded} controls={flowId} buttonRef={processButton}
597
- label={<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} useSessionPendingInteraction={props.useSessionPendingInteraction} motion={motion} />} />
596
+ label={<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />} />
598
597
  </StickyLane>}
599
598
  {boundary.status === 'closed' && hasProcess && autoFold && <ClosedProcessSummary open={expanded} onChange={setExpanded} controls={flowId}
600
599
  steps={steps.filter(step => {
@@ -610,7 +609,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
610
609
  onOpenChange={(key, value) => { pinProcess(); setFoldOpenByKey(current => ({ ...current, [key]: value })); }} renderStep={renderStep} />
611
610
  {/* 状态指示永远排在流程之后:AI 的响应永远出现在最新消息(含补充消息)的下方 */}
612
611
  {!hasProcess && boundary.status === 'open' && !isAwaitingModel && <div className={css.disclosure} data-reader-status-only>
613
- <GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} useSessionPendingInteraction={props.useSessionPendingInteraction} motion={motion} />
612
+ <GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />
614
613
  </div>}
615
614
  {showDeliverablesRow(boundary.status, deliverables) && <DeliverablesRow deliverables={deliverables} openFile={props.openFile} revealFile={props.revealFile} openMode={openMode} />}
616
615
  {boundary.status === 'closed' && <BlockBoundary><OfficialTail official={official} owner={tailOwner} produced={deliverables} /></BlockBoundary>}
@@ -625,7 +624,7 @@ export function Reader(props: ReaderProps) {
625
624
  const nodes = props.useChat(snapshot => snapshot.nodes);
626
625
  const timeline = props.useChat(snapshot => snapshot.timeline);
627
626
  const running = props.useSession(snapshot => snapshot.running);
628
- const pending = props.useSessionPendingInteraction(snapshot => snapshot.get(props.sessionId));
627
+ const pending = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
629
628
  const openError = props.useSession(snapshot => snapshot.openError);
630
629
  const loading = props.useSession(snapshot => snapshot.openState === 'loading');
631
630
  const hasMore = props.useSession(snapshot => snapshot.hasMore);
@@ -816,7 +815,7 @@ export function Reader(props: ReaderProps) {
816
815
 
817
816
  // ChatView publishes data-chat-flow="" on its column. Skins treat a
818
817
  // scrollport without that hook as inspect-only and hide [data-composer-seat].
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'}>
818
+ return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.4" data-dsh-better-display="0.3.4" 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'}>
820
819
  <TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
821
820
  <div className={css.column} data-chat-flow="">
822
821
  <StickyLane kind="toolbar" className={css.toolbar}>
@@ -2,7 +2,7 @@ import { memo, useEffect, useId, useMemo, useRef, useState } from 'react';
2
2
  import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
3
3
  import type { DiffHunk, ReadBlockLine, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives';
4
4
  import { DiffBlock, DisclosureRow, JsonTree, ReadBlock, SearchBlock, TerminalBlock, WebBlock,
5
- IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconSkillOutline16, IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives';
5
+ IconApiOutlineRegular, IconBrowseOutlineRegular, IconEditOutlineRegular, IconSearchOutlineRegular, IconSkillOutlineRegular, IconSparkleRegular } from '@deepseek-ai/dsh-client-ui-primitives';
6
6
  import { Blocks, contentBlocks } from './Blocks.js';
7
7
  import { OfficialTool } from './OfficialContent.js';
8
8
  import { ProcessFragment } from './motion.js';
@@ -15,7 +15,7 @@ import { diffBlockLabels, jsonTreeLabels, readBlockLabels, searchBlockLabels, te
15
15
  import css from './Reader.module.css';
16
16
 
17
17
  const LABEL: Record<ToolPhase, string> = { preparing: '输入生成中', running: '执行中', returned: '已返回', succeeded: '已完成', failed: '失败', interrupted: '已中断' };
18
- const ICONS = { write: IconEditOutline16, read: IconBrowseOutline16, terminal: IconApiOutline14, search: IconSearchOutline16, web: IconSearchOutline16, code: IconApiOutline14, other: IconSparkle16 } satisfies Record<ToolCategory, unknown>;
18
+ const ICONS = { write: IconEditOutlineRegular, read: IconBrowseOutlineRegular, terminal: IconApiOutlineRegular, search: IconSearchOutlineRegular, web: IconSearchOutlineRegular, code: IconApiOutlineRegular, other: IconSparkleRegular } satisfies Record<ToolCategory, unknown>;
19
19
  const number = new Intl.NumberFormat('zh-CN');
20
20
  const language = (path: string | undefined) => path?.split('.').at(-1);
21
21
  const duration = (ms: number) => ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} 秒`;
@@ -179,7 +179,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
179
179
  return () => document.removeEventListener('selectionchange', track);
180
180
  }, []);
181
181
  const facts = executionFacts(entry.block);
182
- const Icon = model.name === 'skill' ? IconSkillOutline16 : ICONS[model.category];
182
+ const Icon = model.name === 'skill' ? IconSkillOutlineRegular : ICONS[model.category];
183
183
  const block = entry.block;
184
184
  const native = block ? toolRowModel(model.name, block) : null;
185
185
  const skillName = typeof model.args?.name === 'string' ? model.args.name.split('\n')[0] : model.raw.split('\n')[0];
@@ -1,5 +1,7 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
+ import type {} from '@deepseek-ai/dsh-api-remotes/client';
2
3
  import type {} from '@deepseek-ai/dsh-api-session-controller/client';
4
+ import type {} from '@deepseek-ai/dsh-client-connection/client';
3
5
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
4
6
  import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path';
5
7
  import * as workspacePathPkg from '@deepseek-ai/dsh-util-workspace-path';
@@ -524,7 +524,8 @@ function renderImage(url: string, alt: string, key: Key, context: MarkdownRender
524
524
  let imageSrc = remote
525
525
  if (rewritten !== undefined) {
526
526
  try {
527
- if (['http:', 'https:', 'blob:', 'data:'].includes(new URL(rewritten).protocol)) imageSrc = rewritten
527
+ const parsed = new URL(rewritten)
528
+ if (['http:', 'https:', 'blob:', 'data:'].includes(parsed.protocol) || rewritten.startsWith('dsh-app://app/api/file?')) imageSrc = rewritten
528
529
  } catch { /* A vocabulary result must be an absolute URL. */ }
529
530
  }
530
531
  if (imageSrc === undefined) {
@@ -60,3 +60,7 @@
60
60
  /* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
61
61
  font: 400 11px/16px var(--ds-font-family-code);
62
62
  }
63
+
64
+ .toolChanges {
65
+ white-space: nowrap;
66
+ }
@@ -1,9 +1,10 @@
1
1
  import { useState } from 'react'
2
2
  import type { ContextMessageNode } from '@deepseek-ai/dsh-client-ui-conversation/client'
3
3
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
4
- import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import { DisclosureRow, IconBrowseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
5
5
  import { ReferenceIcon } from './ReferenceIcon.js'
6
6
  import { contextBody } from './ContextBody.js'
7
+ import { toolUpdateRows } from './tool-update.js'
7
8
  import css from './ContextInjectionRow.module.css'
8
9
 
9
10
  /** Props for the logged non-user message presentation. */
@@ -18,9 +19,9 @@ export interface ContextInjectionRowProps {
18
19
  * optional here and resolved below, so the row renders on either host instead
19
20
  * of throwing on `undefined.role` and degrading its whole block boundary.
20
21
  */
21
- provenance?: ContextMessageNode['provenance'] | null
22
+ provenance?: ContextMessageNode['producer'] | null
22
23
  /** Alpha-generation spelling of {@link provenance}. */
23
- producer?: ContextMessageNode['provenance'] | null
24
+ producer?: ContextMessageNode['producer'] | null
24
25
  /** Producer-declared information form; null renders the opaque body. */
25
26
  form: ContextMessageNode['form']
26
27
  /** The owning view's locale seat, passed down as a plain prop. */
@@ -46,16 +47,32 @@ export function ContextInjectionRow({ content, source, provenance, producer, for
46
47
  // Resolved rather than declared: a form whose fields are unreadable renders
47
48
  // the opaque body, and the marker must say what the row actually shows.
48
49
  const { rendered, summary, body } = contextBody(form, { content, source, t })
50
+ const tools = toolUpdateRows(content)
51
+ const toolSummary = tools === null || tools.single !== null ? null
52
+ : tools.added.length > 0 && tools.removed.length > 0
53
+ ? t('message.toolsChanged', { added: tools.added.length, removed: tools.removed.length })
54
+ : tools.added.length > 0
55
+ ? t('message.toolsAddedCount', { count: tools.added.length })
56
+ : t('message.toolsRemovedCount', { count: tools.removed.length })
49
57
 
50
58
  return (
51
59
  <DisclosureRow
52
60
  className={css.root}
53
- icon={view.role === 'recall'
61
+ icon={view.role === 'recall' && tools === null
54
62
  ? <span data-context-recall-icon><ReferenceIcon kind="session" /></span>
55
- : <IconBrowseOutline16 size={14} />}
63
+ : <IconBrowseOutlineRegular size={14} />}
56
64
  chevronClassName={css.chevron}
57
- title={t(view.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
58
- collapsedContent={view.label === null ? undefined : (
65
+ title={tools?.single !== undefined && tools.single !== null
66
+ ? t(tools.single.type === 'tool-addition' ? 'message.toolAdded' : 'message.toolRemoved', { name: tools.single.toolName })
67
+ : tools !== null
68
+ ? t('message.toolsUpdated')
69
+ : t(view.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
70
+ collapsedContent={toolSummary !== null ? (
71
+ <>
72
+ <span className={css.sep} aria-hidden />
73
+ <span className={css.summary}>{toolSummary}</span>
74
+ </>
75
+ ) : tools !== null || view.label === null ? undefined : (
59
76
  /* ToolRow's separator shape: an aria-hidden dot, so the accessible name
60
77
  stays the two readable parts and the two disclosure rows expose one
61
78
  name shape. A source that names no producer drops the dot with it. */
@@ -71,13 +88,18 @@ export function ContextInjectionRow({ content, source, provenance, producer, for
71
88
  </>
72
89
  )}
73
90
  keepContentWhenOpen
74
- open={open}
75
- expandable
91
+ open={open && tools?.single == null}
92
+ expandable={tools?.single == null}
76
93
  expandOnRowClick
77
94
  onToggle={() => { setOpen(value => !value) }}
78
95
  >
79
96
  <div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
80
- {body}
97
+ {tools === null ? body : (
98
+ <div className={css.toolChanges}>
99
+ {tools.added.length > 0 && <div>{t('message.toolsAdded', { names: tools.added.join(', ') })}</div>}
100
+ {tools.removed.length > 0 && <div>{t('message.toolsRemoved', { names: tools.removed.join(', ') })}</div>}
101
+ </div>
102
+ )}
81
103
  </div>
82
104
  </DisclosureRow>
83
105
  )
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import {
3
- IconBrowseOutline16, IconFolderClose16,
3
+ IconBrowseOutlineRegular, IconFolderCloseRegular,
4
4
  } from '@deepseek-ai/dsh-client-ui-primitives'
5
5
 
6
6
  /** Reference domains with distinct composer and transcript glyphs. */
@@ -29,7 +29,7 @@ export function ReferenceIcon({ kind, size = 16, className }: ReferenceIconProps
29
29
  />
30
30
  </svg>
31
31
  )
32
- case 'file': return <IconBrowseOutline16 size={size} className={className} />
33
- case 'folder': return <IconFolderClose16 size={size} className={className} />
32
+ case 'file': return <IconBrowseOutlineRegular size={size} className={className} />
33
+ case 'folder': return <IconFolderCloseRegular size={size} className={className} />
34
34
  }
35
35
  }
@@ -217,7 +217,7 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
217
217
  export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string, home?: string): ToolRowModel {
218
218
  const variant = classifyTool(toolName)
219
219
  const done = 'kind' in block
220
- const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
220
+ const argsRaw = (done ? block.call?.argsRaw : ('argsRaw' in block ? block.argsRaw : '')) ?? ''
221
221
  const state: ToolRowState = !done ? 'running'
222
222
  : block.error?.code === 'interrupted' ? 'stopped'
223
223
  : block.isError ? 'error' : 'ok'
@@ -0,0 +1,32 @@
1
+ /** One developer tool-change block the 0.1.7-rc.2 context row names in place. */
2
+ export interface ToolChangeBlock {
3
+ type: 'tool-addition' | 'tool-removal'
4
+ toolName: string
5
+ }
6
+
7
+ /** Names collected from a context whose every block is a tool change. */
8
+ export interface ToolUpdateRows {
9
+ added: readonly string[]
10
+ removed: readonly string[]
11
+ /** The only block, when the row is a single add or removal. */
12
+ single: ToolChangeBlock | null
13
+ }
14
+
15
+ /**
16
+ * Classify a context as the rc.2 tool-update row.
17
+ * Mixed or empty content stays on the generic injection presentation.
18
+ * @param content - durable context blocks.
19
+ * @returns collected names, or null when any block is not a tool change.
20
+ */
21
+ export function toolUpdateRows(content: readonly { readonly type: string; readonly toolName?: string }[]): ToolUpdateRows | null {
22
+ if (content.length === 0) return null
23
+ const rows: ToolChangeBlock[] = []
24
+ for (const block of content) {
25
+ if (block.type !== 'tool-addition' && block.type !== 'tool-removal') return null
26
+ if (typeof block.toolName !== 'string') return null
27
+ rows.push({ type: block.type, toolName: block.toolName })
28
+ }
29
+ const added = rows.flatMap(row => row.type === 'tool-addition' ? [row.toolName] : [])
30
+ const removed = rows.flatMap(row => row.type === 'tool-removal' ? [row.toolName] : [])
31
+ return { added, removed, single: rows.length === 1 ? rows[0]! : null }
32
+ }
@@ -62,7 +62,7 @@ const READER_NODES = new Set(['user', 'steering', 'assistant-step', 'tool-call',
62
62
  const EXPECTED: Record<OfficialFamily, Spec> = {
63
63
  actions: { kind: 'list', scope: 'session' },
64
64
  tools: { kind: 'keyed', scope: 'session' },
65
- tail: { kind: 'chain', scope: 'session' },
65
+ tail: { kind: 'list', scope: 'session' },
66
66
  nodes: { kind: 'keyed', scope: 'session' },
67
67
  images: { kind: 'single', scope: 'session' },
68
68
  };
@@ -1,8 +1,14 @@
1
- /** RC2 Chat's same-origin local media contract. Authorization stays Host-side. */
1
+ /** Web or Desktop file route. Authorization stays Host-side. */
2
2
  export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined {
3
- if (protocol !== 'http:' && protocol !== 'https:') return undefined;
4
3
  if (!value.startsWith('/') || value.startsWith('//')) return undefined;
5
- return `${origin}/api/file?path=${encodeURIComponent(value)}`;
4
+ if (protocol === 'http:' || protocol === 'https:') {
5
+ return `${origin}/api/file?path=${encodeURIComponent(value)}`;
6
+ }
7
+ // Official fileMediaUrl accepts the Desktop application base dsh-app://app/.
8
+ if (protocol === 'dsh-app:' && origin === 'dsh-app://app') {
9
+ return `dsh-app://app/api/file?path=${encodeURIComponent(value)}`;
10
+ }
11
+ return undefined;
6
12
  }
7
13
 
8
14
  export const readerPathImages = {
@@ -14,22 +14,23 @@ export const markdownLabels: MarkdownLabels = {
14
14
  };
15
15
 
16
16
  export const readBlockLabels: ReadBlockLabels = {
17
+ codeLabel: '代码', wrapLabel: '自动换行', unwrapLabel: '不换行',
17
18
  window: (shown, total) => `显示 ${shown} / ${total} 行`,
18
19
  copy: '复制', copied: '已复制', collapseAria: '收起文件内容',
19
20
  expandAria: hidden => `展开其余 ${hidden} 行`, collapse: '收起', expand: hidden => `展开其余 ${hidden} 行`,
20
21
  };
21
22
 
22
23
  export const terminalBlockLabels: TerminalBlockLabels = {
23
- signal: signal => `信号 ${signal}`, exitCode: code => `退出码 ${code}`,
24
+ signal: signal => `信号 ${signal}`, exitCode: code => `退出码 ${code}`, noExitCode: '无退出码',
24
25
  running: '执行中', failed: '失败', done: '已完成', copy: '复制', copied: '已复制',
25
26
  noOutput: '没有输出', collapseAria: '收起命令输出', collapse: '收起',
26
27
  expandAria: hidden => `展开其余 ${hidden} 行`, expand: hidden => `展开其余 ${hidden} 行`,
27
28
  };
28
29
 
29
30
  export const diffBlockLabels: DiffBlockLabels = {
31
+ codeLabel: '代码', wrapLabel: '自动换行', unwrapLabel: '不换行',
30
32
  copy: '复制', copied: '已复制', collapseAria: '收起差异', collapse: '收起',
31
33
  expandAria: hidden => `展开其余 ${hidden} 行`, expand: hidden => `展开其余 ${hidden} 行`,
32
- files: count => `${count} 个文件`,
33
34
  };
34
35
 
35
36
  export const searchBlockLabels: SearchBlockLabels = {
@@ -95,13 +95,13 @@ export function toolIdentity(entry: Pick<ToolActivityEntry, 'block' | 'draft'>)
95
95
  const block = entry.block;
96
96
  return {
97
97
  name: block ? 'kind' in block ? block.call?.name ?? entry.draft?.name ?? '工具调用' : block.name : entry.draft?.name ?? '工具调用',
98
- raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : block.argsRaw : entry.draft?.argsRaw ?? '',
98
+ raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : ('argsRaw' in block ? block.argsRaw : '') : entry.draft?.argsRaw ?? '',
99
99
  };
100
100
  }
101
101
 
102
102
  /** Name and raw arguments of any call block, whether it has landed or is pending. */
103
103
  function childIdentity(block: ToolCallBlock): { name?: string; raw: string } {
104
- if (!block || !('kind' in block)) return { name: block?.name, raw: block?.argsRaw ?? '' };
104
+ if (!block || !('kind' in block)) return { name: block?.name, raw: block && 'argsRaw' in block ? block.argsRaw : '' };
105
105
  return { name: block.call?.name, raw: block.call?.argsRaw ?? '' };
106
106
  }
107
107
 
@@ -43,7 +43,7 @@ const useReaderStore: ReaderProps['useStore'] = selector => selector(useSyncExte
43
43
  const props = {
44
44
  sessionId: 'auto-fold-fixture', useChat: selector => selector(chat),
45
45
  useSession: selector => selector(sessionSnapshot), useSessions: selector => selector(sessionsSnapshot),
46
- useSessionPendingInteraction: selector => selector(pending),
46
+ useSessionStatus: selector => selector(new Map([['auto-fold-fixture', { running: true, pendingInteraction: pending.get('auto-fold-fixture'), completionUnread: false }]])),
47
47
  useStore: useReaderStore, actions: session.actions, openPrefs: prefs,
48
48
  t: key => key, renderSlot: (_name, _owner, options) => options?.fallback ?? null,
49
49
  renderSlotChain: (_name, _owner, options) => options?.fallback ?? null,