@changfenhuang/dsh-genui 0.11.4 → 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 (45) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +12 -4
  3. package/README.zh-CN.md +12 -4
  4. package/SKILL.md +2 -2
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +9 -9
  7. package/lib/client.js +30 -30
  8. package/lib/index.js +101 -14
  9. package/lib/types/client/dom-fence.d.ts +2 -1
  10. package/lib/types/client/file-address.d.ts +4 -0
  11. package/lib/types/client/file-link-context.d.ts +41 -0
  12. package/lib/types/client/file-link.d.ts +17 -0
  13. package/lib/types/client/i18n/en.d.ts +5 -1
  14. package/lib/types/client/inline.d.ts +1 -1
  15. package/lib/types/client/mermaid-colors.d.ts +2 -0
  16. package/lib/types/client/panel.d.ts +6 -1
  17. package/lib/types/plugin/fence-feedback.d.ts +6 -2
  18. package/lib/types/plugin/index.d.ts +4 -0
  19. package/lib/types/shared/fence-repair.d.ts +1 -0
  20. package/package.json +1 -12
  21. package/src/client/GenuiBlock.module.css +35 -3
  22. package/src/client/artifact/ArtifactExport.module.css +34 -3
  23. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  24. package/src/client/blocks/charts.tsx +22 -6
  25. package/src/client/blocks/render-node.tsx +10 -7
  26. package/src/client/dom-fence.tsx +97 -30
  27. package/src/client/file-address.ts +37 -0
  28. package/src/client/file-link-context.tsx +114 -0
  29. package/src/client/file-link.ts +46 -0
  30. package/src/client/guard.ts +5 -2
  31. package/src/client/i18n/en.ts +5 -1
  32. package/src/client/i18n/zh.ts +5 -1
  33. package/src/client/index.tsx +23 -5
  34. package/src/client/inline.ts +17 -6
  35. package/src/client/mermaid-colors.ts +39 -0
  36. package/src/client/mermaid-core.ts +7 -3
  37. package/src/client/panel.tsx +18 -5
  38. package/src/plugin/fence-feedback.ts +17 -5
  39. package/src/plugin/index.ts +14 -4
  40. package/src/plugin/tool.ts +8 -2
  41. package/src/shared/fence-repair.ts +36 -0
  42. package/tsdown.config.ts +3 -7
  43. package/lib/invariant.js +0 -19
  44. package/lib/types/plugin/invariant.d.ts +0 -15
  45. package/src/plugin/invariant.ts +0 -30
@@ -13,6 +13,17 @@ interface ArtifactExportMenuProps {
13
13
  getArtifact: () => ReturnType<typeof createGenuiArtifact>
14
14
  }
15
15
 
16
+ /** 导出入口的图标字形:托盘 + 下箭头(“导出为文件”),14px 描边、随文字取色。 */
17
+ function ExportGlyph() {
18
+ return (
19
+ <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
20
+ <path d="M8 2.2v7.1" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
21
+ <path d="M5 6.4 8 9.4l3-3" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
22
+ <path d="M2.9 10.9v1.5a1 1 0 0 0 1 1h8.2a1 1 0 0 0 1-1v-1.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
23
+ </svg>
24
+ )
25
+ }
26
+
16
27
  /** 提供 JSON 和 HTML 的本地化导出菜单。 */
