@changfenhuang/dsh-genui 0.11.3 → 0.12.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 (60) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +21 -6
  3. package/README.zh-CN.md +21 -6
  4. package/SKILL.md +18 -6
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +10 -9
  7. package/lib/client.js +31 -33
  8. package/lib/index.js +1060 -371
  9. package/lib/types/client/blocks/forms.d.ts +5 -6
  10. package/lib/types/client/blocks/state.d.ts +2 -11
  11. package/lib/types/client/dom-fence.d.ts +2 -1
  12. package/lib/types/client/file-address.d.ts +4 -0
  13. package/lib/types/client/file-link-context.d.ts +41 -0
  14. package/lib/types/client/file-link.d.ts +17 -0
  15. package/lib/types/client/i18n/en.d.ts +5 -1
  16. package/lib/types/client/inline.d.ts +1 -1
  17. package/lib/types/client/mermaid-colors.d.ts +2 -0
  18. package/lib/types/client/panel.d.ts +6 -1
  19. package/lib/types/client/spec.d.ts +8 -11
  20. package/lib/types/client/submission-registry.d.ts +51 -0
  21. package/lib/types/client/table-details.d.ts +20 -0
  22. package/lib/types/client/walk-spec.d.ts +3 -0
  23. package/lib/types/plugin/fence-feedback.d.ts +55 -12
  24. package/lib/types/plugin/index.d.ts +7 -3
  25. package/lib/types/shared/fence-repair.d.ts +19 -7
  26. package/package.json +1 -12
  27. package/src/client/GenuiBlock.module.css +100 -4
  28. package/src/client/GenuiBlock.tsx +7 -14
  29. package/src/client/artifact/ArtifactExport.module.css +34 -3
  30. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  31. package/src/client/blocks/charts.tsx +63 -14
  32. package/src/client/blocks/forms.tsx +26 -48
  33. package/src/client/blocks/render-node.tsx +11 -8
  34. package/src/client/blocks/state.ts +2 -12
  35. package/src/client/dom-fence.tsx +190 -50
  36. package/src/client/file-address.ts +37 -0
  37. package/src/client/file-link-context.tsx +114 -0
  38. package/src/client/file-link.ts +46 -0
  39. package/src/client/genui-runtime/diagnostics.ts +20 -0
  40. package/src/client/genui-runtime/normalize.ts +28 -9
  41. package/src/client/guard.ts +98 -153
  42. package/src/client/i18n/en.ts +5 -1
  43. package/src/client/i18n/zh.ts +5 -1
  44. package/src/client/index.tsx +23 -5
  45. package/src/client/inline.ts +28 -10
  46. package/src/client/mermaid-colors.ts +39 -0
  47. package/src/client/mermaid-core.ts +7 -3
  48. package/src/client/panel.tsx +18 -5
  49. package/src/client/spec.ts +8 -11
  50. package/src/client/submission-registry.ts +150 -0
  51. package/src/client/table-details.ts +151 -0
  52. package/src/client/walk-spec.ts +43 -0
  53. package/src/plugin/fence-feedback.ts +384 -53
  54. package/src/plugin/index.ts +20 -9
  55. package/src/plugin/tool.ts +8 -2
  56. package/src/shared/fence-repair.ts +345 -128
  57. package/tsdown.config.ts +3 -7
  58. package/lib/invariant.js +0 -19
  59. package/lib/types/plugin/invariant.d.ts +0 -15
  60. package/src/plugin/invariant.ts +0 -30
@@ -9,6 +9,8 @@ import * as primitives from '../primitive-adapter.ts'
9
9
  import css from '../GenuiBlock.module.css'
10
10
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
11
11
  import { renderInline } from '../inline.ts'
12
+ import { classifyLinkTarget } from '../file-link.ts'
13
+ import { FileLink } from '../file-link-context.tsx'
12
14
  import { t } from '../i18n/index.ts'
13
15
  import type { GenuiList, GenuiNode } from '../spec.ts'
14
16
  import type { AnswersState, GenuiBlockProps } from './state.ts'
@@ -283,13 +285,14 @@ export function renderNode(
283
285
  case 'select': return <SelectNode key={key} node={node} onAction={onAction} answers={answers} />
284
286
  case 'checkbox': return <CheckboxNode key={key} node={node} onAction={onAction} answers={answers} />
285
287
  case 'link': {
286
- // Honest affordance: with a whitelisted href this is a REAL anchor;
287
- // without one it is plain styled text (a dead clickable-looking button
288
- // was the same complaint class as the disabled-button fix).
289
- const href = node.href
290
- return href !== undefined
291
- ? <a key={key} className={css.link} href={href} target="_blank" rel="noopener noreferrer">{renderInline(node.label, false)}</a>
292
- : <span key={key} className={css.linkText}>{renderInline(node.label, false)}</span>
288
+ const target = classifyLinkTarget(node.href)
289
+ if (target.type === 'external') {
290
+ return <a key={key} className={css.link} href={target.href} target="_blank" rel="noopener noreferrer">{renderInline(node.label, false)}</a>
291
+ }
292
+ if (target.type === 'file') {
293
+ return <FileLink key={key} path={target.file.path} label={node.label} {...(target.file.line === undefined ? {} : { line: target.file.line })}>{renderInline(node.label, false)}</FileLink>
294
+ }
295
+ return <span key={key} className={css.linkText}>{renderInline(node.label, false)}</span>
293
296
  }
294
297
  case 'image': return <ImageNode key={`${key}:${node.src}`} node={node} />
295
298
  case 'audio': return <AudioNode key={`${key}:${node.src}`} node={node} />
@@ -299,7 +302,7 @@ export function renderNode(
299
302
  return (
300
303
  <span key={key} className={`${css.badge} ${css[tone] || ''}`}>
301
304
  {node.icon !== undefined && <span aria-hidden>{node.icon} </span>}
302
- {renderInline(node.label, false)}
305
+ <span className={css.badgeLabel}>{renderInline(node.label, false)}</span>
303
306
  </span>
304
307
  )
305
308
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { GenuiSpec } from '../spec.ts'
9
9
  import type { BlockInteractionState } from '../interaction-store.ts'
10
+ import type { SubmissionRegistry } from '../submission-registry.ts'
10
11
 
11
12
  export interface GenuiBlockProps {
12
13
  /** Parsed spec to render. */
@@ -35,16 +36,6 @@ export interface GenuiBlockProps {
35
36
  onStateSnapshot?: ((state: BlockInteractionState) => void) | undefined
36
37
  }
37
38
 
38
- /** Per-question metadata registered by grouped radios for local grading. */
39
- export interface QuestionMeta {
40
- label: string
41
- options: string[]
42
- /** Correct option: index (number) or label (string); absent = no local grading. */
43
- answer?: number | string | undefined
44
- /** Shown after local grading. */
45
- explanation?: string | undefined
46
- }
47
-
48
39
  /** Block-wide aggregation state. Grouped radios store one selected label in
49
40
  * `answers`; grouped checkboxes store a selected-label array in
50
41
  * `multiAnswers`. Keeping the registries separate preserves the existing
@@ -60,7 +51,7 @@ export interface AnswersState {
60
51
  fields: Record<string, string>
61
52
  /** Field ids whose value must never be persisted or collected (secrets). */
62
53
  secretFields: ReadonlySet<string>
63
- meta: Record<string, QuestionMeta>
54
+ registry: SubmissionRegistry
64
55
  /** True after a local grading: questions are locked until 重新作答. */
65
56
  locked: boolean
66
57
  /** Bumped by every reset; radios use it as their remount key. */
@@ -69,7 +60,6 @@ export interface AnswersState {
69
60
  setMultiAnswer: (group: string, choice: string, checked: boolean) => void
70
61
  setField: (id: string, value: string) => void
71
62
  registerSecretField: (id: string) => void
72
- registerMeta: (group: string, meta: QuestionMeta) => void
73
63
  clear: () => void
74
64
  setLocked: (locked: boolean) => void
75
65
  }
@@ -4,8 +4,9 @@
4
4
  * Stock DSH renders every fenced code block through the shared CodeBlock
5
5
  * surface (stable class `md-code-block`, language label rendered as the
6
6
  * banner's childless label div). This channel observes the conversation DOM,
7
- * finds blocks labelled `dsh-ui`, parses the raw fence body and mounts the
8
- * plugin's own React tree next to the (hidden) stock block:
7
+ * finds blocks labelled `dsh-ui` or complete `<dsh-ui>…</dsh-ui>` text spans,
8
+ * parses the raw fence body and mounts the plugin's own React tree next to
9
+ * the hidden host element:
9
10
  *
10
11
  * Fence discovery is **multi-surface** (issue #6): besides `md-code-block`,
11
12
  * the channel also matches the deepsuite-style surfaces some host builds
@@ -13,8 +14,9 @@
13
14
  * structural backstop — ANY element whose banner labels it `dsh-ui` and
14
15
  * which contains a `<pre>` body. The only invariants are the language label
15
16
  * (a leaf element with the exact text `dsh-ui`, outside the code body) and
16
- * the `<pre>`, so a host DOM drift degrades to a rendered fence, never a
17
- * silently skipped one:
17
+ * the `<pre>`. Desktop 0.11.0 instead emits a plain-text tag span for the
18
+ * same fence; the DOM channel recognizes that exact wrapper in assistant
19
+ * messages:
18
20
  *
19
21
  * - **Streaming takeover**: the channel takes over a dsh-ui block as soon as
20
22
  * ONE finished component parses (the partial parser), and re-renders the
@@ -46,7 +48,7 @@
46
48
  *
47
49
  * Security posture matches the registry channel: only code shipped in this
48
50
  * plugin's browser bundle mounts React roots, the model can only author
49
- * fence text, and unrepairable bodies stay stock code blocks.
51
+ * fence text, and unrepairable bodies remain visible in their host form.
50
52
  */
51
53
  import { Fragment, isValidElement, type Key, type ReactNode } from 'react'
52
54
  import { createRoot, type Root } from 'react-dom/client'
@@ -56,8 +58,10 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
56
58
  import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
57
59
  import type { SessionId } from '@deepseek-ai/dsh-session/types'
58
60
  import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
61
+ import { withFileLinkContext, type FileLinkAvailability, type FileLinkCordisContext } from './file-link-context.tsx'
59
62
  import css from './GenuiBlock.module.css'
60
63
  import { renderSvgFence } from './svg-fence.tsx'
64
+ import { repairFenceJson, trimToBalancedRoot } from '../shared/fence-repair.ts'
61
65
  import { describeFenceFailure, FenceDiagnostic, renderResolvedFenceNode, type GenuiFenceContext } from './fence-render.tsx'
62
66
  import { resolveViewedSessionId } from './session-resolver.ts'
63
67
  import { validateCanonicalGenuiSpec } from './guard.ts'
@@ -65,13 +69,27 @@ import { diagnoseUnknownGenuiFields } from './genui-runtime/diagnostics.ts'
65
69
  import { normalizeGenuiSpec } from './genui-runtime/normalize.ts'
66
70
  import { sourceFencesOfAssistant, sourceLanguageAt } from './source-fence.ts'
67
71
 
68
- /** Fence surfaces the channel can take over, newest host first: the shared
69
- * CodeBlock surface every rc.6+ markdown fence renders through
70
- * (`.md-code-block`) and the deepsuite-style surfaces some host builds
71
- * render instead (`.code-block` / `.code-block-small`). Surfaces with an
72
- * unlisted class are still found by the structural backstop (label + `<pre>`),
73
- * so this list is an optimization, not a hard contract. */
72
+ /** 优先用宿主代码块内容的语义标记定位包装节点;隐藏语言或改变类名不能阻断
73
+ * 原始消息的围栏识别。类名也用于仍以这些表面呈现的代码块。 */
74
74
  const CODE_BLOCK_SELECTORS = '.md-code-block, .code-block, .code-block-small'
75
+
76
+ /** 在同一处收集代码块包装节点,供候选扫描和原始围栏序号共用。 */
77
+ function codeBlockSurfaces(scope: ParentNode): HTMLElement[] {
78
+ const surfaces = new Set(scope.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTORS))
79
+ for (const content of scope.querySelectorAll<HTMLElement>('[data-code-block-content]')) {
80
+ if (content.parentElement !== null) surfaces.add(content.parentElement)
81
+ }
82
+ return [...surfaces].sort((left, right) => left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
83
+ }
84
+
85
+ /** 返回拥有代码内容语义节点、或已知代码块类名的最近包装节点。 */
86
+ function closestCodeBlockSurface(element: Element | null): Element | null {
87
+ for (let current = element; current !== null; current = current.parentElement) {
88
+ if (current.matches(CODE_BLOCK_SELECTORS)
89
+ || [...current.children].some(child => child.hasAttribute('data-code-block-content'))) return current
90
+ }
91
+ return null
92
+ }
75
93
  /** Marker attribute set on blocks this channel has taken over. */
76
94
  const PROCESSED = 'data-genui-rendered'
77
95
  /** The settled marker on AssistantMarkdown (absent = settled). */
@@ -83,6 +101,10 @@ const DIAGNOSTIC_CLASS = 'genui-dom-fence-diagnostic'
83
101
  /** Slow sweep interval: the observer catches everything, this is the 1s
84
102
  * belt-and-braces pass (history loads, missed attribute batches). */
85
103
  const SWEEP_MS = 1000
104
+ const NO_FILE_LINK_AVAILABILITY: FileLinkAvailability = {
105
+ subscribe: () => () => {},
106
+ getSnapshot: () => false,
107
+ }
86
108
 
87
109
  /** Max ancestors walked from a `<pre>` to its fence surface root (banner +
88
110
  * pre holder). Most hosts put the pre directly under the surface; some wrap
@@ -161,6 +183,18 @@ function isTextNode(node: Node): node is Text {
161
183
  return node.nodeType === Node.TEXT_NODE
162
184
  }
163
185
 
186
+ /** 读取 DSH Desktop 0.11.0 将 dsh-ui 围栏呈现为纯文本标签时的正文。 */
187
+ function tagTextBodyOf(block: Element): string | null {
188
+ if (block.tagName !== 'SPAN' || block.childElementCount !== 0) return null
189
+ const flowKind = block.closest('[data-chat-flow-kind]')?.getAttribute('data-chat-flow-kind')
190
+ if (flowKind !== undefined && flowKind !== null && flowKind !== 'assistant-step') return null
191
+ const text = block.textContent?.trim() ?? ''
192
+ const opening = '<dsh-ui>'
193
+ const closing = '</dsh-ui>'
194
+ if (!text.startsWith(opening) || !text.endsWith(closing)) return null
195
+ return text.slice(opening.length, -closing.length).trim()
196
+ }
197
+
164
198
  /** The banner's language label: a leaf element whose text is exactly the
165
199
  * lang. CodeBlock renders the label as a childless div; deepsuite-style
166
200
  * surfaces use a span; the ONLY structural invariants across hosts are "a
@@ -181,19 +215,46 @@ function infostringOf(block: Element): 'dsh-ui' | 'svg' | null {
181
215
  // surface's banner, not `block`'s own banner. Only accept labels whose
182
216
  // nearest known surface is `block` itself (or none — unknown surfaces
183
217
  // stay supported by the structural backstop).
184
- const owner = el.closest(CODE_BLOCK_SELECTORS)
218
+ const owner = closestCodeBlockSurface(el)
185
219
  if (owner !== null && owner !== block) continue
186
220
  return lang
187
221
  }
188
222
  return null
189
223
  }
190
224
 
191
- const GENERIC_CODE_LABELS = new Set(['Code', 'Code block', '代码块'])
225
+ /**
226
+ * 已知**真实语言标识符**的闭集:语言 id 有限、由本插件维护,白名单列它是对的。
227
+ * 反例——宿主各语言里通用代码块标题的叫法(`Code` / `代码块` / «Код» / Código /
228
+ * Codice / Kode …)——是**无界集合**,白名单永远列不全:旧实现 `GENERIC_CODE_LABELS`
229
+ * 只列了英/中两种,宿主 locale 为其它语言时本地化标题被当成真语言,围栏永远不被
230
+ * 接管(issue #258)。因此判定反转:标签命中本集合才视为「带语言」,其余(空标签、
231
+ * 本地化通用词)一律视为「无语言」,由 {@link isGenericGenuiFence} 的内容校验把关——
232
+ * 同一份正文的渲染结果不因宿主语言而不同。
233
+ */
234
+ const KNOWN_LANGUAGE_LABELS = new Set([
235
+ // 插件自有语言
236
+ 'dsh-ui', 'svg',
237
+ // 数据 / 标记 / 纯文本
238
+ 'json', 'json5', 'yaml', 'yml', 'toml', 'ini', 'xml', 'html', 'css', 'scss', 'sass', 'less',
239
+ 'markdown', 'md', 'text', 'txt', 'plain', 'plaintext', 'log', 'console', 'diff', 'patch',
240
+ // 助手回复里常见的编程语言
241
+ 'js', 'jsx', 'javascript', 'ts', 'tsx', 'typescript', 'python', 'py', 'bash', 'sh', 'shell', 'zsh',
242
+ 'sql', 'graphql', 'gql', 'rust', 'go', 'golang', 'java', 'kotlin', 'kt', 'swift', 'scala', 'dart',
243
+ 'c', 'h', 'cpp', 'c++', 'cxx', 'cs', 'csharp', 'php', 'ruby', 'rb', 'perl', 'lua', 'r', 'matlab',
244
+ 'haskell', 'hs', 'elixir', 'ex', 'erlang', 'clojure', 'clj', 'groovy', 'objc', 'objective-c',
245
+ 'dockerfile', 'docker', 'makefile', 'make', 'cmake', 'protobuf', 'proto',
246
+ ])
192
247
 
193
- /** Read a language that the host still exposes in its CodeBlock banner. */
248
+ /**
249
+ * Read a language that the host still exposes in its CodeBlock banner.
250
+ *
251
+ * 只有标签命中 {@link KNOWN_LANGUAGE_LABELS} 才返回语言;空标签与本地化通用词
252
+ * («Код» / Código / 代码块 …)是宿主的**呈现文案**、不是语义,一律返回 null——
253
+ * 此前它们会被当成真语言,连 ChatSnapshot 来源都被短路(issue #258)。
254
+ */
194
255
  function domLanguageOf(block: Element): string | null {
195
256
  const label = labelTextOf(block)
196
- return label === '' || GENERIC_CODE_LABELS.has(label) ? null : label
257
+ return label !== '' && KNOWN_LANGUAGE_LABELS.has(label.toLowerCase()) ? label : null
197
258
  }
198
259
 
199
260
  /** The banner label's raw text (empty while streaming — the host renders the
@@ -210,7 +271,18 @@ function labelTextOf(block: Element): string {
210
271
  return ''
211
272
  }
212
273
 
213
- /** 仅在通用 CodeBlock 的完整 JSON 通过现有 GenUI 规范时恢复丢失的围栏语言。
274
+ /**
275
+ * 仅在通用 CodeBlock 的内容经过**与带标签路径相同的标点级修复**后仍是合规 GenUI
276
+ * 规范时,恢复丢失的围栏语言。
277
+ *
278
+ * 宿主会隐去它不认识的语言(高亮器不支持 `dsh-ui`),于是同一份围栏在 DOM 里表现为
279
+ * 一个通用标题的普通代码块(标题随宿主 locale 本地化:`Code` / `代码块` / «Код» …);
280
+ * ChatSnapshot 的语言来源在部分行上不可用
281
+ * 时,内容识别是唯一出路。此前这里要求 `JSON.parse(raw)` 直接通过,导致**正文只差一个
282
+ * 未转义引号(tier-1 能修)的围栏永远不会被接管**——用户看到一个能渲染却始终是代码块的
283
+ * 围栏(真实会话 seq 40530:正文经 tier-1 修 14 处后可渲染,界面却停在代码块)。
284
+ * 结构级修复(tier-2)刻意不参与:内容识别是兜底,不该接管只是"长得像 JSON"的普通代码。
285
+
214
286
  *
215
287
  * @param block - 宿主提供的代码块元素。
216
288
  * @param raw - 未修改的围栏正文。
@@ -219,20 +291,33 @@ function labelTextOf(block: Element): string {
219
291
  function isGenericGenuiFence(block: Element, raw: string): boolean {
220
292
  const row = block.closest<HTMLElement>(ASSISTANT_FLOW_ROW)
221
293
  if (row === null || row.dataset.chatGroupPart === 'reasoning') return false
294
+ // domLanguageOf 已反转(#258):banner 是已知真实语言 ⇒ 不是通用块;空标签或
295
+ // 本地化通用词 ⇒ null,进入内容校验。这里不再要求标题命中任何白名单。
222
296
  if (domLanguageOf(block) !== null || !block.querySelector('[data-code-block-banner]')) return false
223
- if (!GENERIC_CODE_LABELS.has(labelTextOf(block))) return false
297
+ const repaired = repairFenceJson(raw)
298
+ const candidate = repaired === null ? raw : repaired.text
224
299
  let value: unknown
225
300
  try {
226
- value = JSON.parse(raw)
301
+ value = JSON.parse(candidate)
227
302
  } catch {
228
- return false
303
+ // 「合法 JSON + 尾部杂字符」(真实样本:模型把工具调用模板泄漏在 JSON 之后,
304
+ // 而且围栏没闭合)同样要能认出来:裁到平衡根值再试一次。只裁剪、不补全结构。
305
+ const trimmed = trimToBalancedRoot(candidate)
306
+ if (trimmed === null) return false
307
+ try {
308
+ value = JSON.parse(trimmed)
309
+ } catch {
310
+ return false
311
+ }
229
312
  }
230
313
  if (!validateCanonicalGenuiSpec(value).ok || diagnoseUnknownGenuiFields(value).length > 0) return false
231
314
  return JSON.stringify(normalizeGenuiSpec(value).value) === JSON.stringify(value)
232
315
  }
233
316
 
234
- /** Raw fence body from the stock block's code surface. */
317
+ /** 读取宿主代码块或纯文本标签中的围栏正文。 */
235
318
  function rawOf(block: Element): string {
319
+ const tagTextBody = tagTextBodyOf(block)
320
+ if (tagTextBody !== null) return tagTextBody
236
321
  const pre = block.querySelector('pre')
237
322
  if (pre === null) return ''
238
323
  let text = ''
@@ -274,7 +359,7 @@ function implausibleLabeledAncestorOf(pre: HTMLElement, scope: ParentNode = docu
274
359
  }
275
360
 
276
361
  /**
277
- * Every dsh-ui fence surface under `scope`, outer-most first, deduped.
362
+ * Every dsh-ui fence surface or complete tag-text span under `scope`, in DOM order.
278
363
  * Known surface classes first (cheap, ordered), then a structural sweep —
279
364
  * every `<pre>` whose banner labels it `dsh-ui` — so a host with an
280
365
  * unlisted surface shape still renders. The label + `<pre>` gates make the
@@ -284,11 +369,11 @@ function implausibleLabeledAncestorOf(pre: HTMLElement, scope: ParentNode = docu
284
369
  function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
285
370
  const seen = new Set<HTMLElement>()
286
371
  const out: HTMLElement[] = []
287
- for (const el of scope.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTORS)) {
372
+ for (const el of codeBlockSurfaces(scope)) {
288
373
  // Modifier classes can sit inside a surface (e.g. a `code-block-small`
289
374
  // child of `code-block`): only the outermost matching element is a
290
375
  // candidate, so a fence is never double-counted or taken over twice.
291
- if (el.parentElement !== null && el.parentElement.closest(CODE_BLOCK_SELECTORS) !== null) continue
376
+ if (closestCodeBlockSurface(el.parentElement) !== null) continue
292
377
  if (el.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui-svg-fence]`) !== null) continue
293
378
  if (seen.has(el)) continue
294
379
  // Message-level containers that happen to carry a surface class must
@@ -307,7 +392,8 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
307
392
  // markdown root holding both a dsh-ui fence and a python block — and
308
393
  // the backstop would mislabel that whole container as a fence, hiding
309
394
  // every other code block with it (issue #13).
310
- if (pre.closest(`${CODE_BLOCK_SELECTORS}, .${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui-svg-fence]`) !== null) continue
395
+ if (closestCodeBlockSurface(pre) !== null
396
+ || pre.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui-svg-fence]`) !== null) continue
311
397
  const surface = surfaceOf(pre, scope)
312
398
  if (surface === null) {
313
399
  // Diagnose the issue #19 guard: a labeled ancestor that is NOT a code
@@ -327,7 +413,13 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
327
413
  out.push(surface)
328
414
  seen.add(surface)
329
415
  }
330
- return out
416
+ for (const span of scope.querySelectorAll<HTMLElement>('span')) {
417
+ if (closestCodeBlockSurface(span) !== null
418
+ || span.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-chat-group-part="reasoning"], [data-tool], [data-sidebar-chat], [data-sidebar-right-session], [data-sidebar-right-panel], [data-panel-conversation], [data-plugin-panel]`) !== null) continue
419
+ if (tagTextBodyOf(span) === null) continue
420
+ out.push(span)
421
+ }
422
+ return out.sort((left, right) => left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
331
423
  }
332
424
 
333
425
  /** One-time-per-install drift diagnostic flag (reset per install, so tests
@@ -383,7 +475,7 @@ function fenceIndexOf(ctx: Context, row: Element, block: Element, sourceLanguage
383
475
  let fallbackIndex = 0
384
476
  for (const candidate of findFenceCandidates(scope)) {
385
477
  if (candidate.closest(STREAMING) !== null) continue
386
- const language = domLanguageOf(candidate) ?? sourceLanguage(candidate)
478
+ const language = tagTextBodyOf(candidate) !== null ? 'dsh-ui' : domLanguageOf(candidate) ?? sourceLanguage(candidate)
387
479
  if (language !== 'dsh-ui' && !(language === undefined && isGenericGenuiFence(candidate, rawOf(candidate)))) continue
388
480
  fallbackIndex += 1
389
481
  if (candidate === block) return fallbackIndex
@@ -391,9 +483,11 @@ function fenceIndexOf(ctx: Context, row: Element, block: Element, sourceLanguage
391
483
  return fallbackIndex + 1
392
484
  }
393
485
  let index = 0
394
- for (const candidate of hostFenceBlocksOf(row)) {
486
+ for (const candidate of findFenceCandidates(row)) {
395
487
  if (candidate.closest(STREAMING) !== null) continue
396
- const language = domLanguageOf(candidate) ?? sourceLanguage(candidate)
488
+ if (candidate.closest(ASSISTANT_FLOW_ROW) !== row) continue
489
+ if (candidate.closest('[data-chat-group-part="reasoning"]') !== null) continue
490
+ const language = tagTextBodyOf(candidate) !== null ? 'dsh-ui' : domLanguageOf(candidate) ?? sourceLanguage(candidate)
397
491
  if (language !== 'dsh-ui' && !(language === undefined && isGenericGenuiFence(candidate, rawOf(candidate)))) continue
398
492
  index += 1
399
493
  if (candidate === block) return index
@@ -403,8 +497,8 @@ function fenceIndexOf(ctx: Context, row: Element, block: Element, sourceLanguage
403
497
 
404
498
  /** Return outer host Markdown code surfaces in one assistant row's DOM order. */
405
499
  function hostFenceBlocksOf(row: Element): HTMLElement[] {
406
- return [...row.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTORS)].filter(candidate => {
407
- if (candidate.parentElement?.closest(CODE_BLOCK_SELECTORS) !== null) return false
500
+ return codeBlockSurfaces(row).filter(candidate => {
501
+ if (closestCodeBlockSurface(candidate.parentElement) !== null) return false
408
502
  if (candidate.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui], [data-genui-svg-fence]`) !== null) return false
409
503
  if (candidate.closest('[data-chat-group-part="reasoning"], [data-tool], [data-sidebar-chat], [data-sidebar-right-session], [data-sidebar-right-panel], [data-panel-conversation], [data-plugin-panel]') !== null) return false
410
504
  if (candidate.closest(ASSISTANT_FLOW_ROW) !== row) return false
@@ -431,13 +525,18 @@ export function sourceLanguageOf(ctx: Context, block: Element): string | null |
431
525
  }
432
526
 
433
527
  /** Cache each row's Markdown parse for one sweep and retain confirmed opening-line languages per host block. */
434
- function createSourceLanguageResolver(ctx: Context): { beginSweep: () => void; get: (block: Element) => string | null | undefined } {
528
+ function createSourceLanguageResolver(ctx: Context): {
529
+ beginSweep: () => void
530
+ get: (block: Element) => string | null | undefined
531
+ isSessionUnavailable: () => boolean
532
+ } {
435
533
  const stableLanguages = new WeakMap<Element, { sessionId: SessionId; nodeKey: string; language: string | null }>()
436
534
  let sweepLanguages = new Map<Element, string | null | undefined>()
437
535
  let parsedNodes = new Map<string, ReturnType<typeof sourceFencesOfAssistant>>()
438
536
  let sessionId: SessionId | undefined
439
537
  let chat: ChatSnapshot | undefined
440
538
  let chatRead = false
539
+ let sessionUnavailable = false
441
540
 
442
541
  return {
443
542
  beginSweep() {
@@ -446,13 +545,23 @@ function createSourceLanguageResolver(ctx: Context): { beginSweep: () => void; g
446
545
  sessionId = undefined
447
546
  chat = undefined
448
547
  chatRead = false
548
+ sessionUnavailable = false
549
+ },
550
+ isSessionUnavailable() {
551
+ return sessionUnavailable
449
552
  },
450
553
  get(block) {
451
554
  if (sweepLanguages.has(block)) return sweepLanguages.get(block)
452
555
  const row = block.closest<HTMLElement>(`${ASSISTANT_FLOW_ROW}[data-chat-node-key]`)
453
556
  if (row === null || row.dataset.chatGroupPart === 'reasoning') return undefined
454
557
  const nodeKey = row.dataset.chatNodeKey
455
- const activeSessionId = sessionIdOfForSource(ctx)
558
+ const sourceSession = sourceSessionOf(ctx)
559
+ if (!sourceSession.available) {
560
+ sessionUnavailable = true
561
+ sweepLanguages.set(block, undefined)
562
+ return undefined
563
+ }
564
+ const activeSessionId = sourceSession.sessionId
456
565
  if (!nodeKey || activeSessionId === undefined) return undefined
457
566
 
458
567
  const stable = stableLanguages.get(block)
@@ -497,9 +606,19 @@ export function hostFenceIndexOf(row: Element, block: Element): number {
497
606
  return hostFenceBlocksOf(row).indexOf(block as HTMLElement)
498
607
  }
499
608
 
609
+ /** Read the viewed session and report when Cordis rejects access to its Context. */
610
+ function sourceSessionOf(ctx: Context): { available: true; sessionId: SessionId | undefined } | { available: false } {
611
+ try {
612
+ return { available: true, sessionId: resolveViewedSessionId(ctx.sessions.list.getSnapshot()) }
613
+ } catch {
614
+ return { available: false }
615
+ }
616
+ }
617
+
500
618
  /** Read the currently viewed session for source Markdown lookup. */
501
619
  function sessionIdOfForSource(ctx: Context): SessionId | undefined {
502
- return resolveViewedSessionId(ctx.sessions.list.getSnapshot())
620
+ const sourceSession = sourceSessionOf(ctx)
621
+ return sourceSession.available ? sourceSession.sessionId : undefined
503
622
  }
504
623
 
505
624
  /** Read uiConversation as an optional service so Cordis does not require a hard inject. */
@@ -577,6 +696,7 @@ function anchorSeqOf(row: Element): number {
577
696
  export function installDomFenceRenderer(
578
697
  ctx: Context,
579
698
  sendAction: (sessionId: SessionId, action: string, payload: Record<string, unknown>) => void,
699
+ fileLinkAvailability: FileLinkAvailability = NO_FILE_LINK_AVAILABILITY,
580
700
  ): () => void {
581
701
  if (typeof document === 'undefined') return () => {}
582
702
  driftWarned = false
@@ -592,12 +712,27 @@ export function installDomFenceRenderer(
592
712
  let unsubscribeChat: (() => void) | undefined
593
713
  const sourceLanguages = createSourceLanguageResolver(ctx)
594
714
 
595
- const sessionIdOf = (): SessionId | undefined => {
596
- try {
597
- return resolveViewedSessionId(ctx.sessions.list.getSnapshot())
598
- } catch {
599
- return undefined
600
- }
715
+ /** Apply both plugin contexts to every actual GenUI root render. */
716
+ function wrapFenceNode(node: ReactNode, context: GenuiFenceContext, handler: GenuiActionHandler, streaming: boolean): ReactNode {
717
+ const actionTree = <GenuiActionContext.Provider value={handler}>{node}</GenuiActionContext.Provider>
718
+ return withFileLinkContext(
719
+ ctx as unknown as FileLinkCordisContext,
720
+ fileLinkAvailability,
721
+ context.sessionId,
722
+ streaming,
723
+ actionTree,
724
+ )
725
+ }
726
+
727
+ const sessionIdOf = (): SessionId | undefined => sessionIdOfForSource(ctx)
728
+ let lastAvailableSessionId = sessionIdOf()
729
+
730
+ /** Keep a mounted fence's React identity stable while Cordis temporarily denies session access. */
731
+ function sessionIdForRender(preserveWhenUnavailable: boolean): SessionId | undefined {
732
+ const sourceSession = sourceSessionOf(ctx)
733
+ if (!sourceSession.available) return preserveWhenUnavailable ? lastAvailableSessionId : undefined
734
+ lastAvailableSessionId = sourceSession.sessionId
735
+ return sourceSession.sessionId
601
736
  }
602
737
 
603
738
  /** Retry ChatSnapshot subscription while the active session binding is unavailable. */
@@ -615,7 +750,7 @@ export function installDomFenceRenderer(
615
750
  /** Render context for a block: session always; the stable source identity
616
751
  * only once settled — streaming renders are identity-less (no panel
617
752
  * publish, no durable state), mirroring the registry channel. */
618
- function contextOf(row: Element, block: Element, settled: boolean): { key: Key; context: GenuiFenceContext } {
753
+ function contextOf(row: Element, block: Element, settled: boolean, preserveSessionIdentity = false): { key: Key; context: GenuiFenceContext } {
619
754
  if (settled && row.getAttribute('data-chat-anchor-key') === null) {
620
755
  // Safari / fallback render path: the host omitted the row anchor (the
621
756
  // attribute is a React key that React drops when undefined). Fences
@@ -626,7 +761,7 @@ export function installDomFenceRenderer(
626
761
  const fenceIndex = fenceIndexOf(ctx, row, block, sourceLanguages.get)
627
762
  const anchorKey = row.getAttribute('data-chat-anchor-key') ?? 'unknown'
628
763
  const key = `dom:${anchorKey}:${fenceIndex}` as Key
629
- const sessionId = sessionIdOf()
764
+ const sessionId = sessionIdForRender(preserveSessionIdentity)
630
765
  const context: GenuiFenceContext = {
631
766
  ...(sessionId === undefined ? {} : { sessionId }),
632
767
  ...(settled ? { source: { id: key as string, order: [anchorSeqOf(row), 0, fenceIndex] as const } } : {}),
@@ -744,7 +879,7 @@ export function installDomFenceRenderer(
744
879
  if (block.hasAttribute(PROCESSED)) return
745
880
  const row = rowOf(block)
746
881
  const settled = isSettled(block)
747
- const domLanguage = domLanguageOf(block)
882
+ const domLanguage = tagTextBodyOf(block) !== null ? 'dsh-ui' : domLanguageOf(block)
748
883
  const sourceLanguage = domLanguage === null ? sourceLanguages.get(block) : undefined
749
884
  const language = domLanguage ?? sourceLanguage
750
885
  const raw = rawOf(block)
@@ -795,7 +930,7 @@ export function installDomFenceRenderer(
795
930
  }
796
931
  try {
797
932
  const handler: GenuiActionHandler = (action, payload) => sendActionForBlock(block, action, payload)
798
- root.render(<GenuiActionContext.Provider value={handler}>{payload}</GenuiActionContext.Provider>)
933
+ root.render(wrapFenceNode(payload, context, handler, !settled))
799
934
  } catch (error) {
800
935
  try {
801
936
  root.unmount()
@@ -868,7 +1003,9 @@ export function installDomFenceRenderer(
868
1003
  function sweep(): void {
869
1004
  if (disposed) return
870
1005
  sourceLanguages.beginSweep()
871
- const sessionId = sessionIdOf()
1006
+ const sourceSession = sourceSessionOf(ctx)
1007
+ const sessionId = sourceSession.available ? sourceSession.sessionId : undefined
1008
+ if (sourceSession.available) lastAvailableSessionId = sourceSession.sessionId
872
1009
  syncChatSubscription(sessionId)
873
1010
  for (const [block, mount] of mounts) {
874
1011
  if (!block.isConnected) {
@@ -877,16 +1014,18 @@ export function installDomFenceRenderer(
877
1014
  }
878
1015
  const raw = rawOf(block)
879
1016
  const settled = isSettled(block)
880
- const domLanguage = domLanguageOf(block)
1017
+ const domLanguage = tagTextBodyOf(block) !== null ? 'dsh-ui' : domLanguageOf(block)
881
1018
  const sourceLanguage = domLanguage === null ? sourceLanguages.get(block) : undefined
882
1019
  const language = domLanguage ?? sourceLanguage
1020
+ const sourceUnavailable = domLanguage === null
1021
+ && (!sourceSession.available || sourceLanguages.isSessionUnavailable())
883
1022
  const validGenui = language === 'dsh-ui'
884
1023
  || (language === undefined && settled && isGenericGenuiFence(block, raw))
885
1024
  if (mount.language === 'svg' && language !== 'svg') {
886
1025
  unmountBlock(block)
887
1026
  continue
888
1027
  }
889
- if (mount.language === 'dsh-ui' && !validGenui) {
1028
+ if (mount.language === 'dsh-ui' && !validGenui && !sourceUnavailable) {
890
1029
  unmountBlock(block)
891
1030
  continue
892
1031
  }
@@ -897,7 +1036,7 @@ export function installDomFenceRenderer(
897
1036
  const contentWiped = !isPanelRoot(mount.lastNode) && mount.container.childElementCount === 0
898
1037
  if (mount.lastRaw !== raw || mount.lastSettled !== settled || contentWiped) {
899
1038
  const anchor = rowOf(block)
900
- const { key, context } = contextOf(anchor, block, settled)
1039
+ const { key, context } = contextOf(anchor, block, settled, true)
901
1040
  const node = mount.language === 'svg' ? renderSvgFence(raw, key) : renderResolvedFenceNode(raw, key, context)
902
1041
  if (node === null) {
903
1042
  if (mount.skeleton && !settled) {
@@ -929,7 +1068,7 @@ export function installDomFenceRenderer(
929
1068
  try {
930
1069
  const freshRoot = domRootFactory(fresh)
931
1070
  const handler: GenuiActionHandler = (action, payload) => sendActionForBlock(block, action, payload)
932
- freshRoot.render(<GenuiActionContext.Provider value={handler}>{node}</GenuiActionContext.Provider>)
1071
+ freshRoot.render(wrapFenceNode(node, context, handler, !settled))
933
1072
  mount.root = freshRoot
934
1073
  mount.container = fresh
935
1074
  } catch (error) {
@@ -944,7 +1083,8 @@ export function installDomFenceRenderer(
944
1083
  }
945
1084
  } else {
946
1085
  try {
947
- mount.root.render(<GenuiActionContext.Provider value={(action, payload) => sendActionForBlock(block, action, payload)}>{node}</GenuiActionContext.Provider>)
1086
+ const handler: GenuiActionHandler = (action, payload) => sendActionForBlock(block, action, payload)
1087
+ mount.root.render(wrapFenceNode(node, context, handler, !settled))
948
1088
  } catch (error) {
949
1089
  // Never leave the stock block hidden behind a broken root: restore
950
1090
  // the raw code block and drop the mount (issue #19).
@@ -972,7 +1112,7 @@ export function installDomFenceRenderer(
972
1112
  // label is no longer dsh-ui is somebody else's fence, so our explanation
973
1113
  // would be about the wrong block.
974
1114
  if (isSettled(block)) {
975
- const domLanguage = domLanguageOf(block)
1115
+ const domLanguage = tagTextBodyOf(block) !== null ? 'dsh-ui' : domLanguageOf(block)
976
1116
  const sourceLanguage = domLanguage === null ? sourceLanguages.get(block) : undefined
977
1117
  if ((domLanguage ?? sourceLanguage) !== 'dsh-ui') {
978
1118
  clearDiagnostic(block)
@@ -0,0 +1,37 @@
1
+ const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
2
+
3
+ /** Encode one address segment while keeping a Windows drive colon readable. */
4
+ function encodeSegment(segment: string): string {
5
+ return encodeURIComponent(segment).replace(/%3A/gi, ':')
6
+ }
7
+
8
+ /** Encode each path segment independently so separators retain their structure. */
9
+ function encodePath(path: string): string {
10
+ return path.split('/').map(encodeSegment).join('/')
11
+ }
12
+
13
+ /** Check whether a path uses a Windows drive or UNC spelling. */
14
+ function isWindowsStylePath(value: string): boolean {
15
+ return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\')
16
+ }
17
+
18
+ /** Check whether a path is absolute in a spelling accepted by the host. */
19
+ function isAbsoluteWorkspacePath(path: string): boolean {
20
+ return path.startsWith('/') || isWindowsStylePath(path)
21
+ }
22
+
23
+ /** Build a session resource address while preserving encoded path segments. */
24
+ export function sessionFileAddress(sessionId: string, path: string): string {
25
+ const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
26
+ return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`
27
+ }
28
+
29
+ /** Resolve workspace absolute paths to session-relative resource addresses. */
30
+ export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string {
31
+ const normalized = path.replace(/\\/g, '/')
32
+ if (!isAbsoluteWorkspacePath(normalized)) return sessionFileAddress(sessionId, normalized)
33
+ const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '')
34
+ if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '')
35
+ if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
36
+ return sessionFileAddress(sessionId, normalized)
37
+ }