dsh-client-ui-quote 0.12.2-lab.21 → 0.12.2-lab.23

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 CHANGED
@@ -5,6 +5,12 @@
5
5
  ### Changed
6
6
 
7
7
  - 发布工作流:推 `v*` 标签时,除了发 npm `latest`,还会按 `CHANGELOG.md` 同名小节建 GitHub Release(已存在则更新 notes,重跑安全);`publish-stable` 的 `contents` 权限从 read 提到 write。小节缺失时用一句兜底,不让文档缺失把已发布的 npm 包拖成失败。
8
+ - 纯文档提交不再切 lab 预发布:`publish.yml` 对 lab 分支加 `paths-ignore`(`*.md` / `docs/**` / `submission/**` / `.github/**`);标签推送不受路径过滤影响,发版照常。
9
+
10
+ ### Fixed
11
+
12
+ - 表格里划选的引用不发高亮:Chromium 用 `\t` 分隔同一行的单元格、`\n` 分隔行,高亮却按文本节点直接拼接去找,永远对不上;现在按块级边界(单元格 / 行 / 段落 / 列表项 / `<br>`)还原后再量 Range,跨单元格、跨行、跨段落都能画回来。
13
+ - 输入框里已有文字时,引用芯片被粘在最后一句后面:现在引用落到草稿**最上方**,已写的文字另起一行接在下面,光标留在末尾。
8
14
 
9
15
  ## 0.12.1 — 2026-10-20
10
16
 
@@ -22,7 +28,7 @@
22
28
  ### Changed
23
29
 
24
30
  - `package.json` 描述不再以 Kimi 开头,改为「把任意选区变成可审阅的引用对象」。
25
- - 离线断言 **549 组**(check 12 + capsule 384 + panel 153);真浏览器 **51/51**(1600×2000 视口);隔离 DSH web 实例中 `probe().nativeSideChat === true`。
31
+ - 离线断言 **557 组**(check 12 + capsule 392 + panel 153);真浏览器 **51/51**(1600×2000 视口);隔离 DSH web 实例中 `probe().nativeSideChat === true`。
26
32
 
27
33
  ### Fixed
28
34
 
package/README.md CHANGED
@@ -34,15 +34,17 @@
34
34
 
35
35
  ![界面总览:浮层 → 评论 → 胶囊卡片 → 引用清单](docs/screenshots/overview.gif)
36
36
 
37
+ 素材说明见 [`docs/screenshots/README.md`](docs/screenshots/README.md)。
38
+
37
39
  ## 功能
38
40
 
39
41
  | 动作 | 行为 |
40
42
  | --- | --- |
41
43
  | **评论** | 浮层就地变成评论框(`写一句评论…` + `取消` / `添加到对话`)。评论框里**引用正文也在自己的输入框里、可以改**——选多了就当场裁掉。评论框打开期间,原选区用 Chromium 的 `CSS.highlights` 保持高亮,焦点进了输入框也不会丢参照;不支持该 API 的宿主静默退化,评论框里仍有原文。`Enter` 提交、`Esc` 关闭、引用或评论为空时确认按钮禁用;输入法组合输入不会误触发。 |
42
- | **添加到对话** | 直接把引用加进输入框,不弹评论框。 |
44
+ | **添加到对话** | 直接把引用加进输入框,不弹评论框。输入框里已经写好的文字会留在原位:引用落到草稿**最上方**,你的文字另起一行接在下面,光标停在末尾,接着写正式提问即可。 |
43
45
  | **侧边对话** | 把引用写进一条**独立的侧边对话**的输入框:优先用 dsh-better-sidebar 的侧边线程;没装/禁用时自动降级为**fork 一个继承主会话历史的子会话**,用宿主内置的 `subagentchat` 页在右侧栏打开,再把引用写进它的草稿。两种后端都**什么都不发送**,线程独立运行、不污染主会话,页签自动在右侧栏打开。同一条主会话只开一条线程,之后每次点都在同一条线程的草稿后面接着追加。鲸鱼蓝(`#4d6bfe`)的图标是它在浮层里的标识。**一条引用一条线程**:浮层里还有一项「另开侧边对话」,给这条引用单独开一条线程;之后对同一条引用再选侧边对话,回到的就是它自己的那条。引用清单页底部会列出这个会话的**全部侧边线程**,每条都能重新打开(原生兜底时同样用内置页打开)。 |
44
46
  | **键盘直达** | `Alt+Q` 引用 / `Alt+C` 评论 / `Alt+B` 侧边对话——同一个浮层的三个动作,不弹浮层直接办。走 DSH 的快捷键服务注册,所以出现在设置里、可改键、与宿主键位冲突会在注册时报错;没有选区时返回 `pass`,按键不会被吞掉。 |
45
- | **原文侧高亮** | 引用落进草稿或发出后,被引用的原句回到**来源消息**里留下淡黄高亮与**序号角标**(序号就是坐标里的同句序号,和 `「引用N」` 对得上)。支持 `CSS.highlights` 的宿主由浏览器**直接绘制高亮**,滚动、流式输出、虚拟列表重排都跟手,不靠逐帧重画去追;不支持时才退回浮层矩形(只画序号徽章)。行不在视口里、文字已经变了、宿主不给 `Range` 时就静默跳过,**不改宿主的 DOM**(高亮不包 `mark`,也不搬节点)。 |
47
+ | **原文侧高亮** | 引用落进草稿或发出后,被引用的原句回到**来源消息**里留下淡黄高亮与**序号角标**(序号就是坐标里的同句序号,和 `「引用N」` 对得上)。支持 `CSS.highlights` 的宿主由浏览器**直接绘制高亮**,滚动、流式输出、虚拟列表重排都跟手,不靠逐帧重画去追;不支持时才退回浮层矩形(只画序号徽章)。行不在视口里、文字已经变了、宿主不给 `Range` 时就静默跳过,**不改宿主的 DOM**(高亮不包 `mark`,也不搬节点)。表格单元格、表格行、段落、列表项、`<br>` 这些块级边界按浏览器选区报告的 `\t` / `\n` 对齐,所以跨单元格、跨行、跨段落的引用也能画回来。 |
46
48
  | **聚合引用条** | 输入框上方一条 **`引用 ×N`**:每颗芯片一个摘要胶囊,点一下跳回原文(跳不到就把光标放到芯片上,一次退格删掉),悬停就是完整卡片;每条末尾有 **`✎` 编辑**与 **`×` 移除**:编辑把原引用与评论回填进注释框,确认后旧芯片让位、新芯片原地插入;移除优先走宿主自己的 `consumeToken`(按芯片的 detect 区间与 `draftRev` 做 CAS 精确删除,不会碰错别的文本),宿主没有这个读取面或同名芯片无法区分时才退回「聚焦芯片 + 退格」并提示。**整条索引存在本机**:宿主会跨刷新恢复草稿里的芯片,插件的索引也一起恢复,所以刷新后条、卡片与「打开文件」都还在。主会话与侧边线程各自的输入框各有一条;芯片删光时条自己消失。整条带 `role=toolbar`,每颗胶囊的可访问名是完整引用+评论,不是截断后的标签。 |