17
28
  function ArtifactExportMenu({ getArtifact }: ArtifactExportMenuProps) {
18
29
  const t = useT()
@@ -65,8 +76,18 @@ function ArtifactExportMenu({ getArtifact }: ArtifactExportMenuProps) {
65
76
  <div className={css.chrome} ref={rootRef} data-genui-export>
66
77
  {report.externalMedia.length > 0 && <span className={css.notice}>{t('artifact.externalMediaNotice')}</span>}
67
78
  {errorMessage !== '' && <span className={css.statusError} aria-live="polite">{errorMessage}</span>}
68
- <button type="button" className={css.trigger} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(value => !value)}>
69
- {t('artifact.export')}
79
+ {/* 无边框图标入口:完成态内容不再多出一个描边按钮。图标按钮的 aria-label
80
+ + title 组合与项目里的 panelTpl / panelToggle 一致。 */}
81
+ <button
82
+ type="button"
83
+ className={css.trigger}
84
+ aria-label={t('artifact.export')}
85
+ title={t('artifact.export')}
86
+ aria-haspopup="menu"
87
+ aria-expanded={open}
88
+ onClick={() => setOpen(value => !value)}
89
+ >
90
+ <ExportGlyph />
70
91
  </button>
71
92
  {open && (
72
93
  <div className={css.menu} role="menu">
@@ -345,7 +345,7 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
345
345
  .join(' ') || undefined}
346
346
  >
347
347
  {type === 'badge'
348
- ? <span className={css.cellBadge}>{renderInline(String(cell), false)}</span>
348
+ ? <span className={css.cellBadge}>{renderInline(String(cell), 'file')}</span>
349
349
  : type === 'bar'
350
350
  ? <CellBar cell={cell} />
351
351
  : type === 'spark'
@@ -356,8 +356,8 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
356
356
  ? <span className={css.cellIndex}>{rowIndex + 1}</span>
357
357
  : tone === null
358
358
  // Format displayed text without changing sorting or exported data.
359
- ? renderInline(String(cell), false)
360
- : <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell), false)}</span>}
359
+ ? renderInline(String(cell), 'file')
360
+ : <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell), 'file')}</span>}
361
361
  </td>
362
362
  )
363
363
  }
@@ -396,13 +396,21 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
396
396
  <button
397
397
  type="button"
398
398
  className={css.groupToggle}
399
+ aria-label={tr(isCollapsed ? 'block.table.expandGroup' : 'block.table.collapseGroup', {
400
+ label: String(section.header.row[0]).replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
401
+ })}
399
402
  aria-expanded={!isCollapsed}
400
403
  onClick={() => toggleSection(headerIndex)}
401
404
  >
402
405
  <span className={css.groupChevron} aria-hidden>{isCollapsed ? '▸' : '▾'}</span>
403
- {renderInline(String(section.header.row[0]), false)}
404
- <span className={css.groupCount}>{section.children.length}</span>
405
406
  </button>
407
+ <span className={css.groupLabel} onClick={event => {
408
+ if (event.target instanceof Element && event.target.closest('button')) return
409
+ toggleSection(headerIndex)
410
+ }}>
411
+ {renderInline(String(section.header.row[0]), 'file')}
412
+ <span className={css.groupCount}>{section.children.length}</span>
413
+ </span>
406
414
  </td>
407
415
  </tr>
408
416
  )}
@@ -419,12 +427,20 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
419
427
  <button
420
428
  type="button"
421
429
  className={css.detailToggle}
430
+ aria-label={tr(open ? 'block.table.collapseDetails' : 'block.table.expandDetails', {
431
+ label: String(cell).replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
432
+ })}
422
433
  aria-expanded={open}
423
434
  onClick={() => toggleDetail(child.index)}
424
435
  >
425
436
  <span className={css.detailChevron} data-open={open} aria-hidden>▸</span>
426
- {renderInline(String(cell), false)}
427
437
  </button>
438
+ <span className={css.detailContent} onClick={event => {
439
+ if (event.target instanceof Element && event.target.closest('button')) return
440
+ toggleDetail(child.index)
441
+ }}>
442
+ {renderInline(String(cell), 'file')}
443
+ </span>
428
444
  </td>
429
445
  )
430
446
  : renderCell(cell, j, child.index)
@@ -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} />
@@ -58,6 +58,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
58
58
  import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
59
59
  import type { SessionId } from '@deepseek-ai/dsh-session/types'
60
60
  import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
61
+ import { withFileLinkContext, type FileLinkAvailability, type FileLinkCordisContext } from './file-link-context.tsx'
61
62
  import css from './GenuiBlock.module.css'
62
63
  import { renderSvgFence } from './svg-fence.tsx'
63
64
  import { repairFenceJson, trimToBalancedRoot } from '../shared/fence-repair.ts'
@@ -68,13 +69,27 @@ import { diagnoseUnknownGenuiFields } from './genui-runtime/diagnostics.ts'
68
69
  import { normalizeGenuiSpec } from './genui-runtime/normalize.ts'
69
70
  import { sourceFencesOfAssistant, sourceLanguageAt } from './source-fence.ts'
70
71
 
