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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ node_modules/
*.log
scripts/
test-data/
.pnpm-store/
.dsh-meow/
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## [Unreleased]

### 修复
- **修复「插件一运行客户端就卡死」**:装饰动作会改写宿主 DOM 并再次触发 `MutationObserver`,而越界编号在「不生成芯片、保留原文」的分支里仍然无条件 `replaceChild`——等于用一个内容相同的新节点替换原节点,mutation 永不收敛,装饰与观察者互相触发成自激,把主线程打满(表现为整个客户端失去响应)。现在一个可替换项都没有时**完全不碰 DOM**;另外给全量装饰加了 120ms 合并调度(首拍仍立即执行,保住「绘制前隐藏、零闪烁」),避免长会话里装饰自身滚成高频全量扫描。
- 适配 DSH 0.1.7:`sessions.list` 快照不再暴露 `current`,当前会话 id 改经 `uiSession.adapter.current`(`binding.key`)读取,旧内核保留原字段兜底,会话切换订阅同步跟随。修复 0.1.7+ 上「批注标签在、但回车/发送按钮都没反应」——拼稿前取不到会话 id 会直接返回,空草稿时宿主又禁用发送,只有手动输入正文才发得出去。

### 交互
- 浮层底色改成「默认半透明、鼠标移入变实」:默认半透明,悬浮在正文上时能看穿下面的文字;鼠标移进去后**带 0.15s 过渡**换成不透明底色,方便专心读批注内容。半透明**不再依赖宿主变量自带 alpha**(Windows 上 `--dsw-specific-menu` 可能缺失或本身不透明),改用 `color-mix(in srgb, <主题色> 94%, transparent)` 自行生成半透明底,并做多级回退(`--dsw-alias-bg-layer-2` → `--dsw-alias-bg-base` → 深灰),保证明暗主题都能跟随。覆盖回复芯片、批注 ×N 标签、各类 hover 浮窗(回复芯片面板 / 批注清单 / 气泡标签面板)、选区工具条、编辑卡片与 toast。
- 回复芯片匹配放宽:不再强制编号后面跟冒号——模型写成「**Annotation 1** —— 说明」「Annotation 2. 说明」这类变体同样会被装饰成芯片(此前只认协议里的「Annotation N:」,模型换写法时整段回复停在纯文本)。编号仍必须能对应到批注条目,越界编号与只有 "Annotation" 词的句子原样保留。
- 回复芯片只服务真正的逐条回应:编号找不到对应批注条目(整行拿不到条目数据,或模型自己写的越界编号如「Annotation 5:」而只有 3 条)时一律**不生成芯片**、保留原文;正文里出现 "Annotation" 但不构成「Annotation N:」时同样静默,不再输出「未匹配到 Annotation N 模式」与「未找到对应批注条目」这类诊断/占位文案(相关诊断函数与标记一并移除)。能对应上的芯片 hover 照常显示原文与批注。
- 编号只出现在会话正文可见区内(图层独立,`z-index 1100`,不跟随宿主文档结构):遮挡区取整块输入区(composer 容器,含输入卡片下方的统计行)与会话头部,编号必须落在正文滚动容器里、且不与遮挡区相交才渲染;不满足就整条不显示——跟正文一样,滚出可视区即消失,不贴边钉住,也不会被裁成半截残影。修复编号"压在输入框上"、"显示在对话区下面"或盖在会话头部上的问题。
- 批注清单浮窗(悬停「批注 ×N」标签):宽度改为 `border-box`(此前 300px 内容宽再加内外边距会顶出屏幕右边缘),并把浮窗限制在正文可见区内(避开会话头部与整块输入区),批注条数多时在浮窗内部滚动——修掉「批注多时浮窗溢出屏幕、压住输入框」。
- 标记层挖去会话头部与输入区:高亮滚到这些区域时会被遮住,不再盖在上面。
- 编辑批注窗口:标题右侧紧邻一个删除按钮,可直接删掉这条批注;窗口中的取消按钮移除(标题栏右侧的关闭图标保留)。

## [1.4.11-preview.1] - 2026-09-15

