dsh-better-display 0.2.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.en.md +5 -3
  3. package/README.md +5 -3
  4. package/compat/harness-rc2.json +97 -0
  5. package/docs/official-rendering-bridge.md +65 -0
  6. package/lib/client.js +6476 -5977
  7. package/lib/client.js.map +1 -1
  8. package/lib/dsh-better-display.js +4 -2
  9. package/lib/types/client/Blocks.d.ts +2 -1
  10. package/lib/types/client/Blocks.d.ts.map +1 -1
  11. package/lib/types/client/Blocks.js +2 -2
  12. package/lib/types/client/Blocks.js.map +1 -1
  13. package/lib/types/client/OfficialContent.d.ts +25 -0
  14. package/lib/types/client/OfficialContent.d.ts.map +1 -0
  15. package/lib/types/client/OfficialContent.js +53 -0
  16. package/lib/types/client/OfficialContent.js.map +1 -0
  17. package/lib/types/client/Reader.d.ts.map +1 -1
  18. package/lib/types/client/Reader.js +42 -11
  19. package/lib/types/client/Reader.js.map +1 -1
  20. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  21. package/lib/types/client/ToolActivity.js +23 -19
  22. package/lib/types/client/ToolActivity.js.map +1 -1
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +23 -33
  25. package/lib/types/client/index.js.map +1 -1
  26. package/lib/types/client/markdown/MarkdownText.d.ts +3 -1
  27. package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -1
  28. package/lib/types/client/markdown/MarkdownText.js +14 -7
  29. package/lib/types/client/markdown/MarkdownText.js.map +1 -1
  30. package/lib/types/client/markdown/render.d.ts +2 -0
  31. package/lib/types/client/markdown/render.d.ts.map +1 -1
  32. package/lib/types/client/markdown/render.js +22 -6
  33. package/lib/types/client/markdown/render.js.map +1 -1
  34. package/lib/types/client/motion.d.ts.map +1 -1
  35. package/lib/types/client/motion.js +22 -13
  36. package/lib/types/client/motion.js.map +1 -1
  37. package/lib/types/client/native/ContextInjectionRow.d.ts +12 -3
  38. package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
  39. package/lib/types/client/native/ContextInjectionRow.js +7 -4
  40. package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
  41. package/lib/types/client/native/ReferenceIcon.js +3 -3
  42. package/lib/types/client/native/ReferenceIcon.js.map +1 -1
  43. package/lib/types/client/native/tool-call-model.js +1 -1
  44. package/lib/types/client/native/tool-call-model.js.map +1 -1
  45. package/lib/types/client/official-slots.d.ts +71 -0
  46. package/lib/types/client/official-slots.d.ts.map +1 -0
  47. package/lib/types/client/official-slots.js +183 -0
  48. package/lib/types/client/official-slots.js.map +1 -0
  49. package/lib/types/client/platform-media.d.ts +6 -0
  50. package/lib/types/client/platform-media.d.ts.map +1 -0
  51. package/lib/types/client/platform-media.js +13 -0
  52. package/lib/types/client/platform-media.js.map +1 -0
  53. package/lib/types/client/primitive-labels.d.ts.map +1 -1
  54. package/lib/types/client/primitive-labels.js +3 -2
  55. package/lib/types/client/primitive-labels.js.map +1 -1
  56. package/lib/types/client/settings.js.map +1 -1
  57. package/lib/types/client/store.d.ts +1 -0
  58. package/lib/types/client/store.d.ts.map +1 -1
  59. package/lib/types/client/store.js +1 -0
  60. package/lib/types/client/store.js.map +1 -1
  61. package/lib/types/client/tool-activity.d.ts +1 -1
  62. package/lib/types/client/tool-activity.d.ts.map +1 -1
  63. package/lib/types/client/tool-activity.js +11 -4
  64. package/lib/types/client/tool-activity.js.map +1 -1
  65. package/lib/types/client/types.d.ts +18 -8
  66. package/lib/types/client/types.d.ts.map +1 -1
  67. package/lib/types/client/word-motion.d.ts.map +1 -1
  68. package/lib/types/client/word-motion.js +2 -1
  69. package/lib/types/client/word-motion.js.map +1 -1
  70. package/package.json +23 -17
  71. package/scripts/check-harness-compat.mjs +71 -0
  72. package/scripts/link-harness-dependencies.mjs +1 -0
  73. package/scripts/verify-auto-fold.mjs +114 -0
  74. package/scripts/verify-official-bridge.mjs +121 -0
  75. package/src/client/Blocks.tsx +2 -1
  76. package/src/client/OfficialContent.tsx +69 -0
  77. package/src/client/Reader.module.css +35 -8
  78. package/src/client/Reader.tsx +44 -17
  79. package/src/client/ToolActivity.tsx +24 -29
  80. package/src/client/index.tsx +27 -31
  81. package/src/client/markdown/MarkdownText.tsx +14 -6
  82. package/src/client/markdown/render.tsx +23 -7
  83. package/src/client/motion.tsx +18 -11
  84. package/src/client/native/ContextInjectionRow.tsx +21 -9
  85. package/src/client/native/ReferenceIcon.tsx +3 -3
  86. package/src/client/native/tool-call-model.ts +1 -1
  87. package/src/client/official-slots.tsx +211 -0
  88. package/src/client/platform-media.ts +11 -0
  89. package/src/client/primitive-labels.ts +3 -2
  90. package/src/client/settings.tsx +1 -1
  91. package/src/client/store.ts +2 -0
  92. package/src/client/tool-activity.ts +11 -4
  93. package/src/client/types.ts +14 -8
  94. package/src/client/word-motion.tsx +2 -1
  95. package/tests/browser-auto-fold.tsx +64 -0
  96. package/tests/browser-official-bridge.tsx +152 -0
  97. package/tests/official-slots.test.ts +112 -0
  98. package/tests/platform-media.test.ts +17 -0
