dsh-better-display 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.en.md +4 -2
  3. package/README.md +4 -2
  4. package/compat/harness-rc2.json +59 -0
  5. package/docs/official-rendering-bridge.md +65 -0
  6. package/lib/client.js +858 -456
  7. package/lib/client.js.map +1 -1
  8. package/lib/types/client/Blocks.d.ts +2 -1
  9. package/lib/types/client/Blocks.d.ts.map +1 -1
  10. package/lib/types/client/Blocks.js +2 -2
  11. package/lib/types/client/Blocks.js.map +1 -1
  12. package/lib/types/client/OfficialContent.d.ts +25 -0
  13. package/lib/types/client/OfficialContent.d.ts.map +1 -0
  14. package/lib/types/client/OfficialContent.js +42 -0
  15. package/lib/types/client/OfficialContent.js.map +1 -0
  16. package/lib/types/client/Reader.d.ts.map +1 -1
  17. package/lib/types/client/Reader.js +38 -6
  18. package/lib/types/client/Reader.js.map +1 -1
  19. package/lib/types/client/ToolActivity.d.ts.map +1 -1
  20. package/lib/types/client/ToolActivity.js +21 -17
  21. package/lib/types/client/ToolActivity.js.map +1 -1
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/index.js +23 -33
  24. package/lib/types/client/index.js.map +1 -1
  25. package/lib/types/client/markdown/MarkdownText.d.ts +3 -1
  26. package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -1
  27. package/lib/types/client/markdown/MarkdownText.js +14 -7
  28. package/lib/types/client/markdown/MarkdownText.js.map +1 -1
  29. package/lib/types/client/markdown/render.d.ts +2 -0
  30. package/lib/types/client/markdown/render.d.ts.map +1 -1
  31. package/lib/types/client/markdown/render.js +22 -6
  32. package/lib/types/client/markdown/render.js.map +1 -1
  33. package/lib/types/client/motion.d.ts.map +1 -1
  34. package/lib/types/client/motion.js +22 -13
  35. package/lib/types/client/motion.js.map +1 -1
  36. package/lib/types/client/native/ContextInjectionRow.d.ts +12 -3
  37. package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
  38. package/lib/types/client/native/ContextInjectionRow.js +6 -3
  39. package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
  40. package/lib/types/client/official-slots.d.ts +71 -0
  41. package/lib/types/client/official-slots.d.ts.map +1 -0
  42. package/lib/types/client/official-slots.js +183 -0
  43. package/lib/types/client/official-slots.js.map +1 -0
  44. package/lib/types/client/platform-media.d.ts +6 -0
  45. package/lib/types/client/platform-media.d.ts.map +1 -0
  46. package/lib/types/client/platform-media.js +13 -0
  47. package/lib/types/client/platform-media.js.map +1 -0
  48. package/lib/types/client/settings.js.map +1 -1
  49. package/lib/types/client/store.d.ts +1 -0
  50. package/lib/types/client/store.d.ts.map +1 -1
  51. package/lib/types/client/store.js +1 -0
  52. package/lib/types/client/store.js.map +1 -1
  53. package/lib/types/client/tool-activity.d.ts +1 -1
  54. package/lib/types/client/tool-activity.d.ts.map +1 -1
  55. package/lib/types/client/tool-activity.js +9 -2
  56. package/lib/types/client/tool-activity.js.map +1 -1
  57. package/lib/types/client/types.d.ts +18 -8
  58. package/lib/types/client/types.d.ts.map +1 -1
  59. package/lib/types/client/word-motion.d.ts.map +1 -1
  60. package/lib/types/client/word-motion.js +2 -1
  61. package/lib/types/client/word-motion.js.map +1 -1
  62. package/package.json +22 -16
  63. package/scripts/check-harness-compat.mjs +71 -0
  64. package/scripts/link-harness-dependencies.mjs +1 -0
  65. package/scripts/verify-auto-fold.mjs +114 -0
  66. package/scripts/verify-official-bridge.mjs +119 -0
  67. package/src/client/Blocks.tsx +2 -1
  68. package/src/client/OfficialContent.tsx +57 -0
  69. package/src/client/Reader.module.css +23 -8
  70. package/src/client/Reader.tsx +37 -9
  71. package/src/client/ToolActivity.tsx +22 -27
  72. package/src/client/index.tsx +27 -31
  73. package/src/client/markdown/MarkdownText.tsx +14 -6
  74. package/src/client/markdown/render.tsx +23 -7
  75. package/src/client/motion.tsx +18 -11
  76. package/src/client/native/ContextInjectionRow.tsx +19 -7
  77. package/src/client/official-slots.tsx +211 -0
  78. package/src/client/platform-media.ts +11 -0
  79. package/src/client/settings.tsx +1 -1
  80. package/src/client/store.ts +2 -0
  81. package/src/client/tool-activity.ts +9 -2
  82. package/src/client/types.ts +14 -8
  83. package/src/client/word-motion.tsx +2 -1
  84. package/tests/browser-auto-fold.tsx +64 -0
  85. package/tests/browser-official-bridge.tsx +144 -0
  86. package/tests/official-slots.test.ts +112 -0
  87. package/tests/platform-media.test.ts +17 -0