47
49
  | **来源头** | 发送块尾部的坐标行前面多一段人可读的出处:**`※ 引自第 12 轮 · 助手 │q1:…`**,模型不解码也知道来自哪一轮、谁说的;角色属性宿主没给时就只写轮次。胶囊悬停卡片里同样显示 `引自第 12 轮 · 助手`,按界面语言本地化。 |
48
50
  | **Diff 划选** | 在 diff / 每轮改动面板里划选也走同一个浮层:引用按**代码块**发,围栏信息位带探测到的文件路径与行号(`src/app.ts#L12`);路径、行号是宿主 DOM 上的探测,拿不到就只带代码,不影响引用本身。有路径时卡片/清单可直接打开文件并定位到起始行。 |
@@ -136,7 +138,7 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
136
138
 
137
139
  本机切换这三档(这台开发机专用的辅助脚本,不在仓库里):`node .dsh-quote-work/profile-mode.mjs link|lab|stable|status`。换完在窗口里 `Ctrl+Shift+R` 生效;只有宿主半有改动时才需要重启桌面端。
138
140
 
139
- 两条线的断言入口相同:`npm test`(549 项:check 12 + capsule 384 + panel 153);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
141
+ 两条线的断言入口相同:`npm test`(557 项:check 12 + capsule 392 + panel 153);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
140
142
 
141
143
  **推送通道**:`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 到远端。
142
144
 
@@ -156,7 +158,7 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
156
158
 
157
159
  1. 在会话正文、**文件预览**或**终端**里**拖动选中**一段文字(后两者没有自己的会话 id,引用会落进当前屏幕上那条会话,并且以**围栏代码块**的线格式发给模型;输入框内的选区属于改稿,不触发)。
158
160
  2. 选区**下方**浮出浮层(下方空间不够时才改到上方),选择 **评论**、**添加到对话**、**侧边对话** 或 **另开侧边对话**。同一组动作也有键位:**`Alt+Q`** 直接引用、**`Alt+C`** 直接开评论框、**`Alt+B`** 直接开侧边对话(都在设置里可改;没有选区时按键原样让给其它快捷键)。
159
- 3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**;评论框里还有一格 **`让模型逐条回应`** 勾选项,勾上这条引用就带着逐条指令发出去),页面选区被清除、焦点交给输入框,可以接着写正式提问。
161
+ 3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**;评论框里还有一格 **`让模型逐条回应`** 勾选项,勾上这条引用就带着逐条指令发出去),页面选区被清除、焦点交给输入框,可以接着写正式提问。输入框里已经有文字时,引用落在**最上方**,你的文字另起一行接在下面。
160
162
  4. **另开侧边对话**给这条引用单独开一条线程(之后对同一条引用再选侧边对话,回到的就是它自己的那条);**侧边对话**写的是另一条会话的输入框:右侧栏打开该线程的页签(标题取引用首行),引用以纯块引用形态落在它的草稿里,**不发送**;你接着在这条线程里写自己的问题、自己按发送。同一条主会话只会有一条侧边线程,第二次点第三项就把新引用接在草稿后面。同一会话上一次请求还在进行时,再次点击会并入第一次,不会开出两条重复线程。
161
163
  5. 浮层收起条件:单击别处、选区清空、按 `Esc`、滚动会话、窗口尺寸变化,以及动作完成之后。双击选词不会把浮层关掉。
162
164
  6. 发送出去以后,消息里那段块会显示成一颗**胶囊**:收起时是单行 `❝ 引用摘要 │ 评论摘要`,**鼠标移上去**会浮出一张卡片显示完整的引用与评论
@@ -322,7 +324,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
322
324
  1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
323
325
  2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标以「人可读来源头 + base64 尾巴」随消息走——前者让模型知道出处,后者让插件跨刷新回读。base64 确实占一点 prompt,这是用「不依赖本地表」换来的取舍。
324
326
  3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在同类里仍是少数。
325
- 4. **工程化**:549 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
327
+ 4. **工程化**:557 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
326
328
 
327
329
  反过来也要说清楚:**本插件不做的**——不做翻译/解释/改写/飞书投递类选区动作;不做图片/截图引用(**明确取舍**:图片引用要的是另一套胶囊/卡片/附件语义,是第二个产品的量级,塞进这里只会把「文本引用带坐标」这条主线冲淡;`dsh-selection-toolbar` 与 Kimi 桌面端已经做了,真要做建议独立成插件);不做完整 Topic 系统;不做常显大块引用。它只把「引用」这一件事做穿——但「做穿」包括发送后的管理、来源闭环和审阅,不只是浮层。
328
330
 
@@ -358,8 +360,8 @@ locale/zh.json 插件页卡片文案(中文)
358
360
  locale/en.json 插件页卡片文案(英文)
359
361
  test/check.mjs 离线自检(清单 + 组件契约)
360
362
  test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
361
- test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,384 项断言)
362
- test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 文件路径打开 + 侧边线程回流 + 审阅工作台(状态 / 标签 / 批量)+ JSON 导出 + 本地索引,152 项断言)
363
+ test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,392 项断言)
364
+ test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 文件路径打开 + 侧边线程回流 + 审阅工作台(状态 / 标签 / 批量)+ JSON 导出 + 本地索引,153 项断言)
363
365
  test/capsule.html 真浏览器里跑同一套胶囊断言(51 项,可选)
364
366
  ```
365
367
 
@@ -409,10 +411,10 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
409
411
  修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出;
410
412
  **浮层不跟着座位生死**(`conversation.input.overlay` 没挂载也能选中出栏、挂上两个会话座位既不叠加也不丢监听、
411
413
  座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份);
