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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [0.8.2] - 2026-08-14
### 修复
- **页面刷新后面板 dock 冻结(issue #4)**:宿主 anchor key 格式为 `<kindlen>:<kind><id>`(assistant step 的 id 是 `<turn>:<step>`,如 `14:assistant-step3:0`)。DOM 通道 `anchorSeqOf` 旧实现取 key 里**第一个数字 = kind 名称长度常量**(所有 assistant step 都是同一个值)→ 面板 store 的持久化重放屏障(刷新后 replayBarrier = 持久化 maxSeenSeq = 该常量)拒绝一切新 panel 围栏:dock 停在旧快照、`[genui-action]` 还活着、控制台零日志;清 `localStorage['dsh.genui.panel']` 恢复但刷新复发(与报告完全一致)。修复:`anchorSeqOf` 改从 assistant-step key 解析 `<turn>:<step>`,seq = `turn*1000+step`(随消息顺序严格单调,刷新后新消息必然大于持久化屏障);非 assistant 行 / 无锚点(Safari)行保留文档序兜底。同类隐患一并修复:`/panel` 本地覆盖的 localBarrier 同样依赖该 seq,此前也会冻结后续更新
- **面板静默拒绝可观测(issue #4 建议 3)**:`applyPanelOperation` 的 barrier 拒绝路径加一次性 `console.warn`(`[genui] 面板操作被重放屏障拒绝…`,每 source 每页面会话一条;预算 overflow 后置 append 拒绝仍走既有的 budget 诊断,不重复告警);`clearSessionPanel` 同步清理 blocked 诊断集
### 测试
- 263 → 266(+3:DOM 通道刷新回归「turn 2/3 面板 → 模拟刷新 → 历史重放保持旧快照 → turn 4 新围栏更新 dock」(旧代码上该测试失败于 `expected '面板B' to be '面板C'`,精确复现报告症状)/同 turn 内 step 单调性;panel-store 屏障拒绝告警一次/条);363 全绿

## [0.8.1] - 2026-08-14
### 修复
- **Safari 围栏全部静默丢失(issue #1)**:Safari 宿主渲染消息行时不带 `data-chat-anchor-key`(该属性是 React key 派生值,key 为 undefined 时 React 直接不渲染该属性;Chrome 同页 14 个代码块全有锚点、Safari 0 个)→ DOM 通道 `rowOf` 落空 → 每个 `dsh-ui` 围栏在静默 return 点被放弃,控制台零报错。修复:行解析降级链 `[data-chat-anchor-key]` → `[data-chat-flow-key]/[data-chat-flow-kind]`(宿主同一行 div 上的路由属性,kind 与 key 相互独立、可幸存)→ 代码块自身(身份降级为 `dom:unknown:<序数>`,`contextOf` 的 `?? 'unknown'` 分支本就存在);`fenceIndexOf` 在无行兜底时改按全文档已落定 dsh-ui 块的序数计数,同行兄弟围栏不会撞同一个 `dom:unknown:N`;`anchorSeqOf` 文档序兜底改用联合选择器(锚点行 + flow 行),无锚点行仍得单调 seq 估计。**所有静默 return 点加一次性 `console.warn`(`[dsh-genui]` 前缀,WeakSet 每块一次,1s sweep 不刷屏)**:无锚点降级、落定空体、落定不可修复体各一条诊断
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.1",
"version": "0.8.2",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/plugin/index.d.ts",
Expand Down
30 changes: 22 additions & 8 deletions src/client/dom-fence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,30 @@ function fenceIndexOf(row: Element, block: Element): number {
return index + 1
}

/** messageSeq estimate: the numeric part of the anchor key when present,
* else the row's document-order index among chat rows (monotonic in seq).
* The document-order fallback counts every host flow row — anchored or not —
* so anchor-less (Safari) rows still get a monotonic seq estimate. */
/**
* messageSeq estimate from the row's anchor key.
*
* The host's context key is `<kindLen>:<kind><id>` (e.g.
* `14:assistant-step3:0`); the id of an assistant step is `<turn>:<step>` —
* the ONLY per-message monotonic counter the host exposes in the DOM. Turn
* and step strictly increase with message order, so a turn-based seq keeps
* growing across page reloads: the panel store's persisted replay barrier
* (hydration: replays at/below the persisted max seq are dead) depends on
* this monotonicity. Without it every assistant step yields the SAME
* constant (the kind-length prefix), so after a refresh the barrier equals
* that constant and silently rejects every new panel fence (issue #4).
*
* Fallback (non-assistant rows, anchor-less Safari rows): the row's
* document-order index among chat rows — monotonic within the current
* render window, degraded across reloads.
*/
function anchorSeqOf(row: Element): number {
const key = row.getAttribute('data-chat-anchor-key') ?? ''
const match = /(\d+)/.exec(key)
if (match !== null) {
const value = Number(match[1])
if (Number.isFinite(value)) return value
const turnStep = /assistant-step(\d+):(\d+)$/.exec(key)
if (turnStep !== null) {
const turn = Number(turnStep[1])
const step = Number(turnStep[2])
if (Number.isFinite(turn) && Number.isFinite(step)) return turn * 1000 + step
}
const rows = document.querySelectorAll(`[data-chat-anchor-key], ${FLOW_ROW}`)
for (let i = 0; i < rows.length; i += 1) {
Expand Down
32 changes: 32 additions & 0 deletions src/client/panel-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ export function applyPanelOperation(sessionId: string, op: PanelOperation): Pane
const result = fold(state, op)
if (result === null) {
if (state.seen.has(op.sourceId) || state.overflow?.sourceId === op.sourceId) return 'idempotent'
// A rejection may come from the replay/local barriers (dead old content
// — worth one diagnostic) or from the budget overflow barrier (a later
// append beyond the panel budget — the budget diagnostic covers it).
const laterThanOverflow = state.overflow !== null
&& op.mode === 'append'
&& compareOrder(op.order, state.overflow.order) > 0
if (!laterThanOverflow) diagnosePanelBlocked(sessionId, op, state)
return 'blocked'
}
const status: PanelOperationStatus = result.overflow !== null && result.overflow.sourceId === op.sourceId
Expand All @@ -299,6 +306,28 @@ export function applyPanelOperation(sessionId: string, op: PanelOperation): Pane
return status
}

/* ---------------- barrier-rejection diagnostics ---------------- */

/** One diagnostic per blocked source per page session (replays stay quiet). */
const diagnosedBlocked = new Set<string>()

/**
* Log one barrier-rejection diagnostic. Barrier kills are EXPECTED for
* history replays (the persisted hydration barrier makes replays at/below
* the persisted max seq dead), so this stays quiet on repeat visits — but a
* NEW message's fence being blocked is a real defect (broken seq
* derivation), and the one-time warning makes it observable instead of a
* silent dock freeze (issue #4 asked for exactly this).
*/
function diagnosePanelBlocked(sessionId: string, op: PanelOperation, state: SessionPanelState): void {
const key = `${sessionId}\u0000${op.sourceId}`
if (diagnosedBlocked.has(key)) return
diagnosedBlocked.add(key)
console.warn(
`[genui] 面板操作被重放屏障拒绝(source ${op.sourceId},order[0]=${op.order[0]} ≤ replayBarrier ${state.replayBarrier} / localBarrier ${state.localBarrier})。历史消息重放被拒是预期行为;若是刚发送的新消息,说明消息序号推导异常,请报告。`,
)
}

/* ---------------- local /panel override ---------------- */

/**
Expand Down Expand Up @@ -350,6 +379,9 @@ export function clearSessionPanel(sessionId: string): void {
for (const key of diagnosedOverflow) {
if (key.startsWith(prefix)) diagnosedOverflow.delete(key)
}
for (const key of diagnosedBlocked) {
if (key.startsWith(prefix)) diagnosedBlocked.delete(key)
}
if (!had && !hadToken) return
for (const fn of listeners) fn()
for (const fn of expandListeners) fn()
Expand Down
75 changes: 74 additions & 1 deletion tests/dom-fence.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import { installDomFenceRenderer } from '../src/client/dom-fence.tsx'
import { inject } from '../src/client/index.tsx'
import { getPanelSpec } from '../src/client/panel-store.ts'
import { clearSessionPanel, getPanelSpec } from '../src/client/panel-store.ts'

const VALID_SPEC = '{"title":"卡片","items":[{"type":"text","content":"你好,世界"}]}'
const BUTTON_SPEC = '{"items":[{"type":"button","label":"刷新","action":"refresh"}]}'
Expand Down Expand Up @@ -431,3 +431,76 @@ describe('anchor-less rows (Safari fallback render path)', () => {
}
})
})

describe('persisted replay barrier across page refresh (issue #4)', () => {
// 回归钉 #4: 宿主 anchor key 是 `<kindlen>:<kind><id>`,assistant step 的
// id 是 `<turn>:<step>`(如 `14:assistant-step3:0`)。旧实现取 key 里第一个
// 数字 = kind 长度常量 → 所有消息的 order[0] 相同 → 刷新后 replayBarrier
// (= 持久化 maxSeenSeq = 该常量) 拒绝一切新 panel 围栏,dock 冻结且零日志。
// 修复:order[0] 改为 turn*1000+step(随消息顺序严格单调),刷新后新消息
// 的 turn 必然大于持久化屏障 → 正常更新。
const PANEL = (title: string, content: string) =>
`{"panel":true,"title":"${title}","items":[{"type":"text","content":"${content}"}]}`

it('lets a new-turn panel fence update the dock after a refresh', async () => {
const send = vi.fn()

// ── 页面 1:turn 2 与 turn 3 的两个 panel 围栏(宿主真实 key 格式)──
const row2 = assistantRow('14:assistant-step2:0')
const blockA = stockCodeBlock(PANEL('面板A', 'A'), 'dsh-ui')
row2.appendChild(blockA)
document.body.appendChild(row2)
const row3 = assistantRow('14:assistant-step3:0')
const blockB = stockCodeBlock(PANEL('面板B', 'B'), 'dsh-ui')
row3.appendChild(blockB)
document.body.appendChild(row3)
let dispose = installDomFenceRenderer(makeCtx('sess-refresh', send), send)
try {
await tick()
expect(getPanelSpec('sess-refresh')?.title).toBe('面板B')

// ── 刷新:内存态清空(localStorage 存活),新页面重装渲染器 ──
dispose()
clearSessionPanel('sess-refresh')
document.body.innerHTML = ''
dispose = installDomFenceRenderer(makeCtx('sess-refresh', send), send)
await tick()

// 历史重放(同一 DOM 重建):被持久化屏障杀死,dock 保持面板B
document.body.appendChild(row2)
document.body.appendChild(row3)
await tick()
expect(getPanelSpec('sess-refresh')?.title).toBe('面板B')

// ── 新消息(turn 4):order[0]=4000 > 屏障 3000 → dock 必须更新 ──
const row4 = assistantRow('14:assistant-step4:0')
const blockC = stockCodeBlock(PANEL('面板C', 'C'), 'dsh-ui')
row4.appendChild(blockC)
document.body.appendChild(row4)
await tick()
expect(getPanelSpec('sess-refresh')?.title).toBe('面板C')
} finally {
dispose()
}
})

it('keeps per-step monotonicity within one turn (later step wins)', async () => {
// 同一 turn 内的多步:step 必须参与 seq,后一步的围栏覆盖前一步。
const send = vi.fn()
const rowA = assistantRow('14:assistant-step5:0')
const blockA = stockCodeBlock(PANEL('面板甲', '甲'), 'dsh-ui')
rowA.appendChild(blockA)
document.body.appendChild(rowA)
const rowB = assistantRow('14:assistant-step5:1')
const blockB = stockCodeBlock(PANEL('面板乙', '乙'), 'dsh-ui')
rowB.appendChild(blockB)
document.body.appendChild(rowB)
const dispose = installDomFenceRenderer(makeCtx('sess-refresh-step', send), send)
try {
await tick()
expect(getPanelSpec('sess-refresh-step')?.title).toBe('面板乙')
} finally {
dispose()
}
})
})
19 changes: 19 additions & 0 deletions tests/genui-panel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,25 @@ describe('panel operation model (real order, no Infinity)', () => {
applyPanelOperation('s1', { sourceId: 'new:10', order: [10, 0, 0], mode: 'replace', spec: { items: [text('新')] } })
expect(getPanelSpec('s1')!.items).toEqual([text('新')])
})

it('warns once per source when a barrier rejects an op (issue #4 diagnostics)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
direct('s1', { items: [text('持久')] }, 3000)
clearSessionPanel('s1') // reload: memory gone, storage keeps maxSeenSeq
const old = { items: [text('旧')] }
applyPanelOperation('s1', { sourceId: 'old:2000', order: [2000, 0, 0], mode: 'replace', spec: old })
expect(getPanelSpec('s1')!.items).toEqual([text('持久')]) // replay dead
// replay of the same source stays silent (one diagnostic per source)
applyPanelOperation('s1', { sourceId: 'old:2000', order: [2000, 0, 0], mode: 'replace', spec: old })
const calls = warn.mock.calls.filter(([m]) => String(m).includes('[genui]'))
expect(calls).toHaveLength(1)
expect(String(calls[0]![0])).toContain('屏障')
expect(String(calls[0]![0])).toContain('2000')
} finally {
warn.mockRestore()
}
})
})

describe('panel budget (node/appends limits)', () => {
Expand Down
Loading