@@ -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;
@@ -10,8 +10,17 @@ import css from './ContextInjectionRow.module.css'
10
10
  export interface ContextInjectionRowProps {
11
11
  content: ContextMessageNode['content']
12
12
  source: ContextMessageNode['source']
13
- /** Role and producer name projected from the durable source. */
14
- provenance: ContextMessageNode['provenance']
13
+ /**
14
+ * Role and producer name projected from the durable source.
15
+ *
16
+ * Host generations disagree on the field name: newer builds project
17
+ * `provenance`, the 0.1.6-alpha generations project `producer`. Both are
18
+ * optional here and resolved below, so the row renders on either host instead
19
+ * of throwing on `undefined.role` and degrading its whole block boundary.
20
+ */
21
+ provenance?: ContextMessageNode['provenance'] | null
22
+ /** Alpha-generation spelling of {@link provenance}. */
23
+ producer?: ContextMessageNode['provenance'] | null
15
24
  /** Producer-declared information form; null renders the opaque body. */
16
25
  form: ContextMessageNode['form']
17
26
  /** The owning view's locale seat, passed down as a plain prop. */
@@ -29,8 +38,11 @@ export interface ContextInjectionRowProps {
29
38
  * @param props - Durable content, its projected producer role/name and form, and the locale seat.
30
39
  * @returns A collapsed context row with a bounded, form-specific body.
31
40
  */
32
- export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
41
+ export function ContextInjectionRow({ content, source, provenance, producer, form, t }: ContextInjectionRowProps) {
33
42
  const [open, setOpen] = useState(false)
43
+ // Two host generations, one component. A row whose projection is absent
44
+ // entirely still renders: the marker falls back to a plain injection.
45
+ const view = provenance ?? producer ?? { role: 'inject' as const, label: null }
34
46
  // Resolved rather than declared: a form whose fields are unreadable renders
35
47
  // the opaque body, and the marker must say what the row actually shows.
36
48
  const { rendered, summary, body } = contextBody(form, { content, source, t })
@@ -38,18 +50,18 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
38
50
  return (
39
51
  <DisclosureRow
40
52
  className={css.root}
41
- icon={provenance.role === 'recall'
53
+ icon={view.role === 'recall'
42
54
  ? <span data-context-recall-icon><ReferenceIcon kind="session" /></span>
43
55
  : <IconBrowseOutline16 size={14} />}
44
56
  chevronClassName={css.chevron}
45
- title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
46
- collapsedContent={provenance.label === null ? undefined : (
57
+ title={t(view.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
58
+ collapsedContent={view.label === null ? undefined : (
47
59
  /* ToolRow's separator shape: an aria-hidden dot, so the accessible name
48
60
  stays the two readable parts and the two disclosure rows expose one
49
61
  name shape. A source that names no producer drops the dot with it. */
50
62
  <>
51
63
  <span className={css.sep} aria-hidden />
52
- <span className={css.source} data-context-source>{provenance.label}</span>
64
+ <span className={css.source} data-context-source>{view.label}</span>
53
65
  {summary !== null && (
54
66
  <>
55
67
  <span className={css.sep} aria-hidden />
@@ -0,0 +1,211 @@
1
+ import { createElement, memo, useMemo, type ComponentType, type ReactNode } from 'react';
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import type { SlotEntryDef, SlotMap, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots';
4
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
5
+
6
+ /**
7
+ * RC2 composition adapter. Only public registry operations are used. We lend
8
+ * registrations a Reader-owned seat, not a second implementation of their UI.
9
+ * The platform still binds injection, hooks, locale, stores and boundaries.
10
+ * Original entries, declarations and components are never changed.
11
+ *
12
+ * Child seats have unique names because RC2 declarations have one owner. The
13
+ * sole component wrapper translates those names before delegating to the
14
+ * framework-provided renderSlot. It does not implement a slot renderer.
15
+ */
16
+ export const OFFICIAL_SLOTS = {
17
+ actions: 'conversation.chat.assistant-actions',
18
+ tools: 'tool.call.toolview',
19
+ tail: 'conversation.chat.turnTail',
20
+ nodes: 'conversation.chat.node',
21
+ images: 'conversation.message.images',
22
+ } as const;
23
+ export type OfficialFamily = keyof typeof OFFICIAL_SLOTS;
24
+ export const OFFICIAL_SEATS = {
25
+ actions: 'dsh-better-display.official.actions/conversation.chat.assistant-actions',
26
+ tools: 'dsh-better-display.official.tools/tool.call.toolview',
27
+ tail: 'dsh-better-display.official.tail/conversation.chat.turnTail',
28
+ nodes: 'dsh-better-display.official.nodes/conversation.chat.node',
29
+ images: 'dsh-better-display.official.images/conversation.message.images',
30
+ } as const;
31
+ export type OfficialSeat = typeof OFFICIAL_SEATS[OfficialFamily];
32
+ export const officialSeat = (family: OfficialFamily, source: string = OFFICIAL_SLOTS[family]) =>
33
+ `dsh-better-display.official.${family}/${source}`;
34
+
35
+ type Spec = SlotSpec<SlotEntryDef>;
36
+ type Render = (key: string, owner: object, options?: object) => ReactNode;
37
+ type RenderProps = { renderSlot: Render; renderSlotChain?: Render } & Record<string, unknown>;
38
+ type Registration = StoredEntry['options'] & Omit<StoredEntry, 'options' | 'component'> & { name: string };
39
+
40
+ /** The documented, type-erased registry inspection/registration boundary. */
41
+ export interface CompositionRegistry {
42
+ spec(key: string): Spec | undefined;
43
+ entriesOfSlot(key: string): readonly StoredEntry[];
44
+ subscribe(key: string, listener: () => void): () => void;
45
+ inject(key: string, effect: () => () => void): () => void;
46
+ register(options: Registration, component: unknown): () => void;
47
+ }
48
+
49
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
50
+ interface SlotMap {
51
+ 'dsh-better-display.official.actions/conversation.chat.assistant-actions': SlotMap['conversation.chat.assistant-actions'];
52
+ 'dsh-better-display.official.tools/tool.call.toolview': SlotMap['tool.call.toolview'];
53
+ 'dsh-better-display.official.tail/conversation.chat.turnTail': SlotMap['conversation.chat.turnTail'];
54
+ 'dsh-better-display.official.nodes/conversation.chat.node': SlotMap['conversation.chat.node'];
55
+ 'dsh-better-display.official.images/conversation.message.images': SlotMap['conversation.message.images'];
56
+ }
57
+ }
58
+
59
+ // Existing Reader presentations own these rows. All other node kinds, including
60
+ // future registrations, reach the official renderer through the fallback seat.
61
+ const READER_NODES = new Set(['user', 'steering', 'assistant-step', 'tool-call', 'turn-tail', 'turn-process']);
62
+ const EXPECTED: Record<OfficialFamily, Spec> = {
63
+ actions: { kind: 'list', scope: 'session' },
64
+ tools: { kind: 'keyed', scope: 'session' },
65
+ tail: { kind: 'chain', scope: 'session' },
66
+ nodes: { kind: 'keyed', scope: 'session' },
67
+ images: { kind: 'single', scope: 'session' },
68
+ };
69
+
70
+ /**
71
+ * Reader already presents produced paths with its copy/reveal actions. RC2's
72
+ * official tail additionally owns explicit `present` artifacts. Leave those
73
+ * cards, actions and state intact, and omit only the duplicate produced chips.
74
+ * This changes a presentation prop, never the source match or session data.
75
+ * Unrecognized shapes pass through intact for safe forward degradation.
76
+ */
77
+ export function readerTailMatch(value: unknown, displayedPaths?: readonly string[]): unknown {
78
+ if (!value || typeof value !== 'object') return value;
79
+ const match = value as Record<string, unknown>;
80
+ const displayed = new Set(displayedPaths ?? []);
81
+ return displayed.size > 0 && Array.isArray(match.produced) && Array.isArray(match.presented)
82
+ ? { ...match, produced: match.produced.filter(path => !displayed.has(path)) }
83
+ : value;
84
+ }
85
+
86
+ function tailPresentation(component: unknown) {
87
+ const Original = component as ComponentType<Record<string, unknown>>;
88
+ return memo(function ReaderTailPresentation(props: Record<string, unknown>) {
89
+ const matched = useMemo(() => readerTailMatch(props.matched, props.readerProducedPaths as readonly string[] | undefined), [props.matched, props.readerProducedPaths]);
90
+ return createElement(Original, { ...props, matched });
91
+ });
92
+ }
93
+
94
+ export function officialChildren(slots: Pick<CompositionRegistry, 'spec'>): { [K in OfficialSeat]: SlotSpec<SlotMap[K]> } {
95
+ return Object.fromEntries(Object.entries(OFFICIAL_SLOTS).map(([name, source]) => {
96
+ const family = name as OfficialFamily;
97
+ const spec = slots.spec(source);
98
+ if (spec && (spec.kind !== EXPECTED[family].kind || spec.scope !== EXPECTED[family].scope)) {
99
+ throw new Error(`Official slot contract changed: ${source} (${spec.kind}/${spec.scope})`);
100
+ }
101
+ // In particular, retain conversation.chat.node's contextual turn-data hook.
102
+ return [officialSeat(family), spec ?? EXPECTED[family]];
103
+ })) as { [K in OfficialSeat]: SlotSpec<SlotMap[K]> };
104
+ }
105
+
106
+ function translatedComponent(entry: StoredEntry, names: ReadonlyMap<string, string>) {
107
+ if (names.size === 0) return entry.component;
108
+ const Original = entry.component as ComponentType<Record<string, unknown>>;
109
+ return memo(function OfficialSlotChildren(props: RenderProps) {
110
+ const renders = useMemo(() => {
111
+ const translate = (render: Render | undefined): Render => (key, owner, options) => {
112
+ const seat = names.get(key);
113
+ if (!seat || !render) throw new Error(`Undeclared official child slot: ${key}`);
114
+ return render(seat, owner, options);
115
+ };
116
+ return {
117
+ renderSlot: translate(props.renderSlot),
118
+ ...(props.renderSlotChain ? { renderSlotChain: translate(props.renderSlotChain) } : {}),
119
+ };
120
+ }, [props.renderSlot, props.renderSlotChain]);
121
+ return createElement(Original, { ...props, ...renders });
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Mirror one elected contribution set incrementally. Unrelated additions do not
127
+ * remount existing cards or recreate their subscriptions. A source unload/HMR
128
+ * disposes the corresponding subtree and closures before its replacement.
129
+ */
130
+ export function mirrorOfficialSlot(
131
+ slots: CompositionRegistry,
132
+ source: string,
133
+ target: string,
134
+ namespace: string,
135
+ accept: (entry: StoredEntry) => boolean = () => true,
136
+ ): () => void {
137
+ const mounted = new Map<StoredEntry, () => void>();
138
+ let stopped = false;
139
+ const reconcile = () => {
140
+ if (stopped) return;
141
+ const entries = slots.entriesOfSlot(source).filter(accept);
142
+ const wanted = new Set(entries);
143
+ for (const [entry, dispose] of mounted) {
144
+ if (wanted.has(entry)) continue;
145
+ dispose();
146
+ mounted.delete(entry);
147
+ }
148
+ for (const entry of entries) {
149
+ if (mounted.has(entry)) continue;
150
+ const names = new Map(Object.keys(entry.children ?? {}).map(key => [key, `${namespace}/${key}`]));
151
+ const children = Object.fromEntries([...names].map(([key, seat]) => [seat, entry.children![key]]));
152
+ const disposers: (() => void)[] = [];
153
+ const dispose = () => { for (const stop of disposers.splice(0).reverse()) stop(); };
154
+ try {
155
+ const { component: _component, options, children: _children, ...metadata } = entry;
156
+ const presentation = source === OFFICIAL_SLOTS.tail && entry.locale === 'deliverables'
157
+ ? { ...entry, component: tailPresentation(entry.component) }
158
+ : entry;
159
+ disposers.push(slots.register({
160
+ ...options, ...metadata, name: target,
161
+ ...(names.size ? { children } : {}),
162
+ registrant: `dsh-better-display → ${entry.registrant ?? source}`,
163
+ }, translatedComponent(presentation, names)));
164
+ for (const [key, seat] of names) {
165
+ disposers.push(mirrorOfficialSlot(slots, key, seat, namespace));
166
+ }
167
+ mounted.set(entry, dispose);
168
+ } catch (error) {
169
+ dispose();
170
+ throw error;
171
+ }
172
+ }
173
+ };
174
+ const unsubscribe = slots.subscribe(source, reconcile);
175
+ const dispose = () => {
176
+ stopped = true;
177
+ unsubscribe();
178
+ for (const stop of [...mounted.values()].reverse()) stop();
179
+ mounted.clear();
180
+ };
181
+ try { reconcile(); } catch (error) { dispose(); throw error; }
182
+ return dispose;
183
+ }
184
+
185
+ export function installOfficialSlots(ctx: Context): () => void {
186
+ // The SlotMap overloads are for statically declared keys. Aliases are a
187
+ // runtime composition, using exactly the public erased metadata contract.
188
+ const slots = ctx.slots as unknown as CompositionRegistry;
189
+ const disposers: (() => void)[] = [];
190
+ try {
191
+ for (const [name, source] of Object.entries(OFFICIAL_SLOTS)) {
192
+ const family = name as OfficialFamily;
193
+ disposers.push(slots.inject(source, () => {
194
+ const spec = slots.spec(source)!;
195
+ const target = officialSeat(family);
196
+ const declared = slots.spec(target);
197
+ if (spec.kind !== declared?.kind || spec.scope !== declared.scope) {
198
+ throw new Error(`Official slot contract changed: ${source}`);
199
+ }
200
+ return mirrorOfficialSlot(slots, source, target, `dsh-better-display.official.${family}`,
201
+ family === 'nodes' ? entry => !READER_NODES.has(entry.options.key ?? '') : undefined);
202
+ }));
203
+ }
204
+ } catch (error) {
205
+ for (const stop of disposers.reverse()) stop();
206
+ throw error;
207
+ }
208
+ return () => { for (const stop of disposers.splice(0).reverse()) stop(); };
209
+ }
210
+
211
+ export type OfficialSlotKey = typeof OFFICIAL_SLOTS[OfficialFamily] & keyof SlotMap;
@@ -0,0 +1,11 @@
1
+ /** RC2 Chat's same-origin local media contract. Authorization stays Host-side. */
2
+ export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined {
3
+ if (protocol !== 'http:' && protocol !== 'https:') return undefined;
4
+ if (!value.startsWith('/') || value.startsWith('//')) return undefined;
5
+ return `${origin}/api/file?path=${encodeURIComponent(value)}`;
6
+ }
7
+
8
+ export const readerPathImages = {
9
+ resolve: (value: string) => typeof window === 'undefined' ? undefined
10
+ : localPathMediaUrl(window.location.protocol, window.location.origin, value),
11
+ };
@@ -38,7 +38,7 @@ function createSkillProbe(ctx: Context): SkillStatusProbe {
38
38
  return await res.json() as HostSkillStatus;
39
39
  },
40
40
  listRemoteSkills: async () => {
41
- const remote = ctx.remote as { skills?: RemoteSkillsFace } | undefined;
41
+ const remote = ctx.remote as unknown as { skills?: RemoteSkillsFace } | undefined;
42
42
  const skills = remote?.skills
43
43
  ?? (ctx.get?.('remote.skills') as RemoteSkillsFace | undefined)
44
44
  ?? ((ctx.get?.('remote') as { skills?: RemoteSkillsFace } | undefined)?.skills);
@@ -27,6 +27,7 @@ export interface ReaderState {
27
27
  }
28
28
  type ReaderActions = {
29
29
  setExpanded: (draft: ReaderState, key: string, value: boolean) => void;
30
+ resetExpanded: (draft: ReaderState) => void;
30
31
  setMotion: (draft: ReaderState, value: boolean) => void;
31
32
  setAutoFold: (draft: ReaderState, value: boolean) => void;
32
33
  setDeliverableOpenMode: (draft: ReaderState, value: DeliverableOpenMode) => void;
@@ -54,6 +55,7 @@ export function createReaderStore(): EngineStoreHandle<ReaderState, ReaderAction
54
55
  persist: 'dsh.reader.v1',
55
56
  actions: {
56
57
  setExpanded: (draft, key: string, value: boolean) => { draft.expanded[key] = value; },
58
+ resetExpanded: draft => { draft.expanded = {}; },
57
59
  setMotion: (draft, value: boolean) => { draft.motion = value; },
58
60
  setAutoFold: (draft, value: boolean) => {
59
61
  applyFoldIntensity(draft, value
@@ -5,7 +5,7 @@ import type { ReaderGroup } from './projection.js';
5
5
 
6
6
  export type ToolDraft = Extract<AssistantBlock, { kind: 'tool-call' }>;
7
7
  export type ToolPhase = 'preparing' | 'running' | 'returned' | 'succeeded' | 'failed' | 'interrupted';
8
- export type ToolCategory = 'write' | 'read' | 'terminal' | 'search' | 'web' | 'other';
8
+ export type ToolCategory = 'write' | 'read' | 'terminal' | 'search' | 'web' | 'code' | 'other';
9
9
  export interface ToolActivityEntry {
10
10
  kind: 'tool'; key: string; callId: string; step: number; order: number;
11
11
  draft?: ToolDraft; block?: ToolCallBlock;
@@ -275,12 +275,19 @@ export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'
275
275
  : /^(read|read_file)$/.test(name) ? 'read'
276
276
  : /^(bash|shell|terminal|terminal_send|exec_command|pwsh)$/.test(name) ? 'terminal'
277
277
  : /^(grep|glob|find|search)$/.test(name) ? 'search'
278
- : /^(web_search|web_fetch|web_open)$/.test(name) ? 'web' : 'other';
278
+ : /^(web_search|web_fetch|web_open)$/.test(name) ? 'web'
279
+ // Code interpreters: no file, no shell, no query — the call is a program.
280
+ // Without this branch `run_code` fell through to `other`, which has no
281
+ // renderer of its own, so its result text was emitted as ordinary prose
282
+ // and stayed outside every fold.
283
+ : /^(run_code|execute_code|code_interpreter|python|node|eval|repl)$/.test(name) ? 'code'
284
+ : 'other';
279
285
  const title = category === 'write' ? `${name === 'write' ? '写入' : '修改'}${file ? ` ${file}` : name === 'apply_patch' ? '代码补丁' : '文件'}`
280
286
  : category === 'read' ? `读取${file ? ` ${file}` : '文件'}`
281
287
  : category === 'terminal' ? description || '运行命令'
282
288
  : category === 'search' ? name === 'glob' ? '查找文件' : '搜索内容'
283
289
  : category === 'web' ? name === 'web_search' ? '搜索网页' : '读取网页'
290
+ : category === 'code' ? (description || '运行代码')
284
291
  : name;
285
292
  return { name, raw, args, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
286
293
  cwd: stringValue(args, 'workdir', 'cwd'), content: stringValue(args, 'content', 'new_string', 'newText', 'file_text') };
@@ -1,12 +1,14 @@
1
- import type { ComponentType } from 'react';
2
1
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
3
- import type { AssistantBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
2
+ import type { AssistantBlock, MessageImageLoader } from '@deepseek-ai/dsh-client-ui-conversation/client';
4
3
  import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives';
5
4
  import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots';
6
5
  import type {} from '@deepseek-ai/dsh-client-ui-chat/client';
7
6
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client';
8
7
  import type {} from '@deepseek-ai/dsh-client-ui-session/client';
9
8
  import type { createReaderStore } from './store.js';
9
+ import type { OfficialSeat } from './official-slots.js';
10
+ import type { OpenFileOptions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
11
+ import type {} from '@deepseek-ai/dsh-client-ui-tool/client';
10
12
 
11
13
  export interface ReaderBlockOwner {
12
14
  block: AssistantBlock;
@@ -31,7 +33,11 @@ export interface ReaderInjected {
31
33
  */
32
34
  fillComposer: (text: string) => boolean;
33
35
  /** Open a workspace file or directory; mode comes from the Better Display setting. */
34
- openFile: (path: string) => Promise<void> | void;
36
+ openFile: (path: string, options?: OpenFileOptions) => Promise<void> | void;
37
+ officialImageLoader: MessageImageLoader;
38
+ officialFileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined;
39
+ officialPreviewFile: (path: string) => void;
40
+ officialHost: { getSnapshot: () => { home?: string }; subscribe: (listener: () => void) => () => void };
35
41
  /** Root-scoped Better Display prefs (`dsh.reader.v1`), shared with Settings. */
36
42
  openPrefs?: {
37
43
  getSnapshot: () => {
@@ -55,16 +61,16 @@ export interface ReaderInjected {
55
61
  forkAt?: (seq: number) => void;
56
62
  /** Load session history through a target sequence number. */
57
63
  loadThrough?: (seq: unknown) => Promise<void>;
58
- /** Resolve a custom tool view registered in the `tool.call.toolview` slot (e.g. diff cards). */
59
- getToolView?: (toolName: string) => ComponentType<any> | null;
60
64
  }
61
65
  export type ReaderProps = PropsRuntime<'conversation.view'>
62
66
  & PropsLocale<'chat'>
63
- & PropsRenderSlots<'dsh-better-display.block'>
67
+ & PropsRenderSlots<'dsh-better-display.block' | OfficialSeat>
64
68
  & PropsStore<ReturnType<typeof createReaderStore>>
65
69
  & ReaderInjected;
66
- export type BlockRenderProps = Pick<ReaderProps, 'renderSlotChain' | 'loadImage' | 'fillComposer' | 'getToolView'> & {
67
- openFile?: (path: string) => Promise<void> | void;
70
+ export type BlockRenderProps = Pick<ReaderProps, 'renderSlotChain' | 'loadImage' | 'fillComposer'> & {
71
+ official?: Pick<ReaderProps, 'renderSlot' | 'renderSlotChain' | 'officialImageLoader' | 'officialFileMentions' | 'officialPreviewFile' | 'officialHost' | 'openView'>;
72
+ cwd?: string;
73
+ openFile?: (path: string, options?: OpenFileOptions) => Promise<void> | void;
68
74
  revealFile?: (path: string) => Promise<void> | void;
69
75
  forkAt?: (seq: number) => void;
70
76
  /** Durable closing-message seq of this turn (turn-tail closing), used as the fork anchor. */
@@ -2,6 +2,7 @@ import { createContext, useContext, useLayoutEffect, useMemo, useRef } from 'rea
2
2
  import type { ReactNode, RefObject } from 'react';
3
3
  import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives';
4
4
  import { MarkdownText } from './markdown/MarkdownText.js';
5
+ import { readerPathImages } from './platform-media.js';
5
6
  import { WORD_MOTION, WordTimeline } from './word-timeline.js';
6
7
  import css from './Reader.module.css';
7
8
 
@@ -86,6 +87,6 @@ export function MotionMarkdown({ text, streaming, enabled, revision, fileMention
86
87
  return current.hasLiveText ? <MotionAtom born={current.bornAt(offset)} generation={current.generation} offset={offset}>{children}</MotionAtom> : children;
87
88
  }, []);
88
89
  return <WordScope.Provider value={scope}>
89
- <MarkdownText text={text} streaming={streaming} codeLabels={CODE_LABELS} fileMentions={fileMentions} renderText={renderText} renderAtom={renderAtom} />
90
+ <MarkdownText text={text} streaming={streaming} codeLabels={CODE_LABELS} fileMentions={fileMentions} pathImages={readerPathImages} renderText={renderText} renderAtom={renderAtom} />
90
91
  </WordScope.Provider>;
91
92
  }