Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [0.8.4] - 2026-08-16
### 修复
- **genui 与普通代码块共存时整条消息被吞(issue #13)**:同一消息容器里 `dsh-ui` 围栏和 python/ts/bash 等普通代码块共存时,DOM 通道的结构兜底从普通代码块的 `<pre>` 向上回溯,越过它自己的 `.md-code-block` 把共享的 `.markdown` 根容器误判为「dsh-ui 围栏」→ 整条消息 `display:none`、只剩 GenUI,普通代码块丢失。修复两层:① 兜底循环跳过已由已知表面选择器命中的 `<pre>`(这些块已处理,不再向上回溯);② 标签判定不认领「属于嵌套已知代码块」的 banner(`owner !== block` 即跳过),共享容器不能再通过嵌套围栏的标签自证。未知类名表面的结构兜底能力保持不变(回归测试覆盖:未知表面 + 已知 python 块并存时仍照常渲染)
### 测试
- 275 → 278(+3 issue #13 回归:dsh-ui + python 并存时 dsh-ui 正常接管、python 与共享根容器不被隐藏/接管且无漂移误报;同根两个 dsh-ui 块各自渲染、面板 fold 以第二个为准;未知表面 + 已知 python 块并存时兜底仍生效)

## [0.8.3] - 2026-08-14
### 修复
- **DOM 通道在异形宿主上静默不渲染(issue #6)**:DOM 通道的围栏发现此前依赖单一表面契约——选择器只认 `.md-code-block`,语言标签只认 banner 里的**叶子 `div`**。部分 DSH 0.1.0-rc.6 部署(deepsuite 风格渲染栈)把围栏渲染成 `.code-block` / `.code-block-small`,标签是 `span`,正文还可能被 content div 包裹 → 插件完全找不到围栏:保持代码块、控制台零报错(与报告完全一致)。修复为**多表面发现**:
Expand Down
6 changes: 3 additions & 3 deletions lib/client.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@omdsh-dev/dsh-genui",
"description": "GenUI for DeepSeek Harness: interactive UI components rendered inline in assistant replies via the ```dsh-ui fence — layout, charts, plots, forms, quizzes, mermaid, 3D scenes, and an action event loop back to the model. Ships the fence-teaching host plugin, the browser renderer (client half), and the genui skill.",
"version": "0.8.3",
"version": "0.8.4",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/plugin/index.d.ts",
Expand Down
18 changes: 17 additions & 1 deletion src/client/dom-fence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,22 @@ function isTextNode(node: Node): node is Text {
* surfaces use a span; the ONLY structural invariants across hosts are "a
* leaf element holds exactly the lang text" and "it lives outside the code
* body" — a fence whose code literally contains the text `dsh-ui` must not
* self-identify through its body. */
* self-identify through its body. A container holding SEVERAL code blocks
* must not self-identify through a nested block's label either (issue #13:
* the shared markdown root was mistaken for a dsh-ui fence and hid the whole
* message, losing every other code block). */
function infostringOf(block: Element): string | null {
const pre = block.querySelector('pre')
for (const el of block.querySelectorAll('*')) {
if (el.childElementCount !== 0) continue
if (el.textContent !== 'dsh-ui') continue
if (pre !== null && pre.contains(el)) continue
// A leaf label that belongs to a NESTED known code surface is that
// surface's banner, not `block`'s own banner. Only accept labels whose
// nearest known surface is `block` itself (or none — unknown surfaces
// stay supported by the structural backstop).
const owner = el.closest(CODE_BLOCK_SELECTORS)
if (owner !== null && owner !== block) continue
return 'dsh-ui'
}
return null
Expand Down Expand Up @@ -165,6 +174,13 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
seen.add(el)
}
for (const pre of scope.querySelectorAll<HTMLElement>('pre')) {
// `<pre>` bodies inside a known surface were already handled by the
// selector pass. Walking up from them again would climb PAST their own
// (non-dsh-ui or dsh-ui) surface into a shared container — e.g. a
// markdown root holding both a dsh-ui fence and a python block — and
// the backstop would mislabel that whole container as a fence, hiding
// every other code block with it (issue #13).
if (pre.closest(CODE_BLOCK_SELECTORS) !== null) continue
const surface = surfaceOf(pre, scope)
if (surface === null || seen.has(surface)) continue
// Host DOM drift diagnostic: the fence renders (structural backstop),
Expand Down
103 changes: 103 additions & 0 deletions tests/dom-fence.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -685,3 +685,106 @@ describe('multi-surface discovery across host DOM shapes (issue #6)', () => {
}
})
})

describe('shared markdown root with mixed code blocks (issue #13)', () => {
// 回归钉 #13: 同一消息容器里 dsh-ui 围栏和 python/ts/bash 等普通代码块
// 共存时,结构兜底从普通代码块的 <pre> 向上回溯,越过它自己的
// .md-code-block 把共享的 .markdown 根容器误判为「dsh-ui 围栏」→ 整条消息
// display:none,python 代码块被吞掉。兜底必须跳过已知表面的 <pre>,且
// 标签判定不得认领嵌套代码块的 banner。

/** Shared markdown root: the host renders one `.markdown` wrapper around
* every code block of a message. */
function markdownRoot(): HTMLElement {
const root = document.createElement('div')
root.className = 'markdown'
return root
}

it('renders the dsh-ui fence and keeps a sibling python block untouched', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const row = assistantRow('s30')
const root = markdownRoot()
const genui = stockCodeBlock(VALID_SPEC, 'dsh-ui')
const python = stockCodeBlock('@dataclass\nclass LineSegment:\n points: list', 'python')
root.appendChild(genui)
root.appendChild(python)
row.appendChild(root)
document.body.appendChild(row)
const send = vi.fn()
const dispose = installDomFenceRenderer(makeCtx('sess-13-1', send), send)
try {
await tick()
await tick()
// dsh-ui 围栏正常接管;python 块与共享根容器都不许被隐藏或接管。
expect(genui.hasAttribute('data-genui-rendered')).toBe(true)
expect(genui.style.display).toBe('none')
expect(python.hasAttribute('data-genui-rendered')).toBe(false)
expect(python.style.display).toBe('')
expect(python.textContent).toContain('LineSegment')
expect(root.style.display).toBe('')
// 恰好一个 genui 容器,且挂在 dsh-ui 块之后,而不是整条消息之后。
expect(row.querySelectorAll('.genui-dom-fence')).toHaveLength(1)
expect(root.querySelectorAll('.genui-dom-fence')).toHaveLength(1)
const container = row.querySelector('.genui-dom-fence')
expect(container?.previousElementSibling).toBe(genui)
expect(container!.textContent).toContain('你好,世界')
// 不该出现「未知表面类名」漂移告警:两个表面都是已知选择器命中的。
const drift = warn.mock.calls.filter(([m]) => String(m).includes('围栏表面类名未被已知选择器命中'))
expect(drift).toHaveLength(0)
} finally {
dispose()
warn.mockRestore()
}
})

it('renders the dsh-ui fence when the shared root contains TWO dsh-ui blocks', async () => {
const row = assistantRow('s31')
const root = markdownRoot()
const first = stockCodeBlock(PANEL_SPEC, 'dsh-ui')
const second = stockCodeBlock('{"panel":true,"title":"面板B","items":[{"type":"text","content":"B"}]}', 'dsh-ui')
root.appendChild(first)
root.appendChild(second)
row.appendChild(root)
document.body.appendChild(row)
const send = vi.fn()
const dispose = installDomFenceRenderer(makeCtx('sess-13-2', send), send)
try {
await tick()
// 两个 dsh-ui 块各自接管;面板 fold 走各自的 source,后者赢得 dock。
expect(first.hasAttribute('data-genui-rendered')).toBe(true)
expect(second.hasAttribute('data-genui-rendered')).toBe(true)
expect(root.style.display).toBe('')
expect(root.querySelectorAll('.genui-dom-fence')).toHaveLength(2)
expect(getPanelSpec('sess-13-2')?.title).toBe('面板B')
} finally {
dispose()
}
})

it('keeps the structural backstop working for an unknown surface beside a known python block', async () => {
// 加固不能把结构兜底一并误杀:未知类名表面的 <pre> 没有已知祖先,仍要
// 通过 label+pre 兜底被发现;旁边已知类名的 python 块继续被忽略。
const row = assistantRow('s32')
const root = markdownRoot()
const unknown = deepsuiteCodeBlock(VALID_SPEC, 'dsh-ui', 'host-fence-v99')
const python = stockCodeBlock('print("hello")', 'python')
root.appendChild(unknown)
root.appendChild(python)
row.appendChild(root)
document.body.appendChild(row)
const send = vi.fn()
const dispose = installDomFenceRenderer(makeCtx('sess-13-3', send), send)
try {
await tick()
expect(unknown.hasAttribute('data-genui-rendered')).toBe(true)
expect(unknown.style.display).toBe('none')
expect(python.hasAttribute('data-genui-rendered')).toBe(false)
expect(python.style.display).toBe('')
expect(root.style.display).toBe('')
expect(root.querySelectorAll('.genui-dom-fence')).toHaveLength(1)
} finally {
dispose()
}
})
})
Loading