- 更新 preview 预发布验收宿主至 DSH 0.1.6-alpha.1;发布任务验证同一安装包后发布至 npm preview。
Expand Down
479 changes: 347 additions & 132 deletions client.js

Large diffs are not rendered by default.

83 changes: 81 additions & 2 deletions test/layout-overlay.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,24 @@ import test from 'node:test'

const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8')

test('设置弹窗打开时隐藏会话高亮层(issue #44)', () => {
assert.match(source, /body:has\(\[role="dialog"\]\[aria-modal="true"\]\) \[data-annotation-overlay\] \{ display: none; \}/)
test('设置弹窗打开时隐藏会话高亮层与编号层(issue #44)', () => {
assert.match(source, /body:has\(\[role="dialog"\]\[aria-modal="true"\]\) \[data-annotation-overlay\],/)
assert.match(source, /body:has\(\[role="dialog"\]\[aria-modal="true"\]\) \[data-annotation-num-layer\] \{ display: none; \}/)
})

test('编号独立成层:层级固定,且不压在输入框/会话头部上', () => {
assert.match(source, /data-annotation-num-layer/, '编号应有自己的容器')
const layer = source.slice(source.indexOf('var numLayer = document.createElement'), source.indexOf('var ui = {'))
assert.match(layer, /z-index:1100/, '编号层使用统一层级(不跟随标记层的 900)')
assert.match(layer, /pointer-events:none/, '编号层本体不吃事件')
const render = source.slice(source.indexOf('function renderMarkers()'), source.indexOf('function buildMarkers()'))
assert.match(render, /numLayer\.style\.clipPath = overlay\.style\.clipPath/, '编号层共用裁剪路径兜底,编号不会压在宿主 UI 上')
const build = source.slice(source.indexOf('function buildMarkers()'), source.indexOf('// ---------- 动作 ----------'))
assert.match(build, /numLayer\.appendChild\(chip\)/, '编号挂到编号层而不是被裁剪的高亮层')
assert.doesNotMatch(build, /overlay\.appendChild\(chip\)/, '编号不再挂进被裁剪的 overlay')
assert.match(build, /hostComposerRects\(\)/, '编号的可见性判定覆盖整块输入区')
const sig = source.slice(source.indexOf('function markersSignature('), source.indexOf('function hostLocalRects('))
assert.match(sig, /blockers/, '遮挡区进入签名:输入框/头部变化时编号重走可见性判定')
})

test('滚动和布局变化会刷新输入框批注胶囊(issue #44)', () => {
Expand All @@ -25,3 +41,66 @@ test('工具条优先选区下方,底部空间不足时放在上方(PR #48
assert.equal(place({ left: 200, width: 300, top: 750, bottom: 770 }, 40).top, 702)
assert.ok(place({ left: -100, width: 300, top: 0, bottom: 800 }, 40).top >= 8)
})

test('共享悬浮面板按归属清理:宿主 layout mutation 不误杀正在观看的面板', () => {
// 归属机制存在:清空必须指名归属元素。
assert.match(source, /var tipOwner = null/)
assert.match(source, /function clearTip\(owner\) \{\r?\n\s+if \(owner !== undefined && tipOwner !== owner\) return/)
assert.match(source, /function presentTip\(owner, el\) \{[\s\S]*?tipOwner = owner/)

// 唯一的裸清空只允许在 clearTip / presentTip 内部,其它调用点一律走归属 API。
const raw = source.match(/tipLayer\.textContent = ''/g) ?? []
assert.equal(raw.length, 2, 'tipLayer 只能被 clearTip/presentTip 独占清空')

// updateChip 的「无待发送批注」分支:只能清胶囊自己的面板。该分支由 body 上
// MutationObserver(attributes/characterData/childList)经 onLayoutChange 高频
// 触发,无归属清空会把 hover 中的气泡标签/回复芯片面板抹掉。
const start = source.indexOf('function updateChip()')
const end = source.indexOf('// 悬停宽限', start)
assert.ok(start >= 0 && end > start, 'client.js 应定义 updateChip 与悬停宽限段')
const updateChip = source.slice(start, end)
assert.match(updateChip, /clearTip\(chipLayer\)/)
assert.doesNotMatch(updateChip, /tipLayer\.textContent = ''/)

// 三类面板(输入框胶囊 / 气泡标签 / 回复芯片)各自带归属挂载与清理。
assert.match(source, /presentTip\(chipLayer, el\)/)
assert.match(source, /clearTip\(chipLayer\)/)
assert.match(source, /presentTip\(tag, el\)/)
assert.match(source, /clearTip\(tag\)/)
assert.match(source, /presentTip\(chip, el\)/)
assert.match(source, /clearTip\(chip\)/)

// 删除待发送批注时只重建胶囊面板,不接管他人的面板。
assert.match(source, /if \(tipOwner === chipLayer\) showChipTip\(\)/)
})

test('标记层裁掉会话头部与输入区:编号与高亮像内容一样被遮住', () => {
assert.match(source, /function hostLocalRects\(local\)/, '按 CSS Modules local 名统一取宿主矩形')
assert.match(source, /function hostHeaderRects\(\)/, 'client.js 应定位宿主的会话头部')
assert.match(source, /function hostComposerRects\(\)/, 'client.js 应定位整块输入区(含卡片下方统计行)')
assert.match(source, /hostLocalRects\('composerSeat'\)/, '输入区认 composerSeat,而不是单个输入卡片')
const render = source.slice(source.indexOf('function renderMarkers()'), source.indexOf('function buildMarkers()'))
assert.ok(render.length > 0, 'client.js 应定义 renderMarkers')
assert.match(render, /hostComposerRects\(\)\.concat\(hostHeaderRects\(\)\)/, '输入区与头部共用挖空列表')
assert.match(render, /clipWithHoles\(holes\)/, '共用同一条 evenodd 裁剪路径')

// 编号只出现在"正文实际可见"的地方:与遮挡区或滚动容器边界冲突就不渲染。
const build = source.slice(source.indexOf('function buildMarkers()'), source.indexOf('// ---------- 动作 ----------'))
assert.ok(build.length > 0, 'client.js 应定义 buildMarkers')
assert.match(build, /skipChip/, '不在可见区内的编号不渲染')
assert.match(build, /hostLocalRects\('scrollBody'\)/, '编号必须落在正文滚动容器内')
assert.match(build, /hostHeaderRects\(\)\.concat\(hostComposerRects\(\)\)/, '编号不得与会话头部或输入区相交')
assert.doesNotMatch(build, /chipTop = hd\.bottom \+ 4/, '不再贴边钉住编号(滚出可视区就隐藏)')
})

test('编辑窗口:标题右侧可删除该批注,且不再有取消按钮', () => {
const start = source.indexOf("} else if (ui.mode === 'editing') {")
const end = source.indexOf('function positionEditor(card, left, top)', start)
assert.ok(start >= 0 && end > start, 'client.js 应定义编辑卡片分支')
const editor = source.slice(start, end)
assert.match(editor, /dsh-ann-card-headmain/, '标题与其右侧按钮成组')
assert.match(editor, /t\('edit\.delete'\)/, '删除按钮使用 i18n 文案')
assert.match(editor, /removeQuote\(editingId\)/, '删除复用待发送批注移除逻辑')
assert.doesNotMatch(editor, /dsh-ann-cancel/, '编辑窗口不再有取消按钮')
assert.doesNotMatch(source, /dsh-ann-cancel/, '取消按钮的样式一并移除')
})
2 changes: 2 additions & 0 deletions test/message-block.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ function harness(lang, draft, sourcePath) {
var annotationAttached = false
var sessions = { list: { getSnapshot: () => ({ current: 'session' }) }, scope: () => ({}) }
var ctx = { conversation: { input: { for: () => shell } } }
var uiSessionService = null
function showToast() {}
${['uiSessionFace', 'currentSessionSource', 'readCurrentSessionId'].map(fn).join('\n')}
${['buildBlock', 'shouldAttachForEnter', 'isCommandDraft', 'attachAndSend', 'hideAnnotationBlock', 'parseItemsFromBubble'].map(fn).join('\n')}
return { setLang, attachAndSend, hideAnnotationBlock, parseItemsFromBubble }
`)(shell, document, { SHOW_TEXT: 4 }, sourcePath)
Expand Down
Loading