@@ -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
  /**
@@ -269,8 +272,7 @@ function subagentCount(snapshot: { nodes: { get(key: string): ChatConversationVi
269
272
  return 0;
270
273
  }
271
274
 
272
- function GroupStatus({ group, sessionId, useChat, useSessionPendingInteraction, motion }: Pick<ReaderProps, 'sessionId' | 'useChat' | 'useSessionPendingInteraction'> & { group: ReaderGroup; motion: boolean }) {
273
- 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 }) {
274
276
  const text = useChat(snapshot => {
275
277
  const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
276
278
  if (turn?.status === 'closed') {
@@ -474,7 +476,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
474
476
  const snapshot = props.useChat(snapshot => snapshot);
475
477
  const nodes = snapshot.nodes;
476
478
  const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
477
- const interaction = props.useSessionPendingInteraction(snapshot => snapshot.get(props.sessionId));
479
+ const interaction = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
478
480
  const sessionRunning = props.useSession(snapshot => snapshot.running);
479
481
  const boundary = useMemo(() => boundaryOf(turn), [turn]);
480
482
  const choiceKey = processChoiceKey(group.key, boundary);
@@ -504,11 +506,11 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
504
506
  : item.key === key || ('nodeKey' in item.step && item.step.nodeKey === key)));
505
507
  const expanded = !autoFold || holdingSelection || processExpanded(expansionChoice, boundary);
506
508
  const [foldOpenByKey, setFoldOpenByKey] = useState<Record<string, boolean>>({});
509
+ useLayoutEffect(() => {
510
+ // Restoring automatic folding also releases live "prior steps" disclosures.
511
+ if (autoFold) setFoldOpenByKey(current => Object.keys(current).length ? {} : current);
512
+ }, [autoFold]);
507
513
  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
514
  const tailData = useMemo(() => {
513
515
  for (const key of group.keys) {
514
516
  const n = nodes.get(key);
@@ -516,12 +518,27 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
516
518
  }
517
519
  return undefined;
518
520
  }, [group.keys, nodes]);
521
+ const tailOwner = useMemo(() => turn && tailData ? {
522
+ turn, seq: tailData.closing?.finalNode.seq ?? tailData.seq, openFile: props.openFile,
523
+ } : undefined, [turn, tailData, props.openFile]);
524
+ const fileMentions = useMemo(() => {
525
+ const official = tailOwner ? props.officialFileMentions(tailOwner) : undefined;
526
+ const reader = deliverables.length > 0 ? createProducedFileMentions(deliverables, props.openFile) : undefined;
527
+ return official || reader ? { resolve: (value: string) => official?.resolve(value) ?? reader?.resolve(value) } : undefined;
528
+ }, [tailOwner, props.officialFileMentions, deliverables, props.openFile]);
529
+ const official = useMemo(() => ({
530
+ renderSlot: props.renderSlot, renderSlotChain: props.renderSlotChain,
531
+ officialImageLoader: props.officialImageLoader, officialFileMentions: props.officialFileMentions,
532
+ officialPreviewFile: props.officialPreviewFile,
533
+ officialHost: props.officialHost, openView: props.openView,
534
+ }), [props.renderSlot, props.renderSlotChain, props.officialImageLoader, props.officialFileMentions, props.officialPreviewFile, props.officialHost, props.openView]);
535
+ const cwd = props.useSessions(state => state.byId[props.sessionId]?.cwd);
519
536
  const runMs = turn?.start && turn?.end ? Math.max(0, turn.end.time - turn.start.time) : undefined;
520
537
  const metrics = useMemo(() => ({
521
538
  usage: tailData?.tokenUsage,
522
539
  runMs,
523
- tokensPerSecond: tailData?.tokensPerSecond,
524
- ttftMs: tailData?.ttftMs,
540
+ tokensPerSecond: (tailData as { tokensPerSecond?: number } | undefined)?.tokensPerSecond,
541
+ ttftMs: (tailData as { ttftMs?: number } | undefined)?.ttftMs,
525
542
  endedAt: tailData?.closing?.time ?? turn?.end?.time,
526
543
  }), [tailData, runMs, turn?.end?.time]);
527
544
  const forkSeq = forkAnchorSeq([tailData?.closing?.finalNode]);
@@ -545,7 +562,8 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
545
562
  forkSeq,
546
563
  fileMentions,
547
564
  metrics,
548
- getToolView: props.getToolView,
565
+ official,
566
+ cwd,
549
567
  };
550
568
  const terminal = terminalLabel(boundary.reason);
551
569
  const hasTurnError = flow.some(item => item.kind === 'node' && nodes.get(item.nodeKey)?.kind === 'turn-error');
@@ -575,7 +593,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
575
593
  {startsWithUser && <BlockBoundary><MainNode {...shared} useChat={props.useChat} boundary={boundary} nodeKey={group.keys[0]} /></BlockBoundary>}
576
594
  {hasProcess && !isAwaitingModel && <StickyLane kind="status" className={css.turnProcessSticky}>
577
595
  <Disclosure open={expanded} onChange={setExpanded} controls={flowId} buttonRef={processButton}
578
- 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} />} />
579
597
  </StickyLane>}
580
598
  {boundary.status === 'closed' && hasProcess && autoFold && <ClosedProcessSummary open={expanded} onChange={setExpanded} controls={flowId}
581
599
  steps={steps.filter(step => {
@@ -591,9 +609,10 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
591
609
  onOpenChange={(key, value) => { pinProcess(); setFoldOpenByKey(current => ({ ...current, [key]: value })); }} renderStep={renderStep} />
592
610
  {/* 状态指示永远排在流程之后:AI 的响应永远出现在最新消息(含补充消息)的下方 */}
