dsh-client-ui-quote 0.12.4 → 0.12.5-lab.44
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.
- package/CHANGELOG.md +4 -1
- package/README.md +12 -11
- package/docs/distribution.md +3 -3
- package/lib/client.js +178 -16
- package/package.json +1 -1
- package/test/README.md +8 -6
- package/test/capsule.mjs +203 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased (lab)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- 草稿里的引用删掉后原文高亮还在:主会话「添加到对话 / 评论」把引用写成芯片时,坐标是当作「已发出」存进 `dsh-client-ui-quote:v2:quote-source` 的,读者随后在聚合条点 `×`(或直接退格删掉芯片)表示「不想添加了」,那张表却没有任何改动,于是原句上的淡黄高亮与序号一直留着——明明这条引用从没发出去。现在芯片写进草稿的坐标多带一个 `chip` 标记;`syncDraftBars` 每次扫描草稿时按卡片比对上一次看到的芯片,发现某颗芯片离开了一张**仍挂在页面上**的卡片、而页面上又没有它发出的胶囊时,就把对应坐标降级成 `draft`(`paintAnchors` 的既有跳过规则),高亮与序号随即收回并落库,刷新不会复活;芯片回来(宿主慢一拍才渲染回来、或读者又把同一句加回草稿)时 `restoreChipSource` 立刻补画。已经作为胶囊发出去过的同一段文字不受影响:`chip` 只写在草稿这次保存上,胶囊出现时 `promoteSource` 会清掉它;整个输入框离开页面(切会话)也不算删除。纯文本兜底(宿主没有芯片面)不会留下 `chip` 标记。
|
|
8
|
+
- 离线断言 **686 组**(check 12 + capsule 470 + panel 160 + host 44);新增 11 组断言:「添加到对话写进草稿的坐标带 `chip` 且落库」「芯片还在草稿时照常落色」「删掉芯片(未发送)高亮收回」「收回状态落库、刷新不复活」「芯片回来时补画」「胶囊出现后转正补画」「页面上已有胶囊时不误收」「整个输入框离页不算删除」「两个草稿共用同一坐标时删一颗不收回、删最后一颗才收回」。
|
|
6
9
|
|
|
7
10
|
## 0.12.4 — 2026-10-26
|
|
8
11
|
|
package/README.md
CHANGED
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
| **添加到对话** | 直接把引用加进输入框,不弹评论框。输入框里已经写好的文字会留在原位:引用落到草稿**最上方**,你的文字另起一行接在下面,光标停在末尾,接着写正式提问即可。 |
|
|
45
45
|
| **侧边对话** | 把引用写进一条**独立的侧边对话**的输入框。默认走**内置隐藏线程**:本插件的宿主半用 `agents.create` 建一个 `origin: "subagent"` 子会话(会话列表不显示它),主会话最近几轮的原话作为**一条可折叠的隐性上下文**注进去,而不是把整条历史铺出来,用宿主内置的 `subagentchat` 页签打开——聊天记录、输入框、流式输出与工具调用全是 DSH 原生的。宿主半不可用(旧宿主 / 父会话不在线 / 创建被拒)时自动退回 `sessions.fork` + 本插件 `quote-sidechat` 页签;把 `sideChatBackend` 设成 `"better"` 才优先走 dsh-better-sidebar。**不装 better-sidebar 也有隐藏线程**;装了也不会冲突,两边共享 `Side: ` 持久约定但路由、存储与 descriptor 各自独立。所有后端都**什么都不发送**,线程独立运行、不污染主会话,页签自动在右侧栏打开。同一条主会话只开一条线程,之后每次点都在同一条线程的草稿后面接着追加。鲸鱼蓝(`#4d6bfe`)的图标是它在浮层里的标识。浮层里**只有一项**「侧边对话」——两个长得一样的入口只会让人犹豫;早期版本按引用分过线程的记录仍会被识别并复用。引用清单页底部会列出这个会话的**全部侧边线程**,每条都能重新打开。 |
|
|
46
46
|
| **键盘直达** | `Alt+Q` 引用 / `Alt+C` 评论 / `Alt+B` 侧边对话——同一个浮层的三个动作,不弹浮层直接办。走 DSH 的快捷键服务注册,所以出现在设置里、可改键、与宿主键位冲突会在注册时报错;没有选区时返回 `pass`,按键不会被吞掉。 |
|
|
47
|
-
| **原文侧高亮** | 引用落进草稿或发出后,被引用的原句回到**来源消息**里留下淡黄高亮与**序号角标**(序号就是坐标里的同句序号,和 `「引用N」`
|
|
48
|
-
| **聚合引用条** | 输入框上方一条 **`引用 ×N`**:每颗芯片一个摘要胶囊,点一下跳回原文(跳不到就把光标放到芯片上,一次退格删掉),悬停就是完整卡片;每条末尾有 **`✎` 编辑**与 **`×` 移除**:编辑把原引用与评论回填进注释框,确认后旧芯片让位、新芯片原地插入;移除优先走宿主自己的 `consumeToken`(按芯片的 detect 区间与 `draftRev` 做 CAS 精确删除,不会碰错别的文本),宿主没有这个读取面或同名芯片无法区分时才退回「聚焦芯片 +
|
|
47
|
+
| **原文侧高亮** | 引用落进草稿或发出后,被引用的原句回到**来源消息**里留下淡黄高亮与**序号角标**(序号就是坐标里的同句序号,和 `「引用N」` 对得上)。**芯片被移出草稿(没有发送)时,这块高亮与序号一起收回**——不想添加了就不该在原文上留标记;已经作为胶囊发过的引用不受影响。支持 `CSS.highlights` 的宿主由浏览器**直接绘制高亮**,滚动、流式输出、虚拟列表重排都跟手,不靠逐帧重画去追;不支持时才退回浮层矩形(只画序号徽章)。行不在视口里、文字已经变了、宿主不给 `Range` 时就静默跳过,**不改宿主的 DOM**(高亮不包 `mark`,也不搬节点)。表格单元格、表格行、段落、列表项、`<br>` 这些块级边界按浏览器选区报告的 `\t` / `\n` 对齐,所以跨单元格、跨行、跨段落的引用也能画回来。 |
|
|
48
|
+
| **聚合引用条** | 输入框上方一条 **`引用 ×N`**:每颗芯片一个摘要胶囊,点一下跳回原文(跳不到就把光标放到芯片上,一次退格删掉),悬停就是完整卡片;每条末尾有 **`✎` 编辑**与 **`×` 移除**:编辑把原引用与评论回填进注释框,确认后旧芯片让位、新芯片原地插入;移除优先走宿主自己的 `consumeToken`(按芯片的 detect 区间与 `draftRev` 做 CAS 精确删除,不会碰错别的文本),宿主没有这个读取面或同名芯片无法区分时才退回「聚焦芯片 + 退格」并提示。**移除(或直接退格删掉芯片)之后,如果这条引用从没真正发出去,原句上的高亮与序号也一起收回**;页面上已经有它发出的胶囊时则照旧保留。**整条索引存在本机**:宿主会跨刷新恢复草稿里的芯片,插件的索引也一起恢复,所以刷新后条、卡片与「打开文件」都还在。主会话与侧边线程各自的输入框各有一条;芯片删光时条自己消失。整条带 `role=toolbar`,每颗胶囊的可访问名是完整引用+评论,不是截断后的标签。 |
|
|
49
49
|
| **来源头** | 发送块尾部的坐标行前面多一段人可读的出处:**`※ 引自第 12 轮 · 助手 │q1:…`**,模型不解码也知道来自哪一轮、谁说的;角色属性宿主没给时就只写轮次。胶囊悬停卡片里同样显示 `引自第 12 轮 · 助手`,按界面语言本地化。 |
|
|
50
50
|
| **Diff 划选** | 在 diff / 每轮改动面板里划选也走同一个浮层:引用按**代码块**发,围栏信息位带探测到的文件路径与行号(`src/app.ts#L12`);路径、行号是宿主 DOM 上的探测,拿不到就只带代码,不影响引用本身。有路径时卡片/清单可直接打开文件并定位到起始行。 |
|
|
51
51
|
| **清单多选插入** | 引用清单每行一个勾选框,`插入所选` 一次把多条引用写进输入框——每条各成一颗芯片、各自带坐标,不合并;芯片通路不可用时退回一段包含多个引用块的文本。这一栏是只读导航 + 批量插入,不做就地删除——删除与编辑在**草稿上方的聚合条**里做(宿主暴露了按区间消费 token 的 `consumeToken`;旧宿主没有这个读取面时才退回退格路径)。 |
|
|
@@ -139,7 +139,7 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
|
|
|
139
139
|
|
|
140
140
|
本机切换这三档(这台开发机专用的辅助脚本,不在仓库里):`node .dsh-quote-work/profile-mode.mjs link|lab|stable|status`。换完在窗口里 `Ctrl+Shift+R` 生效;只有宿主半有改动时才需要重启桌面端。
|
|
141
141
|
|
|
142
|
-
两条线的断言入口相同:`npm test`(
|
|
142
|
+
两条线的断言入口相同:`npm test`(686 项:check 12 + capsule 470 + panel 160 + host 44);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
|
|
143
143
|
|
|
144
144
|
**推送通道**:`git push` 优先,但本机 `github.com:443` **时通时不通**(2026-10-19 实测:短时可用、随后连续超时,每次失败等约 21 秒)。日常用工作区根的 `promote.ps1`:先试 git,失败自动转 REST 兜底(`.dsh-quote-work/publish-release.mjs`、`.dsh-quote-work/push-branch.mjs`,`-Channel rest` 可跳过探测),并核对远端 SHA。**别混用两条通道**:REST 重建的提交时区归一到 `+0000`,内容相同但 SHA 不同,同一分支混推会分叉(`git status` 看着干净,远端却多一条平行提交);恢复就先 `git fetch`、确认 `git diff <本地> <远端>` 为空,再把本地线 reset 到远端。
|
|
145
145
|
|
|
@@ -328,7 +328,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
328
328
|
1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
|
|
329
329
|
2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标以「人可读来源头 + base64 尾巴」随消息走——前者让模型知道出处,后者让插件跨刷新回读。base64 确实占一点 prompt,这是用「不依赖本地表」换来的取舍。
|
|
330
330
|
3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在同类里仍是少数。
|
|
331
|
-
4. **工程化**:
|
|
331
|
+
4. **工程化**:686 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
|
|
332
332
|
|
|
333
333
|
反过来也要说清楚:**本插件不做的**——不做翻译/解释/改写/飞书投递类选区动作;不做图片/截图引用(**明确取舍**:图片引用要的是另一套胶囊/卡片/附件语义,是第二个产品的量级,塞进这里只会把「文本引用带坐标」这条主线冲淡;`dsh-selection-toolbar` 与 Kimi 桌面端已经做了,真要做建议独立成插件);不做完整 Topic 系统;不做常显大块引用。它只把「引用」这一件事做穿——但「做穿」包括发送后的管理、来源闭环和审阅,不只是浮层。
|
|
334
334
|
|
|
@@ -365,9 +365,9 @@ locale/zh.json 插件页卡片文案(中文)
|
|
|
365
365
|
locale/en.json 插件页卡片文案(英文)
|
|
366
366
|
test/check.mjs 离线自检(清单 + 组件契约)
|
|
367
367
|
test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
|
|
368
|
-
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 +
|
|
368
|
+
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍/移除后原文标记收回 + 行范围标记 + 浮层不随座位失效,470 项断言)
|
|
369
369
|
test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 文件路径打开 + 侧边线程回流 + 审阅工作台(状态 / 标签 / 批量)+ JSON 导出 + 本地索引,160 项断言)
|
|
370
|
-
test/host.mjs 离线自检(宿主半:标签 / 隐性上下文种子(切点 + 一个收口回合包着上下文消息 + descriptor;只带用户与助手的原话、8000 字上限、跳过注入上下文)/ 空日志兜底 / 继承切点与 descriptor 顺序 / agents.create 参数 / 探针 / 围栏 / 路由注册与卸载,
|
|
370
|
+
test/host.mjs 离线自检(宿主半:标签 / 隐性上下文种子(切点 + 一个收口回合包着上下文消息 + descriptor;只带用户与助手的原话、8000 字上限、跳过注入上下文)/ 空日志兜底 / 继承切点与 descriptor 顺序 / agents.create 参数 / 探针 / 围栏 / 路由注册与卸载,44 项断言)
|
|
371
371
|
test/capsule.html 真浏览器里跑同一套胶囊断言(51 项,可选)
|
|
372
372
|
```
|
|
373
373
|
|
|
@@ -419,7 +419,7 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
|
|
|
419
419
|
**浮层不跟着座位生死**(`conversation.input.overlay` 没挂载也能选中出栏、挂上两个会话座位既不叠加也不丢监听、
|
|
420
420
|
座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份、
|
|
421
421
|
**放弃动作不留高亮**(浮层 `mousedown` 保住的选区在 `Esc` / 取消退场时被还回去));
|
|
422
|
-
(共
|
|
422
|
+
(共 470 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、**表格单元格 / 表格行 / 段落 / 列表项 / `<br>` 边界的引用高亮**、**侧聊坐标在发出前不着色、`promoteSource` 转正后才补画**、**草稿芯片删掉(没发送)后原文高亮与序号收回、芯片回来或胶囊出现才补画(已有胶囊/整个输入框离页不误收)**、**输入框已有文字时引用置顶并将原文另起一行**、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、原生 `quote-sidechat` 页签的注册 / 地址 / 标题 / retain-release、内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
|
|
423
423
|
`test/capsule.html` 是同一套断言的真浏览器版(51 项),用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。这一版修好了它**缺失的 `<body>` 标签**——脚本在 `<head>` 里执行时 `document.body` 为 null,这个页面以前在真浏览器里是直接坏的;并补上真 `Range` 的链接/行内代码断言(真浏览器的 `cloneContents()` 返回的是 DocumentFragment,不是元素——序列化器只认元素的话,链接在真浏览器里会退化成纯文本;离线桩现在也用真片段,不再放过这一类),另外补了 `CSS.highlights` 选区保持与关闭清理。
|
|
424
424
|
|
|
425
425
|
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(160 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
|
|
@@ -440,7 +440,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
440
440
|
|
|
441
441
|
`test/host.mjs` 不需要浏览器:它直接 import 宿主半,用桩 `agents` / `agentPresets` / `sessionTitle` / `webServer` 驱动。断言包括:`Side: ` 标签的前缀与截断;种子固定三件事(`session/end-seed{inherited:true}` → 一条 `source.kind="session-context"` 的 `user/message` → `subagent/descriptor`)、`inheritedEventCount` 恒为 0、上下文字段只收用户与助手的原话(最新在后、8000 字上限、跳过 `runtime-context` 与工具事件)、空日志也有可读文案;descriptor 排在切点之后且 seq 连续;`agents.create` 收到 `origin: "subagent"` / `parentSession` / `isSeeded` / `inheritedEventCount` / 父会话当前模型路由 / preset setup / 清空继承 inbox / 固定标题;探针、409、503、403、405、404 与 `apply` 的注册/卸载全部有断言。
|
|
442
442
|
|
|
443
|
-
合计 **
|
|
443
|
+
合计 **686 组**(check 12 + capsule 470 + panel 160 + host 44),`npm test` 全绿。
|
|
444
444
|
|
|
445
445
|
## 已知限制
|
|
446
446
|
|
|
@@ -469,13 +469,14 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
469
469
|
- 更新**之前**发出的老消息不会被变成胶囊:它们的文本里没有 `❝` 标记,插件无从辨认(重新引用一次即可)。
|
|
470
470
|
- **原文高亮用完整引用文本定位**:页面刚刷新、那颗胶囊还没渲染时,插件手里只有坐标表里的前 120 字身份键,长引用会先亮前 120 字;胶囊进入视口后补上全文并自动重画。行不在视口、被虚拟列表收起、或文字已经变了就不画,绝不改宿主 DOM。支持 `CSS.highlights` 时高亮由浏览器绘制(跟手、不逐帧追滚动),不支持时退回浮层矩形;两种模式都不包 `mark`。
|
|
471
471
|
- **侧边对话的坐标要等它真的发出去才落色**:第三项存坐标时带 `draft` 标记,`paintAnchors` 跳过它,所以原文上不会先亮一块——放弃或关掉侧边对话都不留痕迹。转正靠胶囊:那句引用以胶囊形态出现在某条 transcript(同一个 ref)时才 `promoteSource` 并补画。因此**在另一个窗口**里把侧聊引用发出去时,本窗口要等下一次刷新(重新读 `localStorage` 里的表)才会补上这块高亮,而不是立刻出现。
|
|
472
|
+
- **草稿里的芯片删掉后,原文标记会跟着收回**:主会话「添加到对话 / 评论」写进草稿的坐标以 `chip` 记在表里;聚合条的 `×` 或直接退格让芯片离开草稿、而页面上又没有它发出的胶囊时,坐标转成 `draft`(同样的跳过规则),高亮与序号随即收起,落库后刷新也不会复活。已经作为胶囊发出去过的同一段文字不受影响。收回是**按草稿卡片观察**的:整个输入框离开页面(切会话)不算删除;**在另一个窗口**里删掉的芯片,本窗口要等下一次刷新(重新读表)才收回。
|
|
472
473
|
- **跨消息的选区不带来源头**:选区起点与终点落在不同消息行时,引用照常发出(原文、评论、代码形态都在),但不写 `引自第 N 轮 · 助手`,也不带可跳回的坐标——一句话横跨两轮时,任何单一轮次都是错的。想要带来源,就分两次引用。
|
|
473
474
|
- **评论框期间的原选区高亮依赖 `CSS.highlights`**:Chromium/Electron 支持;不支持时静默跳过,评论框里仍有引用正文,不影响引用本身。
|
|
474
475
|
- **聚合引用条的移除与编辑优先用宿主的 `consumeToken`**(实测 DSH 0.2.1-rc.2 的 conversation shell):宿主把每个芯片投影成一条 occurrence(`offset`/`length`/`label`/`source`),`consumeToken({kind:"span", span:{start,end,draftRev}})` 在 `draftRev` 仍是当前版本时精确删掉这段区间;本插件据此计算 detect 区间(芯片在前面的 occurrence 会把坐标左移 `length - 1`)并带上紧跟的一个空格。宿主没有 `compose()`/`consumeToken`(旧版)或两条同标签芯片无法区分时,才退回「聚焦芯片 + 派发一次 Backspace」;两步都不成功时提示手动按一次退格,绝不做半删除的草稿。`✎` 编辑走同一条移除路径——旧芯片先让位,新芯片再原地插入。草稿芯片的索引存在本机(`dsh-client-ui-quote:v1:chip-index`),所以**刷新后**条、悬停卡与「打开文件」都还在——宿主本来就会恢复草稿里的芯片,索引跟着恢复,它们才重新算是本插件的引用。
|
|
475
476
|
|
|
476
477
|
- **回合级引用依赖宿主的回合尾槽位**:宿主把每条回合的尾部做成一个列表子槽位(`conversation.chat.turnTail`,`kind:"list"`、`scope:"session"`),本插件往里加一条。宿主没有这个槽位时按钮不出现——功能缺失,其它能力不受影响。
|
|
477
478
|
- **引用清单的本机索引只在本机**:它兜的是「上下文压缩把消息裁掉」这一种丢失,记录不进模型上下文、不跨设备、不跨浏览器;没读过的引用它当然也不知道。
|
|
478
|
-
- **侧边对话有一条内置主路 + 三条退路**:默认是宿主半创建的内置隐藏线程(`origin: "subagent"` + 宿主 `subagentchat` 页,会话列表不显示);父会话不在线 / 宿主半不可用时退回 `sessions.fork` + 本插件 `quote-sidechat` 页签(线程作为普通 fork 出现在会话列表);再不行才走 dsh-better-sidebar(仅显式 `sideChatBackend: "better"` 或最后兜底)。隐藏线程与 better-sidebar 共享 `Side: ` 标题约定,所以两边的线程列表可以互相看见,但存储键、路由与 descriptor provider
|
|
479
|
+
- **侧边对话有一条内置主路 + 三条退路**:默认是宿主半创建的内置隐藏线程(`origin: "subagent"` + 宿主 `subagentchat` 页,会话列表不显示);父会话不在线 / 宿主半不可用时退回 `sessions.fork` + 本插件 `quote-sidechat` 页签(线程作为普通 fork 出现在会话列表);再不行才走 dsh-better-sidebar(仅显式 `sideChatBackend: "better"` 或最后兜底)。隐藏线程与 better-sidebar 共享 `Side: ` 标题约定,所以两边的线程列表可以互相看见,但存储键、路由与 descriptor provider 各自独立。未在侧聊里提问前执行「带回主会话」,可能取到复制过来的最后一轮,先在侧聊里问一句即可。所有后端都不可用时浮层里没有侧边对话那一行。
|
|
479
480
|
|
|
480
481
|
## 下一步
|
|
481
482
|
|
|
@@ -485,7 +486,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
485
486
|
- **Markdown 预览的行号**:要精修就得读源文件,而客户端必须声明 `remote` 才能拿到它;声明错会让 client bundle 在不匹配的宿主上**静默失效**(`dsh-quote-annotate` 上游就是这么死的)。所以先不声明——文件仍然打开,只是落在顶部而不是行上。
|
|
486
487
|
- **表格/列表/LaTeX 的结构保真**:链接与行内代码已做;表格要还原成 Markdown 表格,列表要还原成缩进,成本明显更高,留到有人真的需要时再做。
|
|
487
488
|
- **图片/截图引用**:明确不做(理由见「本插件不做的」)。真要做请独立成插件:图片引用要的是另一套胶囊/卡片/附件语义。
|
|
488
|
-
- **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有
|
|
489
|
+
- **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 686 项离线断言;隐藏线程的 `agents.create` / 种子格式已在**隔离的 DSH web 实例**(临时 `DSH_HOME`,端口 8791,CLI 0.2.1-rc.3)里确认:宿主半通过 `ctx.inject(["webServer"])` 晚到注册,`POST /ui-quote/api/sidechat.start` 的探针返回 `{"ok":true,"value":{"available":true,"version":1}}`,说明 `agents` 服务与同源围栏都就绪。本轮又在**运行中的桌面端**(link 模式,重启后加载 `lib/index.js`)核对过一次:`/ui-quote/api/*` 下任意路径都先被围栏拦成 `403 {"code":"forbidden"}`,而同样方法打一个不存在的顶层路径是 `404`,说明前缀路由确实挂上了、并且先过围栏。原生侧聊页签的 `SessionProvider` / `conversation.content` 嵌入契约还需要在真实窗口里重开一次侧边对话确认(离线桩只覆盖注册、地址、标题与 retain/release,不渲染宿主组件)。真浏览器(1600×2000 视口)同一套断言本轮复核 **51/51**。欢迎页的交互点击被安全闸门拦下,因此下面四条仍需在真实窗口逐项点:聚合条编辑、回合尾的两个按钮、打开文件与行范围标记、JSON 导出。
|
|
489
490
|
- **市场收录与首屏**:README 已有截图、徽章、「30 秒上手」与 24 秒静态轮播 GIF;还差把插件提交到 dsh.market / dsh-plugin.org / dsh.pub / awesome-deepseek-harness —— 这些需要外部账号,是人工动作。
|
|
490
491
|
|
|
491
492
|
## 变更记录
|
|
@@ -521,7 +522,7 @@ Screenshots: [floating bar](docs/screenshots/bar.png) · [comment box](docs/scre
|
|
|
521
522
|
Install with `dsh plugin --profile desktop add dsh-client-ui-quote` (published on npm; DSH >= 0.2.0-rc.2, web/desktop client).
|
|
522
523
|
Test with `npm test`. MIT licensed.
|
|
523
524
|
|
|
524
|
-
Kimi Code is where the interaction started, not the whole comparison. As of 2026-10 the DSH market has at least a dozen quote/annotation plugins — `dsh-annotation`, `dsh-sidenote`, `CiteCiter`, `dsh-quote-annotate-pro`, `dsh-select-quote`, `dsh-selection-toolbar`, and others. The floating bar is table stakes; the differentiator here is what happens after the quote is sent: a source highlight and ordinal, jump-back, a searchable review list, side-thread bring-back, revisions, and per-quote reply badges. The draft's `引用 ×N` bar removes a quote through the host's `consumeToken` span CAS (exact occurrence coordinates, draft-revision guarded), falling back to the editor's own Backspace path on hosts without that face, with a manual-backspace hint if neither applies. A preview/diff quote's file now appears as a clickable `path#Lx-Ly` line in the hover card and the quote list; clicking it opens the right sidebar's document preview at the range's first line through `sidebarRight.openResource`, focusing an already-open tab instead of duplicating it. A selection inside one rendered chat code block is quoted as a fenced block carrying the renderer's language, and degrades to a plain blockquote when the selection crosses the block boundary. A quote that is still in the draft can be edited or removed from the aggregate bar, and the chip index is kept locally so a reload does not lose the bar, the hover card or the file it points at. A code quote shows its source (`文件预览 · src/app.ts · L12-L48`) and opens the sidebar's document preview at that line.
|
|
525
|
+
Kimi Code is where the interaction started, not the whole comparison. As of 2026-10 the DSH market has at least a dozen quote/annotation plugins — `dsh-annotation`, `dsh-sidenote`, `CiteCiter`, `dsh-quote-annotate-pro`, `dsh-select-quote`, `dsh-selection-toolbar`, and others. The floating bar is table stakes; the differentiator here is what happens after the quote is sent: a source highlight and ordinal, jump-back, a searchable review list, side-thread bring-back, revisions, and per-quote reply badges. The draft's `引用 ×N` bar removes a quote through the host's `consumeToken` span CAS (exact occurrence coordinates, draft-revision guarded), falling back to the editor's own Backspace path on hosts without that face, with a manual-backspace hint if neither applies. A preview/diff quote's file now appears as a clickable `path#Lx-Ly` line in the hover card and the quote list; clicking it opens the right sidebar's document preview at the range's first line through `sidebarRight.openResource`, focusing an already-open tab instead of duplicating it. A selection inside one rendered chat code block is quoted as a fenced block carrying the renderer's language, and degrades to a plain blockquote when the selection crosses the block boundary. A quote that is still in the draft can be edited or removed from the aggregate bar, and the chip index is kept locally so a reload does not lose the bar, the hover card or the file it points at. A code quote shows its source (`文件预览 · src/app.ts · L12-L48`) and opens the sidebar's document preview at that line. A main session keeps exactly one side thread: the floating bar offers a single 侧边对话 row (a second, identical-looking entry was dropped — threads created by older per-quote builds are still recognised and reused), later quotes append to the same thread's draft, and the quote list shows every thread of the session; by default the same flow creates a hidden seeded subagent thread through the plugin's host half and opens it in the host's built-in chat, with a fork in the plugin's own page as the fallback and better-sidebar as an opt-in backend. Built-in threads share better-sidebar's durable `Side: ` convention, so the two plugins can see each other's threads without sharing storage, routes or descriptors. The quote list exports Markdown and JSON, keeps a local index so a context compaction cannot empty it, and a small whitelisted set of switches plus a `window.__dshQuote` handle sits behind it all. The Chinese section above carries the current 2026-10 market comparison; this plugin is not a Kimi Code clone.
|
|
525
526
|
|
|
526
527
|
Two wire-only extras ride on the same pipeline: a per-quote reply toggle in the comment box (the block carries a
|
|
527
528
|
transport-only instruction, the model answers `「引用N」` by `「引用N」`, and each label becomes a badge that reopens
|
package/docs/distribution.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
| --- | --- |
|
|
9
9
|
| npm | `dsh-client-ui-quote` |
|
|
10
10
|
| 仓库 | https://github.com/BangBang-03/dsh-client-ui-quote |
|
|
11
|
-
| 版本 | `0.12.
|
|
11
|
+
| 版本 | `0.12.4`(发版时同步;最终以 `package.json` 为准) |
|
|
12
12
|
| License | MIT |
|
|
13
13
|
| 图标 | `icon.svg` |
|
|
14
14
|
|
|
@@ -67,8 +67,8 @@ awesome-deepseek-harness 的列表条目(英文,一行):
|
|
|
67
67
|
## 发布前
|
|
68
68
|
|
|
69
69
|
1. 把 `CHANGELOG.md` 顶部的 `Unreleased (lab)` 改成正式版本号(`## X.Y.Z — YYYY-MM-DD`),在 lab 上提交。
|
|
70
|
-
2. 推 `lab`(CI 发一个 `-lab.N` 预发布),在真实 DSH
|
|
70
|
+
2. 推 `lab`(CI 发一个 `-lab.N` 预发布),在真实 DSH 窗口点一遍:聚合条编辑/移除、回合尾按钮、打开文件与行范围、侧边对话(内置隐藏线程)、JSON 导出。
|
|
71
71
|
3. 发正式版:`main` 快进到 `lab` 并推送 → 在 `main` 头上打 `vX.Y.Z` 标签并推送。标签会同时触发两件事:**npm `latest` 发布** 与 **GitHub Release 创建**(正文取自 CHANGELOG 同名小节;已存在则更新 notes,重跑安全)。
|
|
72
72
|
4. 发完确认三处一致:`npm view dsh-client-ui-quote dist-tags` 的 `latest`、仓库 Releases 页的最新一条、`git ls-remote --tags` 的标签指向同一个提交。
|
|
73
73
|
|
|
74
|
-
已复核:`npm test`
|
|
74
|
+
已复核:`npm test` 686 组离线断言;真浏览器(1600×2000 视口)51/51;隔离 DSH web 实例中工作区 link 与 npm lab 发布包均加载成功,`__dshQuote` 可用、conversation/inputTriggers/sidebarRight/sidebarRightTabs 可见、`probe().nativeSideChat === true`(betterSidebar 缺失);`npm pack --dry-run` 包含 README、截图、GIF 与本文件。真实窗口的四条交互路径仍需人工点击(聚合条编辑、回合尾按钮、打开文件与行范围、JSON 导出)。
|
package/lib/client.js
CHANGED
|
@@ -2455,6 +2455,8 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
2455
2455
|
let chipWireBudget = 0;
|
|
2456
2456
|
/** Composer card -> its aggregate quote bar; at most one bar per composer. */
|
|
2457
2457
|
const draftBars = new Map();
|
|
2458
|
+
/** Composer card -> the refs of the quote chips it held on the last pass. */
|
|
2459
|
+
const cardChips = new Map();
|
|
2458
2460
|
let draftBarTimer = null;
|
|
2459
2461
|
/** Below this many pills the bar shows them all; beyond it, a `+N`. */
|
|
2460
2462
|
const DRAFTBAR_LIMIT = 5;
|
|
@@ -2764,6 +2766,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
2764
2766
|
// Absent, not `false`, when it is not a draft: the stored table stays
|
|
2765
2767
|
// the shape older builds wrote.
|
|
2766
2768
|
if (value.draft === true) candidate.draft = true;
|
|
2769
|
+
if (value.chip === true) candidate.chip = true;
|
|
2767
2770
|
return candidate;
|
|
2768
2771
|
}
|
|
2769
2772
|
|
|
@@ -3021,11 +3024,17 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3021
3024
|
* sent anywhere yet — the side thread's chip. The row still resolves (the
|
|
3022
3025
|
* card and the jump need its ordinal) but it is not painted on the source
|
|
3023
3026
|
* until the quote turns up in a transcript; see `promoteSource`.
|
|
3027
|
+
*
|
|
3028
|
+
* `options.chip` marks a coordinate minted by a chip in a composer. Unlike
|
|
3029
|
+
* a draft it may paint right away (the quote is in a visible draft), but it
|
|
3030
|
+
* remembers that a chip is what holds it: `syncDraftBars` retracts the mark
|
|
3031
|
+
* when that chip leaves the draft without sending (see `retractChipSource`).
|
|
3024
3032
|
*/
|
|
3025
3033
|
function saveSource(quote, sessionId, source, options) {
|
|
3026
3034
|
const key = sourceKeyOf(quote);
|
|
3027
3035
|
if (key.length < 4 || source === null || typeof source !== "object") return null;
|
|
3028
3036
|
const draft = options !== null && typeof options === "object" && options.draft === true;
|
|
3037
|
+
const chip = options !== null && typeof options === "object" && options.chip === true;
|
|
3029
3038
|
const owner = typeof source.sessionId === "string" && source.sessionId !== "" ? source.sessionId : sessionId;
|
|
3030
3039
|
if (typeof owner !== "string" || owner === "") return null;
|
|
3031
3040
|
const attr = source.attr;
|
|
@@ -3045,6 +3054,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3045
3054
|
for (const entry of list) ordinal = Math.max(ordinal, (Number(entry.n) || 0) + 1);
|
|
3046
3055
|
candidate = { sessionId: owner, attr, key: rowKey, turn, quote: key, at, n: ordinal, role, origin };
|
|
3047
3056
|
if (draft) candidate.draft = true;
|
|
3057
|
+
if (chip) candidate.chip = true;
|
|
3048
3058
|
list.push(candidate);
|
|
3049
3059
|
} else {
|
|
3050
3060
|
const kept = role === "" && ROLE_CODES.has(list[index].role) ? list[index].role : role;
|
|
@@ -3056,6 +3066,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3056
3066
|
// The other direction is the promotion — a plain save (a send)
|
|
3057
3067
|
// simply drops the mark.
|
|
3058
3068
|
if (draft === true && list[index].draft === true) candidate.draft = true;
|
|
3069
|
+
// The chip mark survives every later save while it is set: a save
|
|
3070
|
+
// means the quote is still in some draft, and only a capsule
|
|
3071
|
+
// (`promoteSource`) proves the chip that minted it is gone.
|
|
3072
|
+
if (list[index].chip === true) candidate.chip = true;
|
|
3059
3073
|
list[index] = candidate;
|
|
3060
3074
|
}
|
|
3061
3075
|
// Re-insert so the Map's own order agrees with "this was just used".
|
|
@@ -3163,30 +3177,125 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3163
3177
|
return out;
|
|
3164
3178
|
}
|
|
3165
3179
|
|
|
3180
|
+
/**
|
|
3181
|
+
* Drop the given bookkeeping marks from every stored coordinate with this
|
|
3182
|
+
* ref. The identity is the ref alone (`session\0attr\0row\0turn\0ordinal`):
|
|
3183
|
+
* the ordinal already tells one quoted sentence in a row from another,
|
|
3184
|
+
* while the wording can be spelled differently on the way back (a code
|
|
3185
|
+
* quote loses its marks on save) and must not stop the write.
|
|
3186
|
+
*
|
|
3187
|
+
* @returns {boolean} true when something changed (the caller repaints).
|
|
3188
|
+
*/
|
|
3189
|
+
function dropSourceMarks(ref, names) {
|
|
3190
|
+
const wire = normalizeRef(ref);
|
|
3191
|
+
if (wire === null) return false;
|
|
3192
|
+
const id = refTextKeyOf(wire);
|
|
3193
|
+
if (id === "") return false;
|
|
3194
|
+
let dropped = false;
|
|
3195
|
+
for (const list of sourceIndex.values()) {
|
|
3196
|
+
for (const entry of list) {
|
|
3197
|
+
if (refTextKeyOf(entry) !== id) continue;
|
|
3198
|
+
for (const name of names) {
|
|
3199
|
+
if (entry[name] !== true) continue;
|
|
3200
|
+
delete entry[name];
|
|
3201
|
+
dropped = true;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
if (dropped) persistSources();
|
|
3206
|
+
return dropped;
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3166
3209
|
/**
|
|
3167
3210
|
* A coordinate saved for a draft becomes permanent the moment the quote
|
|
3168
3211
|
* shows up in a transcript: a capsule carrying the ref is proof the reader
|
|
3169
3212
|
* actually sent it. Until then `saveSource(..., { draft: true })` keeps it
|
|
3170
3213
|
* out of `paintAnchors`, so a side chat the reader backs out of leaves no
|
|
3171
|
-
* mark on the source.
|
|
3214
|
+
* mark on the source. A `chip` mark rides along (a chip in a draft is
|
|
3215
|
+
* visible, so it paints) and is dropped here too: a capsule means the chip
|
|
3216
|
+
* is gone and no retraction may hide the sent quote.
|
|
3172
3217
|
*
|
|
3173
|
-
* @returns {boolean} true when a draft was promoted (the
|
|
3218
|
+
* @returns {boolean} true when a draft or a chip mark was promoted (the
|
|
3219
|
+
* caller repaints).
|
|
3174
3220
|
*/
|
|
3175
3221
|
function promoteSource(ref, quote) {
|
|
3222
|
+
return dropSourceMarks(ref, ["draft", "chip"]);
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
/** Whether a capsule for this coordinate is already drawn in a transcript. */
|
|
3226
|
+
function capsuleOnSourcePage(ref) {
|
|
3176
3227
|
const wire = normalizeRef(ref);
|
|
3177
3228
|
if (wire === null) return false;
|
|
3178
|
-
|
|
3179
|
-
|
|
3229
|
+
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return false;
|
|
3230
|
+
const key = capsuleJumpKey("", null, wire);
|
|
3231
|
+
for (const element of document.querySelectorAll("[data-dshq-jump]")) {
|
|
3232
|
+
if (typeof element.getAttribute !== "function") continue;
|
|
3233
|
+
if (element.getAttribute("data-dshq-jump") === key) return true;
|
|
3234
|
+
}
|
|
3235
|
+
return false;
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
/**
|
|
3239
|
+
* Mark (or unmark) the coordinate a composer chip minted as retracted.
|
|
3240
|
+
* `paintAnchors` skips a draft coordinate, so marking is what makes the
|
|
3241
|
+
* source mark disappear when the reader takes the chip back out; the
|
|
3242
|
+
* chip's own ordinal and card stay usable throughout. Only a coordinate
|
|
3243
|
+
* minted with `saveSource(..., { chip: true })` is touched, so a passage
|
|
3244
|
+
* that was sent earlier keeps its highlight even when the same passage
|
|
3245
|
+
* is quoted into the draft and deleted again.
|
|
3246
|
+
*
|
|
3247
|
+
* @returns {boolean} true when the state changed (the caller repaints).
|
|
3248
|
+
*/
|
|
3249
|
+
function setChipRetracted(info, retracted) {
|
|
3250
|
+
if (info === null || info === undefined) return false;
|
|
3251
|
+
if (info.ref === null || info.ref === undefined) return false;
|
|
3252
|
+
const id = refTextKeyOf(info.ref);
|
|
3253
|
+
if (id === "") return false;
|
|
3254
|
+
let changed = false;
|
|
3180
3255
|
for (const list of sourceIndex.values()) {
|
|
3181
3256
|
for (const entry of list) {
|
|
3182
|
-
if (entry.
|
|
3183
|
-
if (
|
|
3184
|
-
|
|
3185
|
-
|
|
3257
|
+
if (entry.chip !== true) continue;
|
|
3258
|
+
if (refTextKeyOf(entry) !== id) continue;
|
|
3259
|
+
if (retracted === true) {
|
|
3260
|
+
if (entry.draft === true) continue;
|
|
3261
|
+
entry.draft = true;
|
|
3262
|
+
} else {
|
|
3263
|
+
if (entry.draft !== true) continue;
|
|
3264
|
+
delete entry.draft;
|
|
3265
|
+
}
|
|
3266
|
+
changed = true;
|
|
3186
3267
|
}
|
|
3187
3268
|
}
|
|
3188
|
-
if (
|
|
3189
|
-
|
|
3269
|
+
if (changed) {
|
|
3270
|
+
persistSources();
|
|
3271
|
+
scheduleAnchors();
|
|
3272
|
+
}
|
|
3273
|
+
return changed;
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
/**
|
|
3277
|
+
* A chip that was in a live draft is gone, and no capsule carries its
|
|
3278
|
+
* coordinate: the reader took the quote back out instead of sending it.
|
|
3279
|
+
* The mark the chip minted has to go with it, or the source keeps a
|
|
3280
|
+
* highlight (and ordinal) for a quote nothing holds any more. Marking the
|
|
3281
|
+
* coordinate as a draft is exactly the state `paintAnchors` already skips,
|
|
3282
|
+
* and it stays reversible: a later send promotes it again through
|
|
3283
|
+
* `promoteSource`, and a chip that comes back (a slow host render, the
|
|
3284
|
+
* reader re-adding the same quote) lifts the mark through
|
|
3285
|
+
* `restoreChipSource`.
|
|
3286
|
+
*
|
|
3287
|
+
* @returns {boolean} true when a mark was retracted.
|
|
3288
|
+
*/
|
|
3289
|
+
function retractChipSource(info) {
|
|
3290
|
+
// A capsule with this coordinate means the quote did leave the draft.
|
|
3291
|
+
if (info === null || info === undefined || info.ref === null || info.ref === undefined) return false;
|
|
3292
|
+
if (capsuleOnSourcePage(info.ref)) return false;
|
|
3293
|
+
return setChipRetracted(info, true);
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
/** The chip is back in the draft (or never really left): paint again. */
|
|
3297
|
+
function restoreChipSource(info) {
|
|
3298
|
+
return setChipRetracted(info, false);
|
|
3190
3299
|
}
|
|
3191
3300
|
|
|
3192
3301
|
/**
|
|
@@ -4034,19 +4143,27 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4034
4143
|
function syncDraftBars() {
|
|
4035
4144
|
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return;
|
|
4036
4145
|
const seen = new Set();
|
|
4146
|
+
const open = new Set();
|
|
4147
|
+
const present = new Map();
|
|
4037
4148
|
for (const card of document.querySelectorAll(CARD_SELECTOR)) {
|
|
4149
|
+
open.add(card);
|
|
4038
4150
|
const chips = Array.from(card.querySelectorAll("*")).filter((node) => node.getAttribute("data-dshq-chip") !== null);
|
|
4151
|
+
const pairs = [];
|
|
4152
|
+
const held = new Map();
|
|
4153
|
+
for (const chip of chips) {
|
|
4154
|
+
const info = chipInfoFor(chip);
|
|
4155
|
+
if (info === null) continue;
|
|
4156
|
+
pairs.push({ chip, info });
|
|
4157
|
+
const key = info.ref === null || info.ref === undefined ? "" : refTextKeyOf(info.ref);
|
|
4158
|
+
if (key !== "") held.set(key, info);
|
|
4159
|
+
}
|
|
4160
|
+
present.set(card, held);
|
|
4039
4161
|
if (chips.length === 0) {
|
|
4040
4162
|
removeDraftBar(card);
|
|
4041
4163
|
continue;
|
|
4042
4164
|
}
|
|
4043
4165
|
seen.add(card);
|
|
4044
4166
|
const bar = ensureDraftBar(card);
|
|
4045
|
-
const pairs = [];
|
|
4046
|
-
for (const chip of chips) {
|
|
4047
|
-
const info = chipInfoFor(chip);
|
|
4048
|
-
if (info !== null) pairs.push({ chip, info });
|
|
4049
|
-
}
|
|
4050
4167
|
const signature = chips.length + "|" + pairs.map(({ info }) => (info.ref === null ? "" : encodeQuoteRef(info.ref)) + ":" + info.quote + ":" + info.comment).join("\u0001");
|
|
4051
4168
|
if (bar.dataset.signature === signature) continue;
|
|
4052
4169
|
bar.dataset.signature = signature;
|
|
@@ -4104,6 +4221,44 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4104
4221
|
bar.append(el(document, "span", { class: "dshq-db-more", text: "+" + String(pairs.length - DRAFTBAR_LIMIT) }));
|
|
4105
4222
|
}
|
|
4106
4223
|
}
|
|
4224
|
+
// Reconcile after every card was scanned, so a coordinate that two
|
|
4225
|
+
// composers hold (the main draft and a side thread's, same ref) is only
|
|
4226
|
+
// retracted when the last chip that carries it is gone. A chip that a
|
|
4227
|
+
// card held on the last pass and does not hold now was taken out of the
|
|
4228
|
+
// draft; the card itself is still on screen, so this is a removal and
|
|
4229
|
+
// not a session switch (a card that left the page is dropped below). A
|
|
4230
|
+
// key that appears again — a slow host render, the reader re-adding the
|
|
4231
|
+
// same quote — lifts the retraction it caused.
|
|
4232
|
+
const heldNow = new Set();
|
|
4233
|
+
for (const held of present.values()) {
|
|
4234
|
+
for (const key of held.keys()) heldNow.add(key);
|
|
4235
|
+
}
|
|
4236
|
+
const heldBefore = new Set();
|
|
4237
|
+
for (const was of cardChips.values()) {
|
|
4238
|
+
for (const key of was.keys()) heldBefore.add(key);
|
|
4239
|
+
}
|
|
4240
|
+
for (const held of present.values()) {
|
|
4241
|
+
for (const [key, info] of held) {
|
|
4242
|
+
if (heldBefore.has(key)) continue;
|
|
4243
|
+
restoreChipSource(info);
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4246
|
+
for (const [card, was] of cardChips) {
|
|
4247
|
+
if (!open.has(card)) continue;
|
|
4248
|
+
for (const [key, info] of was) {
|
|
4249
|
+
if (heldNow.has(key)) continue;
|
|
4250
|
+
retractChipSource(info);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
for (const [card, held] of present) {
|
|
4254
|
+
if (held.size === 0) cardChips.delete(card);
|
|
4255
|
+
else cardChips.set(card, held);
|
|
4256
|
+
}
|
|
4257
|
+
// A composer card that left the page took its chips with it, but that
|
|
4258
|
+
// is not the reader removing them: forget the ledger without retracting.
|
|
4259
|
+
for (const card of Array.from(cardChips.keys())) {
|
|
4260
|
+
if (!open.has(card)) cardChips.delete(card);
|
|
4261
|
+
}
|
|
4107
4262
|
for (const card of Array.from(draftBars.keys())) {
|
|
4108
4263
|
if (!seen.has(card)) removeDraftBar(card);
|
|
4109
4264
|
}
|
|
@@ -4115,6 +4270,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4115
4270
|
clearTimeout(draftBarTimer);
|
|
4116
4271
|
draftBarTimer = null;
|
|
4117
4272
|
}
|
|
4273
|
+
cardChips.clear();
|
|
4118
4274
|
for (const card of Array.from(draftBars.keys())) removeDraftBar(card);
|
|
4119
4275
|
}
|
|
4120
4276
|
|
|
@@ -5422,11 +5578,14 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
5422
5578
|
// Remember the row this came from before anything can fail, and put the
|
|
5423
5579
|
// same coordinate into the sent text as a wire-only ref line: the table
|
|
5424
5580
|
// survives this window, the ref survives a reload and an empty table.
|
|
5581
|
+
// The `chip` mark says a chip is what holds this quote in the draft, so
|
|
5582
|
+
// `syncDraftBars` can retract the source mark when the reader takes the
|
|
5583
|
+
// chip back out without sending it.
|
|
5425
5584
|
let wireRef = null;
|
|
5426
5585
|
if (source !== null) {
|
|
5427
5586
|
// The source table keys on the sentence as it was rendered, so the
|
|
5428
5587
|
// Markdown marks this plugin added must not be part of the key.
|
|
5429
|
-
const saved = saveSource(plainOf(quoteText), sessionId, source);
|
|
5588
|
+
const saved = saveSource(plainOf(quoteText), sessionId, source, { chip: true });
|
|
5430
5589
|
if (saved !== null) {
|
|
5431
5590
|
wireRef = { v: 1, s: saved.sessionId, a: saved.attr, k: saved.key, t: saved.turn, n: saved.n };
|
|
5432
5591
|
if (ROLE_CODES.has(saved.role)) wireRef.r = saved.role;
|
|
@@ -5466,6 +5625,9 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
5466
5625
|
focusComposer();
|
|
5467
5626
|
return;
|
|
5468
5627
|
}
|
|
5628
|
+
// The plain-text form took the quote instead: no chip holds it any more,
|
|
5629
|
+
// so the retraction path must never mistake a later removal for this one.
|
|
5630
|
+
if (wireRef !== null) dropSourceMarks(wireRef, ["chip"]);
|
|
5469
5631
|
if (insertPayload(entry, buildPayload(quoteText, trimmed, code, meta), composerText().trim() !== "")) focusComposer();
|
|
5470
5632
|
else {
|
|
5471
5633
|
noteFailure("insert", "the plain-text fallback failed too");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-client-ui-quote",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.5-lab.44",
|
|
4
4
|
"description": "Turn any DSH selection into a reviewable quote — comment / add to chat / side chat, source highlights and jump-back, a searchable review list, side threads, revisions, and per-quote reply badges.",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"license": "MIT",
|
package/test/README.md
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
# test
|
|
2
2
|
|
|
3
|
-
离线回归网。没有测试框架,全部是 Node 脚本;`npm test`
|
|
3
|
+
离线回归网。没有测试框架,全部是 Node 脚本;`npm test` 依次跑四套,共 **686 组**断言。
|
|
4
4
|
|
|
5
5
|
| 文件 | 组数 | 覆盖 |
|
|
6
6
|
| --- | --- | --- |
|
|
7
7
|
| `check.mjs` | 12 | 清单字段、`exports`、图标、两份 locale 词典、槽位注册、`codec.serialize`、无强制 peer、`files` 发货白名单、截图被根 README 引用。 |
|
|
8
|
-
| `capsule.mjs` |
|
|
9
|
-
| `panel.mjs` |
|
|
8
|
+
| `capsule.mjs` | 470 | 输入框 → 发送后链路:线格式、芯片插入、胶囊、悬停卡片、跳回、高亮(含草稿坐标不落色、`promoteSource` 转正后才补画、**草稿芯片删掉后标记收回 / 回来再补画**)、草稿聚合条、侧边对话(内置隐藏线程 / fork / better)、逐条徽章、diff 引用。 |
|
|
9
|
+
| `panel.mjs` | 160 | 引用清单页:解析、搜索、Markdown/JSON 导出、读取、注册、审阅工作台、本机索引、侧线程回流。 |
|
|
10
|
+
| `host.mjs` | 44 | 宿主半:隐性上下文种子(继承切点 → 收口回合包着 `session-context` 消息 → descriptor)、切点与 descriptor 顺序、`agents.create` 参数、探针与同源围栏、路由注册与卸载。 |
|
|
10
11
|
| `dom.mjs` | — | 最小 DOM / MutationObserver / Range / `localStorage` 垫片。新用到的 DOM API **先补这里**,否则 `capsule.mjs` 在 Node 里直接挂。 |
|
|
11
|
-
| `capsule.html` | 51 项 | 真浏览器版,`file://` 直接打开;结果写进页面标题与 `#probe-out
|
|
12
|
+
| `capsule.html` | 51 项 | 真浏览器版,`file://` 直接打开;结果写进页面标题与 `#probe-out`。视口要给足(1600×2000),否则浮层摆放的两条几何断言会因空间不够而失败。 |
|
|
12
13
|
|
|
13
14
|
## 命令
|
|
14
15
|
|
|
15
16
|
```bash
|
|
16
|
-
npm test # check + capsule + panel
|
|
17
|
+
npm test # check + capsule + panel + host
|
|
17
18
|
node test/check.mjs # 只跑清单/契约
|
|
18
19
|
node test/capsule.mjs # 只跑发送后链路
|
|
19
20
|
node test/panel.mjs # 只跑清单页
|
|
21
|
+
node test/host.mjs # 只跑宿主半(隐藏线程创建)
|
|
20
22
|
```
|
|
21
23
|
|
|
22
24
|
## 红线
|
|
23
25
|
|
|
24
26
|
- 新增行为必须带断言;**不许删断言或放宽期望值换绿**。
|
|
25
|
-
- 若断言数变化,根 README 四处与 CHANGELOG 一处的数字要同步(
|
|
27
|
+
- 若断言数变化,根 README 四处与 CHANGELOG 一处的数字要同步(686 = 12 + 470 + 160 + 44)。
|
|
26
28
|
- 测试跑在 `dom.mjs` 垫片上:先垫 API,再写实现,不要反过来为了过测试而改期望。
|
package/test/capsule.mjs
CHANGED
|
@@ -2387,6 +2387,29 @@ try {
|
|
|
2387
2387
|
JSON.stringify([landed.length, String(landed[0]?.ref ?? "").slice(0, 24)])
|
|
2388
2388
|
);
|
|
2389
2389
|
|
|
2390
|
+
// 3a. 这条坐标要和芯片绑定在一起:删除芯片(没有发送)时,聚合条的回执就是
|
|
2391
|
+
// 靠它把原文上的高亮收回去的(见后面的「原文侧高亮」段)。
|
|
2392
|
+
const chipMarked = [];
|
|
2393
|
+
shells.set("session-chip-mark", {
|
|
2394
|
+
actions: { captureInsertion: () => ({ start: 0, end: 0, draftRev: 3 }) },
|
|
2395
|
+
insertReference(attachment) {
|
|
2396
|
+
chipMarked.push(attachment);
|
|
2397
|
+
return true;
|
|
2398
|
+
}
|
|
2399
|
+
});
|
|
2400
|
+
const markPane = pane("session-chip-mark", "芯片会带走这条坐标");
|
|
2401
|
+
markPane.run.parentNode.setAttribute("data-chat-anchor-key", "row-chip-mark");
|
|
2402
|
+
markPane.run.parentNode.setAttribute("data-chat-turn", "12");
|
|
2403
|
+
selectIn(markPane.run, "芯片会带走这条坐标");
|
|
2404
|
+
barOf().dispatch("click", { target: barOf().querySelector('[data-action="add"]') });
|
|
2405
|
+
const markTable = JSON.parse(globalThis.localStorage.getItem("dsh-client-ui-quote:v2:quote-source") ?? "{}");
|
|
2406
|
+
const markSaved = Object.values(markTable).flat().find((entry) => entry?.key === "row-chip-mark");
|
|
2407
|
+
check(
|
|
2408
|
+
"a quote added to the chat ties its coordinate to the chip",
|
|
2409
|
+
chipMarked.length === 1 && markSaved?.chip === true,
|
|
2410
|
+
JSON.stringify({ inserted: chipMarked.length, saved: markSaved === undefined ? null : { key: markSaved.key, chip: markSaved.chip } })
|
|
2411
|
+
);
|
|
2412
|
+
|
|
2390
2413
|
// 3b. A draft that already holds the reader's wording: the quote goes to
|
|
2391
2414
|
// the top and the wording moves to a line of its own below it.
|
|
2392
2415
|
const lineCalls = [];
|
|
@@ -3100,6 +3123,186 @@ try {
|
|
|
3100
3123
|
JSON.stringify({ draft: reDraft?.draft, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3101
3124
|
);
|
|
3102
3125
|
|
|
3126
|
+
// 主会话「添加到对话」写进草稿的芯片:坐标以 `chip` 记在表里。芯片还在草稿
|
|
3127
|
+
// 里时照常落色;读者点聚合条的 × 或直接退格把它删掉(并没有发送)之后,
|
|
3128
|
+
// 原文上那块高亮必须跟着消失——否则就留下一条无主的黄条。
|
|
3129
|
+
const chipRow = dom.document.createElement("div");
|
|
3130
|
+
chipRow.setAttribute("data-chat-anchor-key", "row-chip");
|
|
3131
|
+
chipRow.setAttribute("data-chat-turn", "8");
|
|
3132
|
+
chipRow.textContent = "前文 加进草稿又后悔的引用 后文";
|
|
3133
|
+
pane.append(chipRow);
|
|
3134
|
+
const chipQuote = "加进草稿又后悔的引用";
|
|
3135
|
+
const chipRef = { v: 1, s: "session-anchor", a: "data-chat-anchor-key", k: "row-chip", t: "8", n: 1 };
|
|
3136
|
+
const chipSaved = testing.saveSource(chipQuote, "session-anchor", { attr: "data-chat-anchor-key", key: "row-chip", turn: "8" }, { chip: true });
|
|
3137
|
+
check(
|
|
3138
|
+
"a coordinate minted by a composer chip carries the chip mark",
|
|
3139
|
+
chipSaved !== null && chipSaved.chip === true,
|
|
3140
|
+
JSON.stringify(chipSaved)
|
|
3141
|
+
);
|
|
3142
|
+
const chipCard = dom.document.createElement("div");
|
|
3143
|
+
chipCard.setAttribute("data-composer-card", "");
|
|
3144
|
+
const chipNode = dom.document.createElement("span");
|
|
3145
|
+
testing.rememberChip(chipQuote, "", "session-anchor", false, { ref: chipRef });
|
|
3146
|
+
chipNode.textContent = testing.chipLabel(chipQuote, "");
|
|
3147
|
+
chipNode.setAttribute("data-dshq-chip", "1");
|
|
3148
|
+
chipCard.append(chipNode);
|
|
3149
|
+
dom.document.body.append(chipCard);
|
|
3150
|
+
const chipBaseline = highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0;
|
|
3151
|
+
testing.syncDraftBars();
|
|
3152
|
+
testing.paintAnchors();
|
|
3153
|
+
check(
|
|
3154
|
+
"a quote held by a draft chip is painted on its source",
|
|
3155
|
+
(highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0) === chipBaseline + 1,
|
|
3156
|
+
JSON.stringify({ baseline: chipBaseline, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3157
|
+
);
|
|
3158
|
+
chipNode.remove();
|
|
3159
|
+
testing.syncDraftBars();
|
|
3160
|
+
testing.paintAnchors();
|
|
3161
|
+
const chipTable = JSON.parse(globalThis.localStorage.getItem("dsh-client-ui-quote:v2:quote-source") ?? "{}");
|
|
3162
|
+
const chipStoredDraft = Object.values(chipTable).some((list) => Array.isArray(list) && list.some((entry) => entry?.key === "row-chip" && entry?.draft === true));
|
|
3163
|
+
check(
|
|
3164
|
+
"removing the chip without sending drops the source highlight",
|
|
3165
|
+
(highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0) === chipBaseline,
|
|
3166
|
+
JSON.stringify({ baseline: chipBaseline, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3167
|
+
);
|
|
3168
|
+
check("the retraction is stored, so a reload cannot resurrect the mark", chipStoredDraft, JSON.stringify(chipStoredDraft));
|
|
3169
|
+
// 又把同一句加回草稿(或宿主慢一拍才把它画回来):高亮应当自己回来。
|
|
3170
|
+
const chipAgain = dom.document.createElement("span");
|
|
3171
|
+
chipAgain.textContent = testing.chipLabel(chipQuote, "");
|
|
3172
|
+
chipAgain.setAttribute("data-dshq-chip", "1");
|
|
3173
|
+
chipCard.append(chipAgain);
|
|
3174
|
+
testing.syncDraftBars();
|
|
3175
|
+
testing.paintAnchors();
|
|
3176
|
+
check(
|
|
3177
|
+
"the chip coming back lifts the retraction",
|
|
3178
|
+
(highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0) === chipBaseline + 1,
|
|
3179
|
+
JSON.stringify({ baseline: chipBaseline, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3180
|
+
);
|
|
3181
|
+
chipAgain.remove();
|
|
3182
|
+
testing.syncDraftBars();
|
|
3183
|
+
testing.paintAnchors();
|
|
3184
|
+
const chipPromoted = testing.promoteSource(chipRef, chipQuote);
|
|
3185
|
+
testing.paintAnchors();
|
|
3186
|
+
check(
|
|
3187
|
+
"the quote turning up as a capsule paints it again",
|
|
3188
|
+
chipPromoted === true && (highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0) === chipBaseline + 1,
|
|
3189
|
+
JSON.stringify({ promoted: chipPromoted, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3190
|
+
);
|
|
3191
|
+
|
|
3192
|
+
// 已经作为胶囊发出去过的坐标,再被加进草稿、又删掉:页面上有它的胶囊,
|
|
3193
|
+
// 所以这不是「读者反悔」,标记必须留着。
|
|
3194
|
+
const sentRow = dom.document.createElement("div");
|
|
3195
|
+
sentRow.setAttribute("data-chat-anchor-key", "row-sent-chip");
|
|
3196
|
+
sentRow.setAttribute("data-chat-turn", "9");
|
|
3197
|
+
sentRow.textContent = "前文 已经发出去又被加进草稿的引用 后文";
|
|
3198
|
+
pane.append(sentRow);
|
|
3199
|
+
const sentQuote = "已经发出去又被加进草稿的引用";
|
|
3200
|
+
const sentRef = { v: 1, s: "session-anchor", a: "data-chat-anchor-key", k: "row-sent-chip", t: "9", n: 1 };
|
|
3201
|
+
testing.saveSource(sentQuote, "session-anchor", { attr: "data-chat-anchor-key", key: "row-sent-chip", turn: "9" }, { chip: true });
|
|
3202
|
+
const sentCard = dom.document.createElement("div");
|
|
3203
|
+
sentCard.setAttribute("data-composer-card", "");
|
|
3204
|
+
const sentChip = dom.document.createElement("span");
|
|
3205
|
+
testing.rememberChip(sentQuote, "", "session-anchor", false, { ref: sentRef });
|
|
3206
|
+
sentChip.textContent = testing.chipLabel(sentQuote, "");
|
|
3207
|
+
sentChip.setAttribute("data-dshq-chip", "1");
|
|
3208
|
+
sentCard.append(sentChip);
|
|
3209
|
+
dom.document.body.append(sentCard);
|
|
3210
|
+
testing.syncDraftBars();
|
|
3211
|
+
testing.paintAnchors();
|
|
3212
|
+
const sentBaseline = highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0;
|
|
3213
|
+
const sentCapsule = dom.document.createElement("div");
|
|
3214
|
+
sentCapsule.setAttribute("data-dshq-jump", JSON.stringify(["ref", "session-anchor", "data-chat-anchor-key", "row-sent-chip", "9", 1]));
|
|
3215
|
+
dom.document.body.append(sentCapsule);
|
|
3216
|
+
sentChip.remove();
|
|
3217
|
+
testing.syncDraftBars();
|
|
3218
|
+
testing.paintAnchors();
|
|
3219
|
+
check(
|
|
3220
|
+
"a sent capsule keeps the mark when its draft chip is removed",
|
|
3221
|
+
(highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0) === sentBaseline,
|
|
3222
|
+
JSON.stringify({ baseline: sentBaseline, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3223
|
+
);
|
|
3224
|
+
sentCapsule.remove();
|
|
3225
|
+
sentCard.remove();
|
|
3226
|
+
testing.syncDraftBars();
|
|
3227
|
+
|
|
3228
|
+
// 整个输入框离开页面(切会话)不是「读者删了芯片」:不收回、也不误删账本。
|
|
3229
|
+
const leaveRow = dom.document.createElement("div");
|
|
3230
|
+
leaveRow.setAttribute("data-chat-anchor-key", "row-leave-chip");
|
|
3231
|
+
leaveRow.setAttribute("data-chat-turn", "10");
|
|
3232
|
+
leaveRow.textContent = "前文 切走会话时还在草稿里的引用 后文";
|
|
3233
|
+
pane.append(leaveRow);
|
|
3234
|
+
const leaveQuote = "切走会话时还在草稿里的引用";
|
|
3235
|
+
const leaveRef = { v: 1, s: "session-anchor", a: "data-chat-anchor-key", k: "row-leave-chip", t: "10", n: 1 };
|
|
3236
|
+
testing.saveSource(leaveQuote, "session-anchor", { attr: "data-chat-anchor-key", key: "row-leave-chip", turn: "10" }, { chip: true });
|
|
3237
|
+
const leaveCard = dom.document.createElement("div");
|
|
3238
|
+
leaveCard.setAttribute("data-composer-card", "");
|
|
3239
|
+
const leaveChip = dom.document.createElement("span");
|
|
3240
|
+
testing.rememberChip(leaveQuote, "", "session-anchor", false, { ref: leaveRef });
|
|
3241
|
+
leaveChip.textContent = testing.chipLabel(leaveQuote, "");
|
|
3242
|
+
leaveChip.setAttribute("data-dshq-chip", "1");
|
|
3243
|
+
leaveCard.append(leaveChip);
|
|
3244
|
+
dom.document.body.append(leaveCard);
|
|
3245
|
+
testing.syncDraftBars();
|
|
3246
|
+
testing.paintAnchors();
|
|
3247
|
+
const leaveBaseline = highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0;
|
|
3248
|
+
leaveCard.remove();
|
|
3249
|
+
testing.syncDraftBars();
|
|
3250
|
+
testing.paintAnchors();
|
|
3251
|
+
check(
|
|
3252
|
+
"a composer that leaves the page does not drop the draft's mark",
|
|
3253
|
+
(highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0) === leaveBaseline,
|
|
3254
|
+
JSON.stringify({ baseline: leaveBaseline, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3255
|
+
);
|
|
3256
|
+
|
|
3257
|
+
// 同一坐标同时挂在两个草稿里(主会话与侧边线程共用同一个 ref,侧聊草稿也是
|
|
3258
|
+
// 这一句):删掉其中一颗芯片不代表引用离开了草稿,最后两颗都删掉才收回。
|
|
3259
|
+
const sharedRow = dom.document.createElement("div");
|
|
3260
|
+
sharedRow.setAttribute("data-chat-anchor-key", "row-shared-chip");
|
|
3261
|
+
sharedRow.setAttribute("data-chat-turn", "11");
|
|
3262
|
+
sharedRow.textContent = "前文 两个草稿共享同一个坐标的引用 后文";
|
|
3263
|
+
pane.append(sharedRow);
|
|
3264
|
+
const sharedQuote = "两个草稿共享同一个坐标的引用";
|
|
3265
|
+
const sharedRef = { v: 1, s: "session-anchor", a: "data-chat-anchor-key", k: "row-shared-chip", t: "11", n: 1 };
|
|
3266
|
+
testing.saveSource(sharedQuote, "session-anchor", { attr: "data-chat-anchor-key", key: "row-shared-chip", turn: "11" }, { chip: true });
|
|
3267
|
+
testing.rememberChip(sharedQuote, "", "session-anchor", false, { ref: sharedRef });
|
|
3268
|
+
const sharedLabel = testing.chipLabel(sharedQuote, "");
|
|
3269
|
+
const sharedCardA = dom.document.createElement("div");
|
|
3270
|
+
sharedCardA.setAttribute("data-composer-card", "");
|
|
3271
|
+
const sharedChipA = dom.document.createElement("span");
|
|
3272
|
+
sharedChipA.textContent = sharedLabel;
|
|
3273
|
+
sharedChipA.setAttribute("data-dshq-chip", "1");
|
|
3274
|
+
sharedCardA.append(sharedChipA);
|
|
3275
|
+
dom.document.body.append(sharedCardA);
|
|
3276
|
+
const sharedCardB = dom.document.createElement("div");
|
|
3277
|
+
sharedCardB.setAttribute("data-composer-card", "");
|
|
3278
|
+
const sharedChipB = dom.document.createElement("span");
|
|
3279
|
+
sharedChipB.textContent = sharedLabel;
|
|
3280
|
+
sharedChipB.setAttribute("data-dshq-chip", "1");
|
|
3281
|
+
sharedCardB.append(sharedChipB);
|
|
3282
|
+
dom.document.body.append(sharedCardB);
|
|
3283
|
+
testing.syncDraftBars();
|
|
3284
|
+
testing.paintAnchors();
|
|
3285
|
+
const sharedBaseline = highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0;
|
|
3286
|
+
sharedChipA.remove();
|
|
3287
|
+
testing.syncDraftBars();
|
|
3288
|
+
testing.paintAnchors();
|
|
3289
|
+
check(
|
|
3290
|
+
"one of two chips for one coordinate going away keeps the mark",
|
|
3291
|
+
(highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0) === sharedBaseline,
|
|
3292
|
+
JSON.stringify({ baseline: sharedBaseline, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3293
|
+
);
|
|
3294
|
+
sharedChipB.remove();
|
|
3295
|
+
testing.syncDraftBars();
|
|
3296
|
+
testing.paintAnchors();
|
|
3297
|
+
check(
|
|
3298
|
+
"the mark goes when the last chip for it is gone",
|
|
3299
|
+
(highlightRegistry.get("dshq-quote")?.ranges?.length ?? 0) === sharedBaseline - 1,
|
|
3300
|
+
JSON.stringify({ baseline: sharedBaseline, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
3301
|
+
);
|
|
3302
|
+
sharedCardA.remove();
|
|
3303
|
+
sharedCardB.remove();
|
|
3304
|
+
testing.syncDraftBars();
|
|
3305
|
+
|
|
3103
3306
|
const longQuote = "长".repeat(200);
|
|
3104
3307
|
const longRef = { s: "session-anchor", a: "data-chat-anchor-key", k: "row-anchor", t: "5", n: 1 };
|
|
3105
3308
|
testing.rememberQuoteText(longRef, longQuote);
|