71
- /** Fence surfaces the channel can take over, newest host first: the shared
72
- * CodeBlock surface every rc.6+ markdown fence renders through
73
- * (`.md-code-block`) and the deepsuite-style surfaces some host builds
74
- * render instead (`.code-block` / `.code-block-small`). Surfaces with an
75
- * unlisted class are still found by the structural backstop (label + `<pre>`),
76
- * so this list is an optimization, not a hard contract. */
72
+ /** 优先用宿主代码块内容的语义标记定位包装节点;隐藏语言或改变类名不能阻断
73
+ * 原始消息的围栏识别。类名也用于仍以这些表面呈现的代码块。 */
77
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
+ }
78
93
  /** Marker attribute set on blocks this channel has taken over. */
79
94
  const PROCESSED = 'data-genui-rendered'
80
95
  /** The settled marker on AssistantMarkdown (absent = settled). */
@@ -86,6 +101,10 @@ const DIAGNOSTIC_CLASS = 'genui-dom-fence-diagnostic'
86
101
  /** Slow sweep interval: the observer catches everything, this is the 1s
87
102
  * belt-and-braces pass (history loads, missed attribute batches). */
88
103
  const SWEEP_MS = 1000
104
+ const NO_FILE_LINK_AVAILABILITY: FileLinkAvailability = {
105
+ subscribe: () => () => {},
106
+ getSnapshot: () => false,
107
+ }
89
108
 
90
109
  /** Max ancestors walked from a `<pre>` to its fence surface root (banner +
91
110
  * pre holder). Most hosts put the pre directly under the surface; some wrap
@@ -196,7 +215,7 @@ function infostringOf(block: Element): 'dsh-ui' | 'svg' | null {
196
215
  // surface's banner, not `block`'s own banner. Only accept labels whose
197
216
  // nearest known surface is `block` itself (or none — unknown surfaces
198
217
  // stay supported by the structural backstop).
199
- const owner = el.closest(CODE_BLOCK_SELECTORS)
218
+ const owner = closestCodeBlockSurface(el)
200
219
  if (owner !== null && owner !== block) continue
201
220
  return lang
202
221
  }
@@ -350,11 +369,11 @@ function implausibleLabeledAncestorOf(pre: HTMLElement, scope: ParentNode = docu
350
369
  function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
351
370
  const seen = new Set<HTMLElement>()
352
371
  const out: HTMLElement[] = []
353
- for (const el of scope.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTORS)) {
372
+ for (const el of codeBlockSurfaces(scope)) {
354
373
  // Modifier classes can sit inside a surface (e.g. a `code-block-small`
355
374
  // child of `code-block`): only the outermost matching element is a
356
375
  // candidate, so a fence is never double-counted or taken over twice.
357
- if (el.parentElement !== null && el.parentElement.closest(CODE_BLOCK_SELECTORS) !== null) continue
376
+ if (closestCodeBlockSurface(el.parentElement) !== null) continue
358
377
  if (el.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui-svg-fence]`) !== null) continue
359
378
  if (seen.has(el)) continue
360
379
  // Message-level containers that happen to carry a surface class must
@@ -373,7 +392,8 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
373
392
  // markdown root holding both a dsh-ui fence and a python block — and
374
393
  // the backstop would mislabel that whole container as a fence, hiding
375
394
  // every other code block with it (issue #13).
376
- 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
377
397
  const surface = surfaceOf(pre, scope)
378
398
  if (surface === null) {
379
399
  // Diagnose the issue #19 guard: a labeled ancestor that is NOT a code
@@ -394,7 +414,8 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
394
414
  seen.add(surface)
395
415
  }
396
416
  for (const span of scope.querySelectorAll<HTMLElement>('span')) {
397
- if (span.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, ${CODE_BLOCK_SELECTORS}, [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
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
398
419
  if (tagTextBodyOf(span) === null) continue
399
420
  out.push(span)
400
421
  }
@@ -476,8 +497,8 @@ function fenceIndexOf(ctx: Context, row: Element, block: Element, sourceLanguage
476
497
 
477
498
  /** Return outer host Markdown code surfaces in one assistant row's DOM order. */