412
- (共 384 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、无 better-sidebar 时 `sessions.fork` + 内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
414
+ (共 392 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、**表格单元格 / 表格行 / 段落 / 列表项 / `<br>` 边界的引用高亮**、**输入框已有文字时引用置顶并将原文另起一行**、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、无 better-sidebar 时 `sessions.fork` + 内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
413
415
  `test/capsule.html` 是同一套断言的真浏览器版(51 项),用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。这一版修好了它**缺失的 `<body>` 标签**——脚本在 `<head>` 里执行时 `document.body` 为 null,这个页面以前在真浏览器里是直接坏的;并补上真 `Range` 的链接/行内代码断言(真浏览器的 `cloneContents()` 返回的是 DocumentFragment,不是元素——序列化器只认元素的话,链接在真浏览器里会退化成纯文本;离线桩现在也用真片段,不再放过这一类),另外补了 `CSS.highlights` 选区保持与关闭清理。
414
416
 
415
- `test/panel.mjs` 单独跑引用清单页与侧边线程回流(152 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
417
+ `test/panel.mjs` 单独跑引用清单页与侧边线程回流(153 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
416
418
  引用按原文逐行拆出来而评论跟着同一条记录、最新一条排最前、空查询保持全量、引用与评论都能被搜到且忽略大小写、
417
419
  Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超长引用在标题里被截断、非法时间导出为空;
418
420
  读取侧用桩 `sessions.retain` 真跑一遍——读出两行且 `hasMore` 透传、**读完必须 release**(记录 `source: "quoteList"`)、
@@ -428,7 +430,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
428
430
  `bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
429
431
  `exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
430
432
 
431
- 合计 **549 组**(check 12 + capsule 384 + panel 153),`npm test` 全绿。
433
+ 合计 **557 组**(check 12 + capsule 392 + panel 153),`npm test` 全绿。
432
434
 
433
435
  ## 已知限制
434
436
 
@@ -475,7 +477,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
475
477
  - **Markdown 预览的行号**:要精修就得读源文件,而客户端必须声明 `remote` 才能拿到它;声明错会让 client bundle 在不匹配的宿主上**静默失效**(`dsh-quote-annotate` 上游就是这么死的)。所以先不声明——文件仍然打开,只是落在顶部而不是行上。
476
478
  - **表格/列表/LaTeX 的结构保真**:链接与行内代码已做;表格要还原成 Markdown 表格,列表要还原成缩进,成本明显更高,留到有人真的需要时再做。
477
479
  - **图片/截图引用**:明确不做(理由见「本插件不做的」)。真要做请独立成插件:图片引用要的是另一套胶囊/卡片/附件语义。
478
- - **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 549 项离线断言;真浏览器(1600×2000 视口)同一套断言本轮复核 **51/51**。另外在**隔离的 DSH web 实例**(临时 `DSH_HOME`,端口 8790)里确认:工作区 link 与 **npm lab 发布包 `0.12.1-lab.15`** 都能进入 boot 列表,`window.__dshQuote` 可用、`conversation` / `inputTriggers` / `sidebarRight` / `sidebarRightTabs` 可见,`betterSidebar` 缺失而 `probe().nativeSideChat === true`——原生 fork 降级链的前置条件成立,发布产物的 manifest/打包路径也随真宿主一起验证过。欢迎页的交互点击被安全闸门拦下,因此下面五条仍需在真实窗口逐项点:聚合条编辑、回合尾的两个按钮、打开文件与行范围标记、另开侧边对话、JSON 导出。
480
+ - **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 557 项离线断言;真浏览器(1600×2000 视口)同一套断言本轮复核 **51/51**。另外在**隔离的 DSH web 实例**(临时 `DSH_HOME`,端口 8790)里确认:工作区 link 与 **npm lab 发布包 `0.12.1-lab.15`** 都能进入 boot 列表,`window.__dshQuote` 可用、`conversation` / `inputTriggers` / `sidebarRight` / `sidebarRightTabs` 可见,`betterSidebar` 缺失而 `probe().nativeSideChat === true`——原生 fork 降级链的前置条件成立,发布产物的 manifest/打包路径也随真宿主一起验证过。欢迎页的交互点击被安全闸门拦下,因此下面五条仍需在真实窗口逐项点:聚合条编辑、回合尾的两个按钮、打开文件与行范围标记、另开侧边对话、JSON 导出。
479
481
  - **市场收录与首屏**:README 已有截图、徽章、「30 秒上手」与 24 秒静态轮播 GIF;还差把插件提交到 dsh.market / dsh-plugin.org / dsh.pub / awesome-deepseek-harness —— 这些需要外部账号,是人工动作。
480
482
 
481
483
  ## 变更记录
package/docs/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # docs
2
+
3
+ 面向发布与市场的材料。产品说明本身在根 [`../README.md`](../README.md),这里只放它的周边资产。
4
+
5
+ | 路径 | 作用 |
6
+ | --- | --- |
7
+ | `distribution.md` | 市场提交用的身份、一句话简介、标签、资产清单与检查表;投稿动作需仓库所有者账号。 |
8
+ | [`screenshots/`](screenshots/README.md) | 根 README「界面」一节用到的四张截图与 24 秒轮播 GIF。 |
9
+
10
+ ## 约定
11
+
12
+ - `docs` 在 `package.json` 的 `files` 白名单里,内容会随 npm 包发布;不要往这里放工作稿、报告或临时文件。
13
+ - `screenshots/` 下的每个文件都必须在根 README 里被引用——`test/check.mjs` 会逐个核对,新增/删除截图要同步改根 README。
@@ -66,8 +66,9 @@ awesome-deepseek-harness 的列表条目(英文,一行):
66
66
 
67
67
  ## 发布前
68
68
 
69
- 1. 把 `CHANGELOG.md` 的 Unreleased 段改成下一个版本号,并提交本轮代码(当前工作区含未提交改动)。
70
- 2. 按仓库的 `lab → main` 快进流程发下一个补丁版本。
71
- 3. 在真实 DSH 窗口点一遍:聚合条编辑/移除、回合尾按钮、打开文件与行范围、另开侧边对话、JSON 导出。
69
+ 1. 把 `CHANGELOG.md` 顶部的 `Unreleased (lab)` 改成正式版本号(`## X.Y.Z — YYYY-MM-DD`),在 lab 上提交。
70
+ 2. 推 `lab`(CI 发一个 `-lab.N` 预发布),在真实 DSH 窗口点一遍:聚合条编辑/移除、回合尾按钮、打开文件与行范围、另开侧边对话、JSON 导出。
71
+ 3. 发正式版:`main` 快进到 `lab` 并推送 → 在 `main` 头上打 `vX.Y.Z` 标签并推送。标签会同时触发两件事:**npm `latest` 发布** 与 **GitHub Release 创建**(正文取自 CHANGELOG 同名小节;已存在则更新 notes,重跑安全)。
72
+ 4. 发完确认三处一致:`npm view dsh-client-ui-quote dist-tags` 的 `latest`、仓库 Releases 页的最新一条、`git ls-remote --tags` 的标签指向同一个提交。
72
73
 
73
- 已复核:`npm test` 541 组离线断言;真浏览器(1600×2000 视口)51/51;隔离 DSH web 实例中工作区 link 与 npm lab 发布包 `0.12.1-lab.15` 均加载成功,`__dshQuote` 可用、conversation/inputTriggers/sidebarRight/sidebarRightTabs 可见、`probe().nativeSideChat === true`(betterSidebar 缺失);`npm pack --dry-run` 包含 README、截图、GIF 与本文件。真实窗口的五条交互路径仍需人工点击。
74
+ 已复核:`npm test` 549 组离线断言;真浏览器(1600×2000 视口)51/51;隔离 DSH web 实例中工作区 link 与 npm lab 发布包均加载成功,`__dshQuote` 可用、conversation/inputTriggers/sidebarRight/sidebarRightTabs 可见、`probe().nativeSideChat === true`(betterSidebar 缺失);`npm pack --dry-run` 包含 README、截图、GIF 与本文件。真实窗口的五条交互路径仍需人工点击。
@@ -0,0 +1,17 @@
1
+ # docs/screenshots
2
+
3
+ 根 README「界面」一节使用的界面素材,全部是静态截图 / 静态合成,不是实时录屏。
4
+
5
+ | 文件 | 内容 |
6
+ | --- | --- |
7
+ | `bar.png` | 选中文字后浮出的「评论 / 添加到对话 / 侧边对话」浮层,位置贴着选区。 |
8
+ | `comment.png` | 评论框:引用正文与评论各占一个输入框、都可编辑,可勾选「让模型逐条回应」。 |
9
+ | `card.png` | 发送后的胶囊与悬停卡片:引用、评论、跳回原文、改这条引用、复制。 |
10
+ | `panel.png` | 右侧栏「引用清单」页:搜索、跳回、导出与侧边线程回流。 |
11
+ | `overview.gif` | 四张截图合成的 24 秒轮播,顺序为浮层 → 评论 → 胶囊卡片 → 引用清单。 |
12
+
13
+ ## 替换约定
14
+
15
+ - `test/check.mjs` 会遍历本目录,要求每个文件名都出现在根 [`README.md`](../../README.md) 里(`docs/screenshots/<name>`),所以新增或删除素材必须同步改根 README。
16
+ - 200% / 高分屏截图请先缩放再放入,避免 README 首屏过大。
17
+ - 本目录没有构建脚本;素材是手工截取、合成后提交的。
package/lib/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # lib
2
+
3
+ 插件的两个出口。**功能全部住在 `client.js`**,这里没有构建步骤,也没有运行期依赖。
4
+
5
+ | 文件 | 作用 |
6
+ | --- | --- |
7
+ | `client.js`(≈7.4k 行) | 浏览器半:选区读取、线格式、芯片插入、发送后胶囊、坐标与跳回、高亮、草稿聚合条、侧边对话、引用清单页、快捷键、`apply` 装配。顺序见文件顶部注释与 [`../AGENTS.md`](../AGENTS.md) 的路由表。 |
8
+ | `index.js` | host 半:只有一个空 `apply()`,用来标记 loader 入口已组合。不要在这里加服务、路由或逻辑。 |
9
+
10
+ ## 改这里之前
11
+
12
+ - bundle 内只 `require("react")`;不加 lodash / 日期库 / ESM `import`,`test/check.mjs` 会拒绝未知 require。
13
+ - 不拆新模块、不引 TS 或打包器;所有功能继续留在 `client.js`。
14
+ - 不搬移、不改写宿主 React 节点;胶囊是「隐藏原 run + 在前插入自己的节点」,且可还原。
15
+ - 线格式只增不改,旧消息必须继续可读;坐标随消息文本走,本机索引只是缓存。
16
+ - 宿主服务缺失时静默降级,绝不让整个插件挂掉。
17
+
18
+ 完整红线(R1–R22)见 [`../AGENTS.md`](../AGENTS.md);产品行为口径见 [`../README.md`](../README.md) 的功能表。
package/lib/client.js CHANGED
@@ -1718,19 +1718,29 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
1718
1718
  return String(composer.innerText === undefined ? composer.textContent : composer.innerText);
1719
1719
  }
1720
1720
 
1721
- /** Quote (+ optional comment) exactly as it should land in the draft. */
1721
+ /**
1722
+ * Quote (+ optional comment) exactly as it should land in the draft. The
1723
+ * block always ends with a blank line, so wording that follows starts on
1724
+ * its own line; the caller decides whether it goes in front of the draft.
1725
+ */
1722
1726
  function buildPayload(text, comment, code, meta) {
1723
- const prefix = composerText().trim() === "" ? "" : "\n";
1724
- return prefix + quoteBlock(text, comment, code, meta) + "\n\n";
1727
+ return quoteBlock(text, comment, code, meta) + "\n\n";
1725
1728
  }
1726
1729
 
1727
- /** Kimi inserts at the caret; the composer answers a collapsed span at the end. */
1728
- function insertPayload(entry, payload) {
1730
+ /**
1731
+ * Place a plain-text payload at the caret, or in front of the draft when
1732
+ * the reader already wrote something — the same top-then-line-break rule
1733
+ * as the chip path, for hosts that lost the native reference face.
1734
+ */
1735
+ function insertPayload(entry, payload, atTop) {
1729
1736
  for (let attempt = 0; attempt < 2; attempt += 1) {
1730
1737
  const actions = entry.actions();
1731
1738
  if (actions === null || typeof actions.captureInsertion !== "function") return false;
1732
1739
  if (typeof actions.insertText !== "function") return false;
1733
- const span = actions.captureInsertion();
1740
+ const captured = actions.captureInsertion();
1741
+ const span = atTop === true && captured !== null && captured !== undefined && typeof captured === "object"
1742
+ ? { ...captured, start: 0, end: 0 }
1743
+ : captured;
1734
1744
  if (actions.insertText(payload, span) === true) return true;
1735
1745
  }
1736
1746
  return false;
@@ -1989,6 +1999,12 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
1989
1999
  appearance: "session",
1990
2000
  clipboardText: model
1991
2001
  };
2002
+ // A chip is an inline node. The reader's wording already in the draft
2003
+ // must stay *after* the quote, on a line of its own: the quote goes to
2004
+ // the top, then one real line break, then whatever they had written.
2005
+ // The Markdown fallback writes the quote first for the same reason.
2006
+ const ownText = shell === null || shell === undefined ? composerText().trim() !== "" : (draftOwnText(shell) ?? composerText().trim() !== "");
2007
+ const toTop = ownText === true;
1992
2008
  let refusal = "";
1993
2009
  for (let attempt = 0; attempt < 2; attempt += 1) {
1994
2010
  const actions = entry.actions();
@@ -1996,7 +2012,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
1996
2012
  noteFailure("insert", "the composer face has no captureInsertion");
1997
2013
  return false;
1998
2014
  }
1999
- const span = actions.captureInsertion();
2015
+ const captured = actions.captureInsertion();
2016
+ const span = toTop && captured !== null && captured !== undefined && typeof captured === "object"
2017
+ ? { ...captured, start: 0, end: 0 }
2018
+ : captured;
2000
2019
  if (canShell) {
2001
2020
  try {
2002
2021
  if (shell.insertReference(attachment, span) === true) {
@@ -2005,6 +2024,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
2005
2024
  // along, so the chip's card already knows the coordinate it will
2006
2025
  // carry once the message is sent.
2007
2026
  rememberChip(text, comment, sessionId, code, meta);
2027
+ if (toTop) {
2028
+ separateTopChip(entry);
2029
+ focusComposerAtEnd();
2030
+ }
2008
2031
  return true;
2009
2032
  }
2010
2033
  } catch (error) {
@@ -2014,6 +2037,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
2014
2037
  }
2015
2038
  if (dispatchInputEvent(actx, "slash/input-insert-reference", { reference: attachment, span })) {
2016
2039
  rememberChip(text, comment, sessionId, code, meta);
2040
+ if (toTop) {
2041
+ separateTopChip(entry);
2042
+ focusComposerAtEnd();
2043
+ }
2017
2044
  return true;
2018
2045
  }
2019
2046
  }
@@ -2039,6 +2066,50 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
2039
2066
  if (target !== null && typeof target.focus === "function") target.focus({ preventScroll: true });
2040
2067
  }
2041
2068
 
2069
+ /**
2070
+ * Put the caret at the end after a quote moved to the top: the reader's
2071
+ * own wording now sits below the chip, and typing must continue after it
2072
+ * instead of in front of it. A DOM without a usable selection just keeps
2073
+ * whatever caret the browser left.
2074
+ */
2075
+ function focusComposerAtEnd() {
2076
+ const target = composerEditor();
2077
+ if (target === null || typeof target.focus !== "function") return;
2078
+ try {
2079
+ target.focus({ preventScroll: true });
2080
+ if (typeof document.getSelection !== "function" || typeof document.createRange !== "function") return;
2081
+ const selection = document.getSelection();
2082
+ if (selection === null || typeof selection.addRange !== "function" || typeof selection.removeAllRanges !== "function") return;
2083
+ const range = document.createRange();
2084
+ if (typeof range.selectNodeContents !== "function" || typeof range.collapse !== "function") return;
2085
+ range.selectNodeContents(target);
2086
+ range.collapse(false);
2087
+ selection.removeAllRanges();
2088
+ selection.addRange(range);
2089
+ } catch (error) {
2090
+ // A caret left where the browser put it is not a failed insert.
2091
+ }
2092
+ }
2093
+
2094
+ /**
2095
+ * One real line break between a quote that just went to the top and the
2096
+ * wording that was already in the draft. The chip is an inline node, so
2097
+ * only an edit in the editor can move the wording to its own line; a host
2098
+ * without `insertText` keeps the chip and loses only the break.
2099
+ */
2100
+ function separateTopChip(entry) {
2101
+ const actions = typeof entry.actions === "function" ? entry.actions() : null;
2102
+ if (actions === null || actions === undefined) return false;
2103
+ if (typeof actions.captureInsertion !== "function" || typeof actions.insertText !== "function") return false;
2104
+ try {
2105
+ const span = actions.captureInsertion();
2106
+ if (span === null || span === undefined || typeof span !== "object") return false;
2107
+ return actions.insertText("\n", span) === true;
2108
+ } catch (error) {
2109
+ return false;
2110
+ }
2111
+ }
2112
+
2042
2113
  /**
2043
2114
  * 列出多选 — write several quotes into the draft in one action. Each row
2044
2115
  * keeps its own chip and coordinate; nothing is merged. If the chip path
@@ -2057,7 +2128,12 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
2057
2128
  }
2058
2129
  let inserted = 0;
2059
2130
  const pending = [];
2060
- for (const row of list) {
2131
+ // A quote goes to the top when the draft already holds wording, so
2132
+ // prepending walks the list backwards: the first row must end up on
2133
+ // top after every later row has pushed it down.
2134
+ const toTop = shell === null || shell === undefined ? composerText().trim() !== "" : (draftOwnText(shell) ?? composerText().trim() !== "");
2135
+ const ordered = toTop === true ? [...list].reverse() : list;
2136
+ for (const row of ordered) {
2061
2137
  const ref = row.ref === undefined ? null : row.ref;
2062
2138
  const meta = {
2063
2139
  path: typeof row.fence === "string" ? row.fence : null,
@@ -2068,6 +2144,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
2068
2144
  if (insertChip(entry, sessionId, row.quote, typeof row.comment === "string" ? row.comment : "", row.code === true, meta)) inserted += 1;
2069
2145
  else pending.push(row);
2070
2146
  }
2147
+ if (toTop === true) pending.reverse();
2071
2148
  // A partial chip path must not drop the rest: the rows that could not
2072
2149
  // become chips go in as text blocks, coordinates included.
2073
2150
  if (pending.length > 0) {
@@ -3935,36 +4012,111 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
3935
4012
  return index;
3936
4013
  }
3937
4014
 
3938
- /** The text nodes a quote may live in, skipping buttons and our own marks. */
3939
- function textNodesOf(row) {
3940
- if (typeof document.createTreeWalker !== "function") return [];
3941
- const nodes = [];
3942
- const walker = document.createTreeWalker(row, 4, (node) => {
3943
- const parent = node.parentElement;
3944
- if (parent === null || parent === undefined) return 3;
3945
- if (typeof parent.closest === "function" && parent.closest('[data-dshq],button,script,style,[aria-hidden="true"],svg') !== null) return 3;
3946
- return 1;
3947
- });
3948
- let node = walker.nextNode();
3949
- while (node !== null) {
3950
- if (typeof node.nodeValue === "string" && node.nodeValue !== "") nodes.push(node);
3951
- node = walker.nextNode();
3952
- }
3953
- return nodes;
4015
+ /**
4016
+ * Elements a selection reads as a line break at: paragraphs, list items,
4017
+ * table cells and rows, `<br>`. A selection across a table cell boundary
4018
+ * gives `a\tb` and across two rows `a\nb`, while the row's text nodes
4019
+ * concatenate as `ab` — which is exactly why a quoted table never found
4020
+ * its source range, and why a quote spanning two paragraphs missed too.
4021
+ * Inline elements (`a`, `code`, `strong`, `span`) are deliberately absent:
4022
+ * a selection inside them keeps the words glued together.
4023
+ */
4024
+ const BLOCK_TAGS = new Set([
4025
+ "address", "article", "aside", "blockquote", "caption", "dd", "div", "dl", "dt",
4026
+ "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4",
4027
+ "h5", "h6", "header", "hr", "li", "main", "nav", "ol", "p", "pre", "section",
4028
+ "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul"
4029
+ ]);
4030
+ /** Subtrees a quote never reads: controls, code chrome, and our own marks. */
4031
+ const SKIP_TAGS = new Set(["button", "input", "select", "textarea", "script", "style", "svg"]);
4032
+
4033
+ /**
4034
+ * The row's text the way a reader's selection reports it: one space at
4035
+ * every block boundary, real characters elsewhere. `points[i]` is where
4036
+ * character `i` lives in the DOM, or null for a boundary the DOM has no
4037
+ * character for; the highlight maps a whitespace-folded match back
4038
+ * through those points.
4039
+ */
4040
+ function quoteTextMap(row) {
4041
+ const chars = [];
4042
+ const points = [];
4043
+ // A boundary never adds a second space and never starts the text with
4044
+ // one, so `foo` + `bar` inside inline spans stays the glued `foobar`.
4045
+ let spaced = true;
4046
+ const space = () => {
4047
+ if (spaced) return;
4048
+ chars.push(" ");
4049
+ points.push(null);
4050
+ spaced = true;
4051
+ };
4052
+ const takeText = (node) => {
4053
+ const value = typeof node.nodeValue === "string" ? node.nodeValue : "";
4054
+ for (let offset = 0; offset < value.length; offset += 1) {
4055
+ chars.push(value[offset]);
4056
+ points.push({ node, offset });
4057
+ spaced = /\s/u.test(value[offset]);
4058
+ }
4059
+ };
4060
+ const visit = (node) => {
4061
+ if (node === null || node === undefined) return;
4062
+ if (node.nodeType === 3) {
4063
+ takeText(node);
4064
+ return;
4065
+ }
4066
+ if (node.nodeType !== 1) return;
4067
+ const tag = String(node.tagName ?? "").toLowerCase();
4068
+ if (tag === "br") {
4069
+ space();
4070
+ return;
4071
+ }
4072
+ if (SKIP_TAGS.has(tag)) return;
4073
+ if (typeof node.getAttribute === "function" && node.getAttribute("aria-hidden") === "true") return;
4074
+ if (typeof node.closest === "function" && node.closest("[data-dshq]") !== null) return;
4075
+ const block = BLOCK_TAGS.has(tag);
4076
+ if (block) space();
4077
+ for (const child of node.childNodes ?? []) visit(child);
4078
+ if (block) space();
4079
+ };
4080
+ visit(row);
4081
+ return { text: chars.join(""), points };
3954
4082
  }
3955
4083
 
3956
- /** A Range over the quote's nth occurrence in one row, or null. */
4084
+ /**
4085
+ * A Range over the quote's nth occurrence in one row, or null. The match
4086
+ * runs on `quoteTextMap` (block boundaries as spaces) and the Markdown
4087
+ * marks `selectedText` may add are stripped as a second pass, so a quote
4088
+ * from a table, a list, or an inline code span can still be painted.
4089
+ */
3957
4090
  function rangeForQuote(row, quote, ordinal) {
3958
4091
  if (typeof document.createRange !== "function") return null;
3959
- const nodes = textNodesOf(row);
3960
- if (nodes.length === 0) return null;
3961
- const text = nodes.map((node) => node.nodeValue).join("");
3962
- const spans = findQuoteSpans(text, quote);
4092
+ const map = quoteTextMap(row);
4093
+ if (map.text === "") return null;
4094
+ let spans = findQuoteSpans(map.text, quote);
4095
+ if (spans.length === 0) {
4096
+ const plain = plainOf(quote);
4097
+ if (plain !== quote) spans = findQuoteSpans(map.text, plain);
4098
+ }
3963
4099
  if (spans.length === 0) return null;
3964
4100
  const wanted = Math.min(Math.max((Number(ordinal) || 1) - 1, 0), spans.length - 1);
3965
4101
  const span = spans[wanted];
3966
- const start = textPointAt(nodes, span.start);
3967
- const end = textPointAt(nodes, span.end);
4102
+ // A boundary character has no DOM position: the range starts at the
4103
+ // first real character and ends after the last one.
4104
+ let start = null;
4105
+ for (let at = Math.max(span.start, 0); at < span.end && at < map.points.length; at += 1) {
4106
+ const point = map.points[at];
4107
+ if (point !== null && point !== undefined) {
4108
+ start = point;
4109
+ break;
4110
+ }
4111
+ }
4112
+ let end = null;
4113
+ for (let at = Math.min(span.end, map.points.length) - 1; at >= 0; at -= 1) {
4114
+ const point = map.points[at];
4115
+ if (point !== null && point !== undefined) {
4116
+ end = { node: point.node, offset: point.offset + 1 };
4117
+ break;
4118
+ }
4119
+ }
3968
4120
  if (start === null || end === null) return null;
3969
4121
  const range = document.createRange();
3970
4122
  range.setStart(start.node, start.offset);
@@ -5139,7 +5291,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5139
5291
  focusComposer();
5140
5292
  return;
5141
5293
  }
5142
- if (insertPayload(entry, buildPayload(quoteText, trimmed, code, meta))) focusComposer();
5294
+ if (insertPayload(entry, buildPayload(quoteText, trimmed, code, meta), composerText().trim() !== "")) focusComposer();
5143
5295
  else {
5144
5296
  noteFailure("insert", "the plain-text fallback failed too");
5145
5297
  toast(translate("failed"));
@@ -5523,6 +5675,32 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5523
5675
  }
5524
5676
  }
5525
5677
 
5678
+ /**
5679
+ * Whether the draft holds the reader's own wording, not just chips. A
5680
+ * chip's clipboard projection is the whole quote block, so a draft with
5681
+ * one chip is never "empty" textually; the occurrences have to be cut out
5682
+ * before the answer means anything. Null when the shell exposes no draft.
5683
+ */
5684
+ function draftOwnText(shell) {
5685
+ try {
5686
+ const snapshot = shell.state === undefined ? null : shell.state.getSnapshot();
5687
+ if (snapshot === null || snapshot === undefined || typeof snapshot.draft !== "string") return null;
5688
+ let text = snapshot.draft;
5689
+ const occurrences = Array.isArray(snapshot.occurrences) ? [...snapshot.occurrences] : [];
5690
+ // Cut from the end so the earlier offsets stay valid.
5691
+ occurrences.sort((a, b) => (Number(b?.offset) || 0) - (Number(a?.offset) || 0));
5692
+ for (const occurrence of occurrences) {
5693
+ const offset = occurrence?.offset;
5694
+ const length = occurrence?.length;
5695
+ if (typeof offset !== "number" || typeof length !== "number" || offset < 0 || length < 0 || offset + length > text.length) continue;
5696
+ text = text.slice(0, offset) + text.slice(offset + length);
5697
+ }
5698
+ return text.trim() !== "";
5699
+ } catch (error) {
5700
+ return null;
5701
+ }
5702
+ }
5703
+
5526
5704
  /** Write a whole draft through whichever face the shell exposes. */
5527
5705
  function writeDraft(shell, text) {
5528
5706
  try {
@@ -0,0 +1,14 @@
1
+ # locale
2
+
3
+ 插件页卡片的本地化元数据,宿主按 `locale/*.json` 扫描。
4
+
5
+ | 文件 | 作用 |
6
+ | --- | --- |
7
+ | `en.json` | 扫描锚点(host 以它确认目录存在);英文 `meta.title` / `meta.description`。 |
8
+ | `zh.json` | 中文同名两份文案,另有 `sourceLost`(跳回失败时的提示,需与 `lib/client.js` 里的词典逐字一致)。 |
9
+
10
+ ## 约定
11
+
12
+ - 两份字典的键必须成对;`test/check.mjs` 会逐键核对,并断言 `sourceLost` 与插件内词典一致。
13
+ - 文案改动要三处同改:`lib/client.js` 的 `zh` / `en`、这里的 `zh.json` / `en.json`、`package.json` 的 `description`;行为变了顺带改根 README 的功能表。
14
+ - 本目录只放 JSON(可选 README);不要放脚本或构建产物。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.12.2-lab.21",
3
+ "version": "0.12.2-lab.23",
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 ADDED
@@ -0,0 +1,26 @@
1
+ # test
2
+
3
+ 离线回归网。没有测试框架,全部是 Node 脚本;`npm test` 依次跑三套,共 **549 组**断言。
4
+
5
+ | 文件 | 组数 | 覆盖 |
6
+ | --- | --- | --- |
7
+ | `check.mjs` | 12 | 清单字段、`exports`、图标、两份 locale 词典、槽位注册、`codec.serialize`、无强制 peer、`files` 发货白名单、截图被根 README 引用。 |
8
+ | `capsule.mjs` | 384 | 输入框 → 发送后链路:线格式、芯片插入、胶囊、悬停卡片、跳回、高亮、草稿聚合条、侧边对话、逐条徽章、diff 引用。 |
9
+ | `panel.mjs` | 153 | 引用清单页:解析、搜索、Markdown/JSON 导出、读取、注册、审阅工作台、本机索引、侧线程回流。 |
10
+ | `dom.mjs` | — | 最小 DOM / MutationObserver / Range / `localStorage` 垫片。新用到的 DOM API **先补这里**,否则 `capsule.mjs` 在 Node 里直接挂。 |
11
+ | `capsule.html` | 51 项 | 真浏览器版,`file://` 直接打开;结果写进页面标题与 `#probe-out`。 |
12
+
13
+ ## 命令
14
+
15
+ ```bash
16
+ npm test # check + capsule + panel
17
+ node test/check.mjs # 只跑清单/契约
18
+ node test/capsule.mjs # 只跑发送后链路
19
+ node test/panel.mjs # 只跑清单页
20
+ ```
21
+
22
+ ## 红线
23
+
24
+ - 新增行为必须带断言;**不许删断言或放宽期望值换绿**。
25
+ - 若断言数变化,根 README 四处与 CHANGELOG 一处的数字要同步(549 = 12 + 384 + 153)。
26
+ - 测试跑在 `dom.mjs` 垫片上:先垫 API,再写实现,不要反过来为了过测试而改期望。
package/test/capsule.mjs CHANGED
@@ -1925,6 +1925,52 @@ try {
1925
1925
  JSON.stringify([landed.length, String(landed[0]?.ref ?? "").slice(0, 24)])
1926
1926
  );
1927
1927
 
1928
+ // 3b. A draft that already holds the reader's wording: the quote goes to
1929
+ // the top and the wording moves to a line of its own below it.
1930
+ const lineCalls = [];
1931
+ let lineDraft = "已经写好的问题";
1932
+ let lineCaret = lineDraft.length;
1933
+ let lineRev = 1;
1934
+ shells.set("session-line", {
1935
+ state: { getSnapshot: () => ({ draft: lineDraft, occurrences: [] }) },
1936
+ actions: {
1937
+ captureInsertion: () => ({ start: lineCaret, end: lineCaret, draftRev: lineRev }),
1938
+ insertText(text, span) {
1939
+ lineCalls.push({ kind: "text", text, span });
1940
+ lineDraft = lineDraft.slice(0, span.start) + text + lineDraft.slice(span.end);
1941
+ lineCaret = span.start + text.length;
1942
+ lineRev += 1;
1943
+ return true;
1944
+ }
1945
+ },
1946
+ insertReference(attachment, span) {
1947
+ lineCalls.push({ kind: "reference", span, attachment });
1948
+ const model = String(attachment?.ref ?? "");
1949
+ lineDraft = lineDraft.slice(0, span.start) + model + lineDraft.slice(span.end);
1950
+ lineCaret = span.start + model.length;
1951
+ lineRev += 1;
1952
+ return true;
1953
+ }
1954
+ });
1955
+ const linePane = pane("session-line", "引用要自己起一行的原文");
1956
+ selectIn(linePane.run, "引用要自己起一行的原文");
1957
+ barOf().dispatch("click", { target: barOf().querySelector('[data-action="add"]') });
1958
+ check(
1959
+ "a quote in a written draft goes to the top",
1960
+ lineCalls[0]?.kind === "reference" && lineCalls[0]?.span?.start === 0 && lineCalls[0]?.span?.end === 0 && lineCalls[0]?.span?.draftRev === 1,
1961
+ JSON.stringify(lineCalls.map((call) => [call.kind, call.span?.start, call.span?.end, call.span?.draftRev]))
1962
+ );
1963
+ check(
1964
+ "and the wording moves below it on a new line",
1965
+ lineCalls[1]?.kind === "text" && lineCalls[1]?.text === "\n" && lineCalls[1]?.span?.draftRev === 2 && lineDraft.endsWith("\n已经写好的问题"),
1966
+ JSON.stringify(lineCalls.map((call) => [call.kind, call.text, call.span?.draftRev]))
1967
+ );
1968
+ check(
1969
+ "the quote keeps its wire block in front",
1970
+ lineDraft.startsWith(String(lineCalls[0]?.attachment?.ref ?? "")) && String(lineCalls[0]?.attachment?.ref ?? "").includes("引用要自己起一行的原文"),
1971
+ JSON.stringify(lineDraft.slice(0, 24))
1972
+ );
1973
+
1928
1974
  // 4. With nowhere to insert, the reader is told instead of left wondering.
1929
1975
  const thirdPane = pane("session-noshell", "没有输入框的会话");
1930
1976
  selectIn(thirdPane.run, "没有输入框的会话");
@@ -2466,6 +2512,85 @@ try {
2466
2512
  JSON.stringify({ start: second?.start?.offset, end: second?.end?.offset })
2467
2513
  );
2468
2514
 
2515
+ // 表格划选:Chrome 的 selection.toString() 用 \t 分隔同一行的单元格、用
2516
+ // \n 分隔行,而行的文本节点是直接拼接的 ("alpha" + "第一行说明")。高亮要
2517
+ // 按这种边界对齐,否则表格里的引用永远找不到原文。
2518
+ const tableRow = dom.document.createElement("div");
2519
+ tableRow.setAttribute("data-chat-anchor-key", "row-table");
2520
+ tableRow.setAttribute("data-chat-turn", "6");
2521
+ tableRow.setAttribute("data-chat-role", "assistant");
2522
+ const table = dom.document.createElement("table");
2523
+ const tbody = dom.document.createElement("tbody");
2524
+ const tr1 = dom.document.createElement("tr");
2525
+ const cellA = dom.document.createElement("td");
2526
+ cellA.textContent = "alpha";
2527
+ const cellB = dom.document.createElement("td");
2528
+ cellB.textContent = "第一行说明";
2529
+ const tr2 = dom.document.createElement("tr");
2530
+ const cellC = dom.document.createElement("td");
2531
+ cellC.textContent = "beta";
2532
+ const cellD = dom.document.createElement("td");
2533
+ cellD.textContent = "第二行说明";
2534
+ tr1.append(cellA);
2535
+ tr1.append(cellB);
2536
+ tr2.append(cellC);
2537
+ tr2.append(cellD);
2538
+ tbody.append(tr1);
2539
+ tbody.append(tr2);
2540
+ table.append(tbody);
2541
+ tableRow.append(table);
2542
+ pane.append(tableRow);
2543
+ const cellRange = testing.rangeForQuote(tableRow, "alpha\t第一行说明", 1);
2544
+ check(
2545
+ "a quote across two table cells is measured as one range",
2546
+ cellRange?.start?.node === cellA.firstChild && cellRange?.start?.offset === 0 &&
2547
+ cellRange?.end?.node === cellB.firstChild && cellRange?.end?.offset === 5,
2548
+ JSON.stringify({ start: cellRange?.start?.node?.nodeValue, end: cellRange?.end?.node?.nodeValue })
2549
+ );
2550
+ const rowRange = testing.rangeForQuote(tableRow, "alpha\t第一行说明\nbeta", 1);
2551
+ check(
2552
+ "a quote across two table rows keeps both ends",
2553
+ rowRange?.start?.node === cellA.firstChild && rowRange?.start?.offset === 0 &&
2554
+ rowRange?.end?.node === cellC.firstChild && rowRange?.end?.offset === 4,
2555
+ JSON.stringify({ start: rowRange?.start?.node?.nodeValue, end: rowRange?.end?.node?.nodeValue })
2556
+ );
2557
+ const cellOnly = testing.rangeForQuote(tableRow, "第一行说明", 1);
2558
+ check(
2559
+ "one cell still matches exactly",
2560
+ cellOnly?.start?.node === cellB.firstChild && cellOnly?.start?.offset === 0 && cellOnly?.end?.offset === 5,
2561
+ JSON.stringify({ start: cellOnly?.start?.offset, end: cellOnly?.end?.offset })
2562
+ );
2563
+ // A table cell with inline code: selectedText keeps the backticks, the
2564
+ // rendered cell does not — the plain fallback is what makes it paintable.
2565
+ const codeCell = dom.document.createElement("td");
2566
+ const codeNode = dom.document.createElement("code");
2567
+ codeNode.textContent = "run";
2568
+ codeCell.append(codeNode);
2569
+ tr1.append(codeCell);
2570
+ const codeRange = testing.rangeForQuote(tableRow, "`run`", 1);
2571
+ check(
2572
+ "a code span inside a cell matches through its Markdown marks",
2573
+ codeRange?.start?.node === codeNode.firstChild && codeRange?.start?.offset === 0 && codeRange?.end?.offset === 3,
2574
+ JSON.stringify({ start: codeRange?.start?.node?.nodeValue, end: codeRange?.end?.node?.nodeValue })
2575
+ );
2576
+ // Inline neighbours must stay glued: two spans with no whitespace between
2577
+ // them still match one word, not a spaced-out phantom.
2578
+ const inlineRow = dom.document.createElement("div");
2579
+ inlineRow.setAttribute("data-chat-anchor-key", "row-inline");
2580
+ const spanA = dom.document.createElement("span");
2581
+ spanA.textContent = "foo";
2582
+ const spanB = dom.document.createElement("span");
2583
+ spanB.textContent = "bar";
2584
+ inlineRow.append(spanA);
2585
+ inlineRow.append(spanB);
2586
+ pane.append(inlineRow);
2587
+ const inlineRange = testing.rangeForQuote(inlineRow, "foobar", 1);
2588
+ check(
2589
+ "inline text nodes still match without an inserted space",
2590
+ inlineRange?.start?.node === spanA.firstChild && inlineRange?.end?.node === spanB.firstChild && inlineRange?.end?.offset === 3,
2591
+ JSON.stringify({ start: inlineRange?.start?.node?.nodeValue, end: inlineRange?.end?.node?.nodeValue })
2592
+ );
2593
+
2469
2594
  const longQuote = "长".repeat(200);
2470
2595
  const longRef = { s: "session-anchor", a: "data-chat-anchor-key", k: "row-anchor", t: "5", n: 1 };
2471
2596
  testing.rememberQuoteText(longRef, longQuote);