593
611
  {!hasProcess && boundary.status === 'open' && !isAwaitingModel && <div className={css.disclosure} data-reader-status-only>
594
- <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} />
595
613
  </div>}
596
614
  {showDeliverablesRow(boundary.status, deliverables) && <DeliverablesRow deliverables={deliverables} openFile={props.openFile} revealFile={props.revealFile} openMode={openMode} />}
615
+ {boundary.status === 'closed' && <BlockBoundary><OfficialTail official={official} owner={tailOwner} produced={deliverables} /></BlockBoundary>}
597
616
  {showTerminalNotice && <div className={css.notice} data-reader-terminal>{terminal}</div>}
598
617
  </section>;
599
618
  });
@@ -605,7 +624,7 @@ export function Reader(props: ReaderProps) {
605
624
  const nodes = props.useChat(snapshot => snapshot.nodes);
606
625
  const timeline = props.useChat(snapshot => snapshot.timeline);
607
626
  const running = props.useSession(snapshot => snapshot.running);
608
- const pending = props.useSessionPendingInteraction(snapshot => snapshot.get(props.sessionId));
627
+ const pending = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
609
628
  const openError = props.useSession(snapshot => snapshot.openError);
610
629
  const loading = props.useSession(snapshot => snapshot.openState === 'loading');
611
630
  const hasMore = props.useSession(snapshot => snapshot.hasMore);
@@ -622,6 +641,14 @@ export function Reader(props: ReaderProps) {
622
641
  );
623
642
  const storeAutoFold = props.useStore(state => state.autoFold);
624
643
  const autoFold = prefsSnap?.autoFold ?? (prefsSnap?.foldIntensity !== undefined ? prefsSnap.foldIntensity !== 0 : undefined) ?? storeAutoFold ?? true;
644
+ const previousAutoFold = useRef(autoFold);
645
+ useLayoutEffect(() => {
646
+ const restored = autoFold && !previousAutoFold.current;
647
+ previousAutoFold.current = autoFold;
648
+ // Watch the effective preference so toolbar and Settings changes behave alike.
649
+ // Reading while folding is off must not pin completed process content forever.
650
+ if (restored) props.actions.resetExpanded();
651
+ }, [autoFold, props.actions]);
625
652
  const frostedGlass = frostedGlassOf(prefsSnap);
626
653
  const streamMotion = useMemo(() => ({ enabled: motion, activatedAt: activatedAt.current }), [motion]);
627
654
  const groups = useMemo(() => groupNodes(order, key => nodes.get(key)), [order, nodes, timeline]);
@@ -788,7 +815,7 @@ export function Reader(props: ReaderProps) {
788
815
 
789
816
  // ChatView publishes data-chat-flow="" on its column. Skins treat a
790
817
  // scrollport without that hook as inspect-only and hide [data-composer-seat].
791
- return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-dsh-better-display="0.2.1" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
818
+ return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.1" data-dsh-better-display="0.3.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
819
  <TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
793
820
  <div className={css.column} data-chat-flow="">
794
821
  <StickyLane kind="toolbar" className={css.toolbar}>
@@ -2,8 +2,9 @@ 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
+ 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: IconEditOutlineRegular, read: IconBrowseOutlineRegular, terminal: IconApiOutlineRegular, search: IconSearchOutlineRegular, web: IconSearchOutlineRegular, code: IconApiOutlineRegular, other: IconSparkleRegular } satisfies Record<ToolCategory, unknown>;
18
19
  const number = new Intl.NumberFormat('zh-CN');
19
20
  const language = (path: string | undefined) => path?.split('.').at(-1);
20
21
  const duration = (ms: number) => ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} 秒`;
@@ -80,28 +81,14 @@ function searchFiles(value: unknown): SearchFileGroup[] | null {
80
81
  return files;
81
82
  }
82
83
 
83
- function CustomToolWrapper({ Component, block, toolName, cwd, openFile }: {
84
- Component: any;
85
- block: any;
86
- toolName: string;
87
- cwd?: string;
88
- openFile?: (path: string) => Promise<void> | void;
89
- }) {
90
- const containerRef = useRef<HTMLDivElement>(null);
91
- useEffect(() => {
92
- // If the component renders a collapsed disclosure row (like DiffCard with role="button" and aria-expanded="false"),
93
- // expand it automatically once so details are immediately visible inside ResultView.
94
- const row = containerRef.current?.querySelector<HTMLElement>('[role="button"][aria-expanded="false"]');
95
- if (row) row.click();
96
- }, []);
97
- return (
98
- <div ref={containerRef} data-reader-tool-custom>
99
- <Component block={block} toolName={toolName} cwd={cwd} openFile={openFile} />
100
- </div>
101
- );
84
+ function ResultView(props: BlockRenderProps & { entry: ToolActivityEntry; model: ReturnType<typeof activitySummary>; phase: ToolPhase }) {
85
+ const { official, entry, model } = props;
86
+ const fallback = <ResultFallback {...props} />;
87
+ if (!official || !entry.block || model.name === 'render_ui' || model.name === 'show_widget') return fallback;
88
+ return <OfficialTool {...props} official={official} block={entry.block} toolName={model.name} cwd={props.cwd ?? model.cwd} fallback={fallback} />;
102
89
  }
103
90
 
104
- function ResultView({ entry, model, phase, ...render }: BlockRenderProps & { entry: ToolActivityEntry; model: ReturnType<typeof activitySummary>; phase: ToolPhase }) {
91
+ function ResultFallback({ entry, model, phase, ...render }: BlockRenderProps & { entry: ToolActivityEntry; model: ReturnType<typeof activitySummary>; phase: ToolPhase }) {
105
92
  if ((model.name === 'render_ui' || model.name === 'show_widget') && typeof model.args?.html === 'string') {
106
93
  return <McpAppFrame html={model.args.html as string} title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} fillComposer={render.fillComposer} />;
107
94
  }
@@ -114,11 +101,6 @@ function ResultView({ entry, model, phase, ...render }: BlockRenderProps & { ent
114
101
  const text = block.content.filter(item => item.type === 'text').map(item => item.text).join('\n');
115
102
  if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model} preparing={false} fillComposer={render.fillComposer} /><pre className={css.toolRaw}>{text}</pre></>;
116
103
 
117
- const CustomToolView = render.getToolView?.(model.name);
118
- if (CustomToolView) {
119
- return <CustomToolWrapper Component={CustomToolView} block={block} toolName={model.name} cwd={model.cwd} openFile={render.openFile} />;
120
- }
121
-
122
104
  if (model.category === 'terminal') {
123
105
  const facts = executionFacts(block);
124
106
  const output = text.replace(/\n\[(?:exit code: \d+|killed by signal: [^\]\n]+)\]$/, '');
@@ -154,6 +136,19 @@ function ResultView({ entry, model, phase, ...render }: BlockRenderProps & { ent
154
136
  {generatedInput(model.content, model.target, false)}
155
137
  </>;
156
138
  const content: ToolResultNode['content'] = block.content;
139
+ // A code interpreter's product is its result, not prose. Without this branch
140
+ // the output fell through to the generic document path below, which emits it
141
+ // as reader answer text — so a run_code card sat in the transcript outside
142
+ // every fold, the exact block the reader wanted to be able to close.
143
+ if (model.category === 'code') {
144
+ const output = content.filter(item => item.type === 'text').map(item => item.text).join('\n');
145
+ const facts = executionFacts(block);
146
+ if (output.trim()) return <div data-reader-tool-code>
147
+ <TerminalBlock command={model.command ?? model.name} cwd={model.cwd} output={output}
148
+ exitCode={facts.exitCode} signal={facts.signal} maxLines={18} labels={terminalBlockLabels} />
149
+ </div>;
150
+ return <p className={css.toolDetailNote}>代码已执行,没有可展示的输出。</p>;
151
+ }
157
152
  if (content.some(item => item.type === 'text')) return <div className={css.toolDocument}><Blocks {...render} blocks={contentBlocks(content).filter(item => item.kind === 'text')} source="tool" /></div>;
158
153
  if (content.length) return <p className={css.toolDetailNote}>图片或扩展内容已在对话中单独展示。</p>;
159
154
  return <p className={css.toolDetailNote}>工具没有返回可展示的内容。</p>;
@@ -184,7 +179,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
184
179
  return () => document.removeEventListener('selectionchange', track);
185
180
  }, []);
186
181
  const facts = executionFacts(entry.block);
187
- const Icon = model.name === 'skill' ? IconSkillOutline16 : ICONS[model.category];
182
+ const Icon = model.name === 'skill' ? IconSkillOutlineRegular : ICONS[model.category];
188
183
  const block = entry.block;
189
184
  const native = block ? toolRowModel(model.name, block) : null;
190
185
  const skillName = typeof model.args?.name === 'string' ? model.args.name.split('\n')[0] : model.raw.split('\n')[0];
@@ -265,7 +260,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
265
260
  && previous.entry.draft === next.entry.draft && previous.entry.step === next.entry.step
266
261
  && previous.motion === next.motion && previous.turnClosed === next.turnClosed && previous.depth === next.depth
267
262
  && previous.onRead === next.onRead && previous.renderSlotChain === next.renderSlotChain && previous.loadImage === next.loadImage && previous.fillComposer === next.fillComposer
268
- && previous.getToolView === next.getToolView);
263
+ && previous.official === next.official && previous.cwd === next.cwd && previous.openFile === next.openFile);
269
264
 
270
265
  /** Rich media (images, MCP widgets) rendered outside the folded tool ledger. */
271
266
  export function ToolMedia({ block, depth = 0, ...render }: BlockRenderProps & { block: ToolCallBlock; depth?: number }) {
@@ -13,6 +13,8 @@ import { installBetterDisplaySettings } from './settings.js';
13
13
  import { fillComposerDom } from './mcp-app.js';
14
14
  import { fileAddressFor, modeFromSnapshot, openDeliverableFile } from './open-file.js';
15
15
  import type { ReaderInjected } from './types.js';
16
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
17
+ import { installOfficialSlots, officialChildren, type CompositionRegistry } from './official-slots.js';
16
18
 
17
19
  /** Structural face of the sanctioned per-session composer writer. */
18
20
  interface ComposerShell {
@@ -51,7 +53,7 @@ async function openWorkspacePath(
51
53
  export type { ReaderBlockOwner } from './types.js';
52
54
  export { McpAppFrame } from './McpAppFrame.js';
53
55
  export const name = 'dsh-better-display-client';
54
- export const inject = ['slots', 'sessions', 'conversation', 'remote', 'remote.session'];
56
+ export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'remote', 'remote.session'];
55
57
 
56
58
  export function apply(ctx: Context): void {
57
59
  const store = createReaderStore();
@@ -60,14 +62,17 @@ export function apply(ctx: Context): void {
60
62
  // intensity, and open-mode on the unsuffixed `dsh.reader.v1` key.
61
63
  const prefs = store.create();
62
64
  installBetterDisplaySettings(ctx, prefs);
63
- ctx.slots.inject('conversation.view', function* () {
65
+ ctx.slots.inject('conversation.chat.node', function* () {
64
66
  yield ctx.slots.register({
65
67
  name: 'conversation.view',
66
68
  id: 'reader',
67
69
  order: -5,
68
70
  label: () => '阅读',
69
71
  locale: 'chat',
70
- children: { 'dsh-better-display.block': { kind: 'chain', scope: 'session' } },
72
+ children: {
73
+ 'dsh-better-display.block': { kind: 'chain', scope: 'session' },
74
+ ...officialChildren(ctx.slots as unknown as CompositionRegistry),
75
+ },
71
76
  store,
72
77
  inject: (sessionId: SessionId): ReaderInjected => {
73
78
  const session = () => {
@@ -77,13 +82,28 @@ export function apply(ctx: Context): void {
77
82
  };
78
83
  return {
79
84
  openPrefs: prefs,
85
+ officialImageLoader: Object.assign(
86
+ (attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
87
+ { peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) },
88
+ ),
89
+ officialFileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
90
+ officialPreviewFile: path => {
91
+ const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd;
92
+ const sidebar = ctx.get('sidebarRight') as SidebarRightFace | undefined;
93
+ if (!sidebar?.openResource) throw new Error('文件预览面板不可用。');
94
+ sidebar.openResource((officialFileAddressFor ?? fileAddressFor)(sessionId, cwd, path));
95
+ },
96
+ officialHost: {
97
+ getSnapshot: () => ctx.remote.$host,
98
+ subscribe: listener => ctx.on('connection/reset', listener),
99
+ },
80
100
  loadOlder: async () => { await session().loadOlder(); },
81
101
  loadImage: async attachment => {
82
102
  const receipt = await session().readAttachment(attachment.attachmentId);
83
103
  if (!receipt.ok) throw new Error(receipt.error.message);
84
104
  return { data: Uint8Array.from(receipt.value.data), mediaType: receipt.value.attachment.mediaType };
85
105
  },
86
- openFile: async (path: string) => {
106
+ openFile: async (path, options) => {
87
107
  try {
88
108
  const cwd = ctx.sessions?.list?.getSnapshot?.()?.byId[sessionId]?.cwd;
89
109
  const sidebar = (
@@ -98,7 +118,7 @@ export function apply(ctx: Context): void {
98
118
  resolveWorkspacePath,
99
119
  openExternal: async (absolutePath) => { await openWorkspacePath(ctx, absolutePath); },
100
120
  openSidebar: typeof sidebar?.openResource === 'function'
101
- ? (address) => { sidebar.openResource!(address); }
121
+ ? (address) => { sidebar.openResource!(address, options?.line === undefined ? undefined : { params: { line: options.line } }); }
102
122
  : undefined,
103
123
  fileAddressFor: officialFileAddressFor ?? fileAddressFor,
104
124
  warn: (message, extra) => { console.warn(message, extra); },
@@ -190,35 +210,11 @@ export function apply(ctx: Context): void {
190
210
  return false;
191
211
  }
192
212
  },
193
- getToolView: (toolName: string) => {
194
- try {
195
- const slotsService = ctx.slots as unknown as { entriesOfSlot?: (name: string) => unknown[] };
196
- const entries = slotsService?.entriesOfSlot?.('tool.call.toolview') ?? [];
197
- const matches = entries.filter((e: any) => {
198
- const key = e?.options?.key ?? e?.key;
199
- const comp = e?.component ?? e?.view ?? e?.render ?? (typeof e === 'function' ? e : null);
200
- return key === toolName && typeof comp === 'function';
201
- });
202
- if (matches.length === 0) return null;
203
- matches.sort((a: any, b: any) => {
204
- const prioA = a?.options?.priority ?? a?.priority ?? 0;
205
- const prioB = b?.options?.priority ?? b?.priority ?? 0;
206
- return prioA - prioB;
207
- });
208
- const best = matches[0] as any;
209
- const prio = best?.options?.priority ?? best?.priority ?? 0;
210
- const comp = best?.component ?? best?.view ?? best?.render ?? (typeof best === 'function' ? best : null);
211
- if (prio < 0 || (toolName !== 'edit' && toolName !== 'write')) {
212
- return comp;
213
- }
214
- return null;
215
- } catch {
216
- return null;
217
- }
218
- },
213
+
219
214
  };
220
215
  },
221
216
  }, Reader);
217
+ yield installOfficialSlots(ctx);
222
218
  yield installReaderEntry(ctx);
223
219
  });
224
220
  }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { memo, useMemo, useRef } from 'react'
16
16
  import type { ReactNode } from 'react'
17
+ import type { MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
17
18
  import { IncrementalMarkdownParser } from './incremental.js'
18
19
  import { parseGfm, parseGfmWithMath } from './parse.js'
19
20
  import {
@@ -31,6 +32,7 @@ function renderSettled(
31
32
  text: string,
32
33
  codeLabels: MarkdownCodeLabels | undefined,
33
34
  fileMentions: MarkdownFileMentions | undefined,
35
+ pathImages: MarkdownPathImages | undefined,
34
36
  renderText: MarkdownRenderContext['renderText'],
35
37
  renderAtom: MarkdownRenderContext['renderAtom'],
36
38
  ): ReactNode[] {
@@ -43,6 +45,7 @@ function renderSettled(
43
45
  streaming: false,
44
46
  codeLabels,
45
47
  fileMentions,
48
+ pathImages,
46
49
  targets,
47
50
  footnoteOrder: [],
48
51
  footnoteCounts: new Map(),
@@ -73,7 +76,7 @@ class StreamingRenderer {
73
76
  private lastRendered: ReactNode[] = []
74
77
 
75
78
  /** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
76
- constructor(private readonly codeLabels: MarkdownCodeLabels | undefined, private readonly renderText: MarkdownRenderContext['renderText'], private readonly renderAtom: MarkdownRenderContext['renderAtom']) {}
79
+ constructor(private readonly codeLabels: MarkdownCodeLabels | undefined, private readonly renderText: MarkdownRenderContext['renderText'], private readonly renderAtom: MarkdownRenderContext['renderAtom'], private readonly pathImages: MarkdownPathImages | undefined) {}
77
80
 
78
81
  /**
79
82
  * Render the current accumulated text. Idempotent per text value, so React
@@ -109,6 +112,7 @@ class StreamingRenderer {
109
112
  streaming: true,
110
113
  codeLabels: this.codeLabels,
111
114
  fileMentions: undefined,
115
+ pathImages: this.pathImages,
112
116
  targets: frameTargets,
113
117
  footnoteOrder: this.frozenFootnoteOrder,
114
118
  footnoteCounts: this.frozenFootnoteCounts,
@@ -129,6 +133,7 @@ class StreamingRenderer {
129
133
  streaming: true,
130
134
  codeLabels: this.codeLabels,
131
135
  fileMentions: undefined,
136
+ pathImages: this.pathImages,
132
137
  targets: frameTargets,
133
138
  footnoteOrder: [...this.frozenFootnoteOrder],
134
139
  footnoteCounts: new Map(this.frozenFootnoteCounts),
@@ -162,26 +167,29 @@ class StreamingRenderer {
162
167
  * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
163
168
  * images render directly.
164
169
  */
165
- export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions, renderText, renderAtom }: {
170
+ export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions, pathImages, renderText, renderAtom }: {
166
171
  text: string
167
172
  streaming?: boolean
168
173
  codeLabels?: MarkdownCodeLabels | undefined
169
174
  fileMentions?: MarkdownFileMentions | undefined
175
+ pathImages?: MarkdownPathImages | undefined
170
176
  renderText?: MarkdownRenderContext['renderText']
171
177
  renderAtom?: MarkdownRenderContext['renderAtom']
172
178
  }) {
173
179
  const streamRef = useRef<StreamingRenderer | null>(null)
180
+ const streamImagesRef = useRef(pathImages)
174
181
  const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
175
182
  const children = useMemo(() => {
176
183
  if (!streaming) {
177
184
  streamRef.current = null
178
- return renderSettled(text, codeLabels, fileMentions, renderText, renderAtom)
185
+ return renderSettled(text, codeLabels, fileMentions, pathImages, renderText, renderAtom)
179
186
  }
180
- if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
181
- streamRef.current = new StreamingRenderer(codeLabels, renderText, renderAtom)
187
+ if (streamRef.current === null || streamLabelsRef.current !== codeLabels || streamImagesRef.current !== pathImages) {
188
+ streamRef.current = new StreamingRenderer(codeLabels, renderText, renderAtom, pathImages)
182
189
  streamLabelsRef.current = codeLabels
190
+ streamImagesRef.current = pathImages
183
191
  }
184
192
  return streamRef.current.render(text)
185
- }, [text, streaming, codeLabels, fileMentions, renderText, renderAtom])
193
+ }, [text, streaming, codeLabels, fileMentions, pathImages, renderText, renderAtom])
186
194
  return <div className={css.markdown}>{children}</div>
187
195
  })
@@ -17,7 +17,7 @@
17
17
  * may add node types this renderer has no mapping for.
18
18
  */
19
19
 
20
- import { Fragment, createElement } from 'react'
20
+ import { Fragment, createElement, useState } from 'react'
21
21
  import type { Key, ReactNode } from 'react'
22
22
  import clsx from 'clsx'
23
23
  import type * as Md from 'mdast'
@@ -27,6 +27,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
27
27
  import { renderTexToReact } from './katex.js'
28
28
  import { McpAppCodeBlock, StreamingMcpAppPlaceholder } from '../McpAppFrame.js'
29
29
  import { isMcpAppCodeBlock, extractMcpAppTitle, extractMcpAppHeight } from '../mcp-app.js'
30
+ import type { MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
30
31
  import type { PositionedBlock } from './incremental.js'
31
32
  import css from './MarkdownText.module.css'
32
33
 
@@ -136,6 +137,7 @@ export interface MarkdownRenderContext {
136
137
  readonly inBlockquote?: boolean
137
138
  /** Inline-code file mentions; absent wherever no opener vocabulary exists. */
138
139
  readonly fileMentions: MarkdownFileMentions | undefined
140
+ readonly pathImages?: MarkdownPathImages
139
141
  /** Inside an anchor's children: interactive mentions must not nest there. */
140
142
  readonly inLink?: boolean
141
143
  /** Reference targets visible to this pass. */
@@ -301,7 +303,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
301
303
  case 'linkReference':
302
304
  return renderLinkReference(node, key, context)
303
305
  case 'image':
304
- return renderImage(node.url, node.alt ?? '', key)
306
+ return renderImage(node.url, node.alt ?? '', key, context)
305
307
  case 'imageReference':
306
308
  return renderImageReference(node, key, context)
307
309
  case 'footnoteReference':
@@ -515,17 +517,31 @@ function inlineCodeHttpUrl(value: string): string | undefined {
515
517
  }
516
518
  }
517
519
 
518
- function renderImage(url: string, alt: string, key: Key): ReactNode {
519
- const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
520
+ function renderImage(url: string, alt: string, key: Key, context: MarkdownRenderContext): ReactNode {
521
+ // RC2 semantics, including the revalidation of a trusted vocabulary result.
522
+ const remote = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
523
+ const rewritten = remote === undefined ? context.pathImages?.resolve(url) : undefined
524
+ let imageSrc = remote
525
+ if (rewritten !== undefined) {
526
+ try {
527
+ if (['http:', 'https:', 'blob:', 'data:'].includes(new URL(rewritten).protocol)) imageSrc = rewritten
528
+ } catch { /* A vocabulary result must be an absolute URL. */ }
529
+ }
520
530
  if (imageSrc === undefined) {
521
531
  return <span key={key} className={css.imageAlt}>{alt}</span>
522
532
  }
533
+ return <MarkdownImage key={`${key}:${imageSrc}`} src={imageSrc} alt={alt} destination={url} />
534
+ }
535
+
536
+ function MarkdownImage({ src, alt, destination }: { src: string; alt: string; destination: string }): ReactNode {
537
+ const [failed, setFailed] = useState(false)
538
+ if (failed) return <span className={css.imageAlt}>{alt || destination}</span>
523
539
  return (
524
540
  <img
525
- key={key}
526
541
  className={css.image}
527
- src={imageSrc}
542
+ src={src}
528
543
  alt={alt}
544
+ onError={() => { setFailed(true) }}
529
545
  loading="lazy"
530
546
  decoding="async"
531
547
  referrerPolicy="no-referrer"
@@ -563,7 +579,7 @@ function renderImageReference(
563
579
  ): ReactNode {
564
580
  const definition = context.targets.definitions.get(node.identifier.toUpperCase())
565
581
  if (definition === undefined) return `![${node.alt ?? ''}${referenceSuffix(node)}`
566
- return renderImage(definition.url, node.alt ?? '', key)
582
+ return renderImage(definition.url, node.alt ?? '', key, context)
567
583
  }
568
584
 
569
585
  function renderFootnoteReference(
@@ -126,9 +126,6 @@ export function ProcessFragment({ open, motion, onRead, returnFocusTo, nodeKey,
126
126
  setPresent(open);
127
127
  return;
128
128
  }
129
- if (from > target && motion) {
130
- document.body.dataset.readerFolding = 'true';
131
- }
132
129
  const animation = element.animate(
133
130
  from > target
134
131
  ? [
@@ -149,7 +146,6 @@ export function ProcessFragment({ open, motion, onRead, returnFocusTo, nodeKey,
149
146
  if (settled) return;
150
147
  settled = true;
151
148
  if (running.current === animation) running.current = null;
152
- delete document.body.dataset.readerFolding;
153
149
  animation.cancel();
154
150
  setPresent(open);
155
151
  };
@@ -164,7 +160,6 @@ export function ProcessFragment({ open, motion, onRead, returnFocusTo, nodeKey,
164
160
  return () => {
165
161
  window.clearTimeout(deadline);
166
162
  running.current?.cancel();
167
- delete document.body.dataset.readerFolding;
168
163
  };
169
164
  }, [open, motion, returnFocusTo]);
170
165
  if (!open && !present) return null;
@@ -299,16 +294,28 @@ export function useReadingScroll(root: RefObject<HTMLElement>, motion: boolean):
299
294
  if (followFrame !== 0 || layoutDepth > 0) return;
300
295
  // Our easing frames must not be mistaken for a user leaving the bottom.
301
296
  if (lastWrittenTop !== null && Math.abs(scroll.scrollTop - lastWrittenTop) < 1) return;
297
+ // Bind the follow intent to the position observed HERE. Every scroll that
298
+ // reaches this point is the viewport moving under someone else's hand: a
299
+ // wheel, a drag, a key, or the browser clamping the offset after a shrink.
300
+ //
301
+ // The previous form inferred detaching from a strictly upward delta against
302
+ // lastWrittenTop, which left two holes. That baseline is nulled by
303
+ // cancelFollow/onWheel/onTouch/onKey, and the guard above already returned
304
+ // for any scroll whose delta is under 1px — so a reader parked mid-transcript
305
+ // by a scrollbar drag stayed following === true AND pinned === true. The next
306
+ // content growth then took the re-attach branch and yanked the viewport to
307
+ // the bottom, discarding what they were reading. Reading the position
308
+ // directly needs no baseline: a fold's clamp lands at the very bottom and
309
+ // still keeps following, while any other offset detaches.
310
+ const bottom = atBottom();
311
+ pinned.current = bottom;
312
+ if (!bottom) { cancelAnimationFrame(followFrame); followFrame = 0; scheduleCapture(); }
302
313
  if (!following.current) {
303
314
  // Only an explicit return to the bottom resumes following.
304
- if (atBottom()) { following.current = true; setDetached(false); }
315
+ if (bottom) { following.current = true; setDetached(false); }
305
316
  return;
306
317
  }
307
- // Content growth and our own easing also change scrollTop; only a real
308
- // upward move by the reader detaches the tail.
309
- if (lastWrittenTop !== null && scroll.scrollTop < lastWrittenTop - 1) {
310
- following.current = false; setDetached(true); pinned.current = false; cancelAnimationFrame(followFrame); followFrame = 0; scheduleCapture();
311
- }
318
+ if (!bottom) { following.current = false; setDetached(true); }
312
319
  };
313
320
  const onWheel = (event: WheelEvent) => {
314
321
  cancelAnimationFrame(followFrame); followFrame = 0; lastWrittenTop = null;