478
499
  function hostFenceBlocksOf(row: Element): HTMLElement[] {
479
- return [...row.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTORS)].filter(candidate => {
480
- if (candidate.parentElement?.closest(CODE_BLOCK_SELECTORS) !== null) return false
500
+ return codeBlockSurfaces(row).filter(candidate => {
501
+ if (closestCodeBlockSurface(candidate.parentElement) !== null) return false
481
502
  if (candidate.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui], [data-genui-svg-fence]`) !== null) return false
482
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
483
504
  if (candidate.closest(ASSISTANT_FLOW_ROW) !== row) return false
@@ -504,13 +525,18 @@ export function sourceLanguageOf(ctx: Context, block: Element): string | null |
504
525
  }
505
526
 
506
527
  /** Cache each row's Markdown parse for one sweep and retain confirmed opening-line languages per host block. */
507
- 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
+ } {
508
533
  const stableLanguages = new WeakMap<Element, { sessionId: SessionId; nodeKey: string; language: string | null }>()
509
534
  let sweepLanguages = new Map<Element, string | null | undefined>()
510
535
  let parsedNodes = new Map<string, ReturnType<typeof sourceFencesOfAssistant>>()
511
536
  let sessionId: SessionId | undefined
512
537
  let chat: ChatSnapshot | undefined
513
538
  let chatRead = false
539
+ let sessionUnavailable = false
514
540
 
515
541
  return {
516
542
  beginSweep() {
@@ -519,13 +545,23 @@ function createSourceLanguageResolver(ctx: Context): { beginSweep: () => void; g
519
545
  sessionId = undefined
520
546
  chat = undefined
521
547
  chatRead = false
548
+ sessionUnavailable = false
549
+ },
550
+ isSessionUnavailable() {
551
+ return sessionUnavailable
522
552
  },
523
553
  get(block) {
524
554
  if (sweepLanguages.has(block)) return sweepLanguages.get(block)
525
555
  const row = block.closest<HTMLElement>(`${ASSISTANT_FLOW_ROW}[data-chat-node-key]`)
526
556
  if (row === null || row.dataset.chatGroupPart === 'reasoning') return undefined
527
557
  const nodeKey = row.dataset.chatNodeKey
528
- 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
529
565
  if (!nodeKey || activeSessionId === undefined) return undefined
530
566
 
531
567
  const stable = stableLanguages.get(block)
@@ -570,9 +606,19 @@ export function hostFenceIndexOf(row: Element, block: Element): number {
570
606
  return hostFenceBlocksOf(row).indexOf(block as HTMLElement)
571
607
  }
572
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
+
573
618
  /** Read the currently viewed session for source Markdown lookup. */
574
619
  function sessionIdOfForSource(ctx: Context): SessionId | undefined {
575
- return resolveViewedSessionId(ctx.sessions.list.getSnapshot())
620
+ const sourceSession = sourceSessionOf(ctx)
621
+ return sourceSession.available ? sourceSession.sessionId : undefined
576
622
  }
577
623
 
578
624
  /** Read uiConversation as an optional service so Cordis does not require a hard inject. */
@@ -650,6 +696,7 @@ function anchorSeqOf(row: Element): number {
650
696
  export function installDomFenceRenderer(
651
697
  ctx: Context,
652
698
  sendAction: (sessionId: SessionId, action: string, payload: Record<string, unknown>) => void,
699
+ fileLinkAvailability: FileLinkAvailability = NO_FILE_LINK_AVAILABILITY,
653
700
  ): () => void {
654
701
  if (typeof document === 'undefined') return () => {}
655
702
  driftWarned = false
@@ -665,12 +712,27 @@ export function installDomFenceRenderer(
665
712
  let unsubscribeChat: (() => void) | undefined
666
713
  const sourceLanguages = createSourceLanguageResolver(ctx)
667
714
 
668
- const sessionIdOf = (): SessionId | undefined => {
669
- try {
670
- return resolveViewedSessionId(ctx.sessions.list.getSnapshot())
671
- } catch {
672
- return undefined
673
- }
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
674
736
  }
675
737
 
676
738
  /** Retry ChatSnapshot subscription while the active session binding is unavailable. */
@@ -688,7 +750,7 @@ export function installDomFenceRenderer(
688
750
  /** Render context for a block: session always; the stable source identity
689
751
  * only once settled — streaming renders are identity-less (no panel
690
752
  * publish, no durable state), mirroring the registry channel. */
691
- 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 } {
692
754
  if (settled && row.getAttribute('data-chat-anchor-key') === null) {
693
755
  // Safari / fallback render path: the host omitted the row anchor (the
694
756
  // attribute is a React key that React drops when undefined). Fences
@@ -699,7 +761,7 @@ export function installDomFenceRenderer(
699
761
  const fenceIndex = fenceIndexOf(ctx, row, block, sourceLanguages.get)
700
762
  const anchorKey = row.getAttribute('data-chat-anchor-key') ?? 'unknown'
701
763
  const key = `dom:${anchorKey}:${fenceIndex}` as Key
702
- const sessionId = sessionIdOf()
764
+ const sessionId = sessionIdForRender(preserveSessionIdentity)
703
765
  const context: GenuiFenceContext = {
704
766
  ...(sessionId === undefined ? {} : { sessionId }),
705
767
  ...(settled ? { source: { id: key as string, order: [anchorSeqOf(row), 0, fenceIndex] as const } } : {}),
@@ -868,7 +930,7 @@ export function installDomFenceRenderer(
868
930
  }
869
931
  try {
870
932
  const handler: GenuiActionHandler = (action, payload) => sendActionForBlock(block, action, payload)
871
- root.render(<GenuiActionContext.Provider value={handler}>{payload}</GenuiActionContext.Provider>)
933
+ root.render(wrapFenceNode(payload, context, handler, !settled))
872
934
  } catch (error) {
873
935
  try {
874
936
  root.unmount()
@@ -941,7 +1003,9 @@ export function installDomFenceRenderer(
941
1003
  function sweep(): void {
942
1004
  if (disposed) return
943
1005
  sourceLanguages.beginSweep()
944
- const sessionId = sessionIdOf()
1006
+ const sourceSession = sourceSessionOf(ctx)
1007
+ const sessionId = sourceSession.available ? sourceSession.sessionId : undefined
1008
+ if (sourceSession.available) lastAvailableSessionId = sourceSession.sessionId
945
1009
  syncChatSubscription(sessionId)
946
1010
  for (const [block, mount] of mounts) {
947
1011
  if (!block.isConnected) {
@@ -953,13 +1017,15 @@ export function installDomFenceRenderer(
953
1017
  const domLanguage = tagTextBodyOf(block) !== null ? 'dsh-ui' : domLanguageOf(block)
954
1018
  const sourceLanguage = domLanguage === null ? sourceLanguages.get(block) : undefined
955
1019
  const language = domLanguage ?? sourceLanguage
1020
+ const sourceUnavailable = domLanguage === null
1021
+ && (!sourceSession.available || sourceLanguages.isSessionUnavailable())
956
1022
  const validGenui = language === 'dsh-ui'
957
1023
  || (language === undefined && settled && isGenericGenuiFence(block, raw))
958
1024
  if (mount.language === 'svg' && language !== 'svg') {
959
1025
  unmountBlock(block)
960
1026
  continue
961
1027
  }
962
- if (mount.language === 'dsh-ui' && !validGenui) {
1028
+ if (mount.language === 'dsh-ui' && !validGenui && !sourceUnavailable) {
963
1029
  unmountBlock(block)
964
1030
  continue
965
1031
  }
@@ -970,7 +1036,7 @@ export function installDomFenceRenderer(
970
1036
  const contentWiped = !isPanelRoot(mount.lastNode) && mount.container.childElementCount === 0
971
1037
  if (mount.lastRaw !== raw || mount.lastSettled !== settled || contentWiped) {
972
1038
  const anchor = rowOf(block)
973
- const { key, context } = contextOf(anchor, block, settled)
1039
+ const { key, context } = contextOf(anchor, block, settled, true)
974
1040
  const node = mount.language === 'svg' ? renderSvgFence(raw, key) : renderResolvedFenceNode(raw, key, context)
975
1041
  if (node === null) {
976
1042
  if (mount.skeleton && !settled) {
@@ -1002,7 +1068,7 @@ export function installDomFenceRenderer(
1002
1068
  try {
1003
1069
  const freshRoot = domRootFactory(fresh)
1004
1070
  const handler: GenuiActionHandler = (action, payload) => sendActionForBlock(block, action, payload)
1005
- freshRoot.render(<GenuiActionContext.Provider value={handler}>{node}</GenuiActionContext.Provider>)
1071
+ freshRoot.render(wrapFenceNode(node, context, handler, !settled))
1006
1072
  mount.root = freshRoot
1007
1073
  mount.container = fresh
1008
1074
  } catch (error) {
@@ -1017,7 +1083,8 @@ export function installDomFenceRenderer(
1017
1083
  }
1018
1084
  } else {
1019
1085
  try {
1020
- 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))
1021
1088
  } catch (error) {
1022
1089
  // Never leave the stock block hidden behind a broken root: restore
1023
1090
  // the raw code block and drop the mount (issue #19).
@@ -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
+ }
@@ -0,0 +1,114 @@
1
+ import { createContext, createElement, useContext, useMemo, useSyncExternalStore, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
2
+ import { fileAddressFor } from './file-address.ts'
3
+ import { resolveViewedSessionId } from './session-resolver.ts'
4
+ import css from './GenuiBlock.module.css'
5
+
6
+ interface SidebarRightService {
7
+ openResource(address: string, options?: { params: { line: number } }): void
8
+ }
9
+
10
+ interface FileLinkHostContext {
11
+ get(name: 'sidebarRight', strict?: false): SidebarRightService | undefined
12
+ sessions: {
13
+ list: { getSnapshot(): unknown }
14
+ }
15
+ }
16
+
17
+ interface FileLinkScope {
18
+ get(name: 'sidebarRight'): SidebarRightService | undefined
19
+ effect(callback: () => void | (() => void), name?: string): unknown
20
+ }
21
+
22
+ export interface FileLinkCordisContext extends FileLinkHostContext {
23
+ inject(services: ['sidebarRight'], callback: (scope: FileLinkScope) => void): unknown
24
+ }
25
+
26
+ export interface FileLinkAvailability {
27
+ subscribe(listener: () => void): () => void
28
+ getSnapshot(): boolean
29
+ }
30
+
31
+ const FileLinkOpenContext = createContext<((path: string, line?: number) => void) | undefined>(undefined)
32
+
33
+ /** Track an optional host service through arrival, removal, and plugin unload. */
34
+ export function createFileLinkAvailability(ctx: FileLinkCordisContext): FileLinkAvailability {
35
+ let available = ctx.get('sidebarRight', false)?.openResource !== undefined
36
+ const listeners = new Set<() => void>()
37
+ const notify = (): void => {
38
+ for (const listener of listeners) listener()
39
+ }
40
+ const setAvailable = (next: boolean): void => {
41
+ if (available === next) return
42
+ available = next
43
+ notify()
44
+ }
45
+
46
+ ctx.inject(['sidebarRight'], (scope) => {
47
+ scope.effect(() => {
48
+ setAvailable(scope.get('sidebarRight')?.openResource !== undefined)
49
+ return () => setAvailable(false)
50
+ }, 'genui: file-link sidebarRight')
51
+ })
52
+
53
+ return {
54
+ subscribe(listener) {
55
+ listeners.add(listener)
56
+ return () => { listeners.delete(listener) }
57
+ },
58
+ getSnapshot: () => available,
59
+ }
60
+ }
61
+
62
+ /** Provide one fence's session-scoped file opener to descendant components. */
63
+ function FileLinkProvider({ ctx, availability, sessionId, streaming, children }: {
64
+ ctx: FileLinkHostContext
65
+ availability: FileLinkAvailability
66
+ sessionId: string | undefined
67
+ streaming: boolean
68
+ children?: ReactNode
69
+ }) {
70
+ const available = useSyncExternalStore(availability.subscribe, availability.getSnapshot, () => false)
71
+ const open = useMemo(() => {
72
+ if (!available || streaming || sessionId === undefined) return undefined
73
+ return (path: string, line?: number): void => {
74
+ const snapshot = ctx.sessions.list.getSnapshot()
75
+ if (resolveViewedSessionId(snapshot) !== sessionId) return
76
+ const sessions = snapshot as { byId?: Record<string, { cwd?: string } | undefined> }
77
+ const cwd = sessions.byId?.[sessionId]?.cwd
78
+ const address = fileAddressFor(sessionId, cwd, path)
79
+ const sidebar = ctx.get('sidebarRight', false)
80
+ if (sidebar?.openResource === undefined) return
81
+ if (line === undefined) sidebar.openResource(address)
82
+ else sidebar.openResource(address, { params: { line } })
83
+ }
84
+ }, [available, ctx, sessionId, streaming])
85
+
86
+ return createElement(FileLinkOpenContext.Provider, { value: open }, children)
87
+ }
88
+
89
+ /** Render a file link as an accessible host navigation button when available. */
90
+ export function FileLink({ path, line, label, children }: { path: string; line?: number; label: string; children?: ReactNode }) {
91
+ const open = useContext(FileLinkOpenContext)
92
+ if (open === undefined) return createElement('span', { title: path }, children)
93
+ return createElement('button', {
94
+ type: 'button',
95
+ className: css.inlineFileLink,
96
+ title: path,
97
+ 'aria-label': `${label} (${path})`,
98
+ onClick: (event: ReactMouseEvent<HTMLButtonElement>) => {
99
+ event.stopPropagation()
100
+ open(path, line)
101
+ },
102
+ }, children)
103
+ }
104
+
105
+ /** Add session-scoped file navigation to one fence render tree. */
106
+ export function withFileLinkContext(
107
+ ctx: FileLinkHostContext,
108
+ availability: FileLinkAvailability,
109
+ sessionId: string | undefined,
110
+ streaming: boolean,
111
+ children: ReactNode,
112
+ ): ReactNode {
113
+ return createElement(FileLinkProvider, { ctx, availability, sessionId, streaming, children })
114
+ }
@@ -0,0 +1,46 @@
1
+ import { safeHref } from './genui-runtime/value-utils.ts'
2
+
3
+ export interface ParsedFileLink {
4
+ path: string
5
+ line?: number
6
+ }
7
+
8
+ export type LinkTarget =
9
+ | { type: 'external'; href: string }
10
+ | { type: 'file'; file: ParsedFileLink }
11
+ | { type: 'invalid' }
12
+
13
+ /** Decode a local file destination and its optional first line fragment. */
14
+ export function parseFileLink(value: string): ParsedFileLink | undefined {
15
+ if (value.length > 2048) return undefined
16
+ const hash = value.indexOf('#')
17
+ const destination = hash < 0 ? value : value.slice(0, hash)
18
+ if (destination.includes('?')) return undefined
19
+
20
+ let path: string
21
+ try {
22
+ path = decodeURIComponent(destination)
23
+ } catch {
24
+ return undefined
25
+ }
26
+
27
+ if (path === '' || /[\u0000-\u001f\u007f]/.test(path) || /^[\\/]{2}/.test(path)
28
+ || (/^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path))) return undefined
29
+ if (hash < 0) return { path }
30
+
31
+ const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(value.slice(hash + 1))
32
+ if (match === null) return undefined
33
+ const line = Number(match[1])
34
+ const end = match[2] === undefined ? line : Number(match[2])
35
+ if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return undefined
36
+ return { path, line }
37
+ }
38
+
39
+ /** Classify a link destination while preserving the existing external URL policy. */
40
+ export function classifyLinkTarget(value: unknown): LinkTarget {
41
+ const external = safeHref(value)
42
+ if (external !== undefined) return { type: 'external', href: external }
43
+ if (typeof value !== 'string') return { type: 'invalid' }
44
+ const file = parseFileLink(value.trim())
45
+ return file === undefined ? { type: 'invalid' } : { type: 'file', file }
46
+ }
@@ -33,7 +33,8 @@ import type { GenuiDiagnostic } from './genui-runtime/diagnostics.ts'
33
33
  import { GENUI_LIMITS } from './genui-runtime/limits.ts'
34
34
  import { analyzeSubmissionRegistry } from './submission-registry.ts'
35
35
  import { isTableDetailReachable, prepareTableRows, tableRowsForDetails } from './table-details.ts'
36
- import { color, enu, int, num, obj, opt, safeHref, safeMediaSrc, str } from './genui-runtime/value-utils.ts'
36
+ import { color, enu, int, num, obj, opt, safeMediaSrc, str } from './genui-runtime/value-utils.ts'
37
+ import { classifyLinkTarget } from './file-link.ts'
37
38
 
38
39
  /** Result of `validateGenuiSpec`. */
39
40
  export interface GenuiValidation {
@@ -336,7 +337,9 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
336
337
  case 'link': {
337
338
  const label = str(v.label, GENUI_LIMITS.maxString)
338
339
  if (label === undefined) return null
339
- return { type: 'link', label, ...opt('href', safeHref(v.href)) }
340
+ const target = classifyLinkTarget(v.href)
341
+ const href = target.type === 'external' ? target.href : target.type === 'file' ? String(v.href).trim() : undefined
342
+ return { type: 'link', label, ...opt('href', href) }
340
343
  }
341
344
  case 'image': {
342
345
  const src = safeMediaSrc(v.src)