dsh-client-ui-quote 0.12.3 → 0.12.4-lab.40

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
@@ -2,7 +2,15 @@
2
2
 
3
3
  ## Unreleased (lab)
4
4
 
5
- _(下一轮)_
5
+ ### Fixed
6
+
7
+ - 取消侧边对话后原文仍留着高亮:第三项会为引用预先算坐标(跳回与悬停卡片要用它的序号),以前这一步直接落进原文高亮表,于是**只要点过第三项**,那段文字就被画上高亮与序号徽标——即使读者随即把侧边对话关掉、根本没发送。现在这类坐标以 `draft` 记在 `dsh-client-ui-quote:v2:quote-source` 里,`paintAnchors` 跳过它;只有当这句引用真的以胶囊形态出现在某条 transcript(`capsuleFor` 认到同一个 ref)时,`promoteSource` 才把它转正并补画标记。主会话「添加到对话 / 评论」与已发出的旧引用不受影响(它们保存时不带 `draft`)。
8
+ - 放弃浮层动作后选区仍在:浮层自己的 `mousedown` 调了 `preventDefault()`(不然点行的一瞬间选区就消失),但退场时没人把它还回去,`Esc` / 取消 / 点别处之后那段文字会以浏览器选区形态一直亮着。`close()` 现在见到 `state.keptSelection` 就 `removeAllRanges()`,动作路径与放弃路径都不再残留选区。
9
+ - 离线断言 **674 组**(check 12 + capsule 458 + panel 160 + host 44);新增「草稿坐标不落色 / 转正后才补画(含侧边对话端到端)」「`draft` 标记随表持久化,刷新不会复活标记」「放弃动作把保住的选区还回去」四组断言。
10
+
11
+ ### Docs
12
+
13
+ - README 的侧边对话两处(操作流程第 4 步、实现小节)改回实际形态:引用以**原生芯片**写进侧聊输入框,块引用只是宿主没有芯片面时的兜底;并补上「没发出去的引用不落到原文上」「放弃动作要把选区还回去」两条。同时修掉 `test/panel.mjs` 159 项这个旧数字(实跑 160)。
6
14
 
7
15
  ## 0.12.3 — 2026-10-26
8
16
 
package/README.md CHANGED
@@ -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`(667 项:check 12 + capsule 451 + panel 160 + host 44);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
142
+ 两条线的断言入口相同:`npm test`(674 项:check 12 + capsule 458 + 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
 
@@ -160,7 +160,7 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
160
160
  1. 在会话正文、**文件预览**或**终端**里**拖动选中**一段文字(后两者没有自己的会话 id,引用会落进当前屏幕上那条会话,并且以**围栏代码块**的线格式发给模型;输入框内的选区属于改稿,不触发)。
161
161
  2. 选区**下方**浮出浮层(下方空间不够时才改到上方),选择 **评论**、**添加到对话** 或 **侧边对话**。同一组动作也有键位:**`Alt+Q`** 直接引用、**`Alt+C`** 直接开评论框、**`Alt+B`** 直接开侧边对话(都在设置里可改;没有选区时按键原样让给其它快捷键)。
162
162
  3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**;评论框里还有一格 **`让模型逐条回应`** 勾选项,勾上这条引用就带着逐条指令发出去),页面选区被清除、焦点交给输入框,可以接着写正式提问。输入框里已经有文字时,引用落在**最上方**,你的文字另起一行接在下面。
163
- 4. **侧边对话**写的是另一条会话的输入框:右侧栏打开该线程的页签(标题取引用首行),草稿里**只有这条引用**(纯块引用形态),**不发送**;你接着在这条线程里写自己的问题、自己按发送。同一条主会话只会有一条侧边线程,第二次点第三项就把新引用接在草稿后面;上一次请求还在进行时,再次点击会并入第一次,不会开出两条重复线程。线程打开时的上下文是**一条隐性消息**(主会话最近若干轮的文字,按「上下文」折叠显示),所以侧聊里看不到主会话那一长串历史。
163
+ 4. **侧边对话**写的是另一条会话的输入框:右侧栏打开该线程的页签(标题取引用首行),草稿里**只有这条引用**(原生引用芯片优先,宿主没有芯片面时才退回块引用形态),**不发送**;你接着在这条线程里写自己的问题、自己按发送。同一条主会话只会有一条侧边线程,第二次点第三项就把新引用接在草稿后面;上一次请求还在进行时,再次点击会并入第一次,不会开出两条重复线程。线程打开时的上下文是**一条隐性消息**(主会话最近若干轮的文字,按「上下文」折叠显示),所以侧聊里看不到主会话那一长串历史。
164
164
  5. 浮层收起条件:单击别处、选区清空、按 `Esc`、滚动会话、窗口尺寸变化,以及动作完成之后。双击选词不会把浮层关掉。
165
165
  6. 发送出去以后,消息里那段块会显示成一颗**胶囊**:收起时是单行 `❝ 引用摘要 │ 评论摘要`,**鼠标移上去**会浮出一张卡片显示完整的引用与评论
166
166
  (输入框里那颗芯片同样可以悬停查看),点一下就在原地展开成全文,胶囊上悬停出现 `复制`。
@@ -258,7 +258,9 @@ DSH 的输入框是 Lexical,没有 Kimi 的 `quote` 节点,但有一个通
258
258
  - **父会话在线时可继续**:隐藏线程是 `continuable` 子会话,父会话在线时内置页自己的输入框、流式输出与工具调用都能用;父会话关闭后该页按宿主规则只读,重新打开父会话即可继续。这个限制来自宿主的内置 `subagentchat` 页,不是本插件的额外依赖。
259
259
  - **共享 `Side:` 约定**:与 dsh-better-sidebar 的侧聊线程使用同一套持久约定(`origin: "subagent"` + `Side: ` 标题)。装不装 better-sidebar 都不冲突:它的自动打开会忽略这类标题;它的侧聊列表也会列出我们的线程,我们的引用清单也会扫到它的 `Side:` 线程。路由、存储键与 descriptor provider(`ui-quote`)全部独立。
260
260
  - **回退链**:宿主半不可用(旧宿主、`agents.create` 失败、父会话不在线)→ `sessions.fork` + 本插件 `quote-sidechat` 页签(线程以普通 fork 出现在会话列表)→ better-sidebar(仅显式 `sideChatBackend: "better"` 或最后兜底)→ 无。所有后端都只写草稿、不发送。
261
- - **写进输入框**:页签打开后,`ctx.get("conversation").input.shell(childId)` 拿到子会话的输入框外壳(`SessionInputShell`),用 `setDraft(...)` 把引用的**纯块引用**形态(`>` 逐行前缀,不带 `❝` 标记——那两个标记是主会话胶囊增强器的识别符,侧边线程用不上)写成草稿:输入框里已经有你写的内容时,引用接在下面、空行隔开;**不发送,也不碰别的东西**。子会话的输入框要等页签挂载后才存在(`shell(id)` 在此之前会抛「resolved no binding」),所以这里按 200ms 轮询等它出现;外壳在而 `setDraft` 不在(版本对不上)则立刻放弃,不空等 5 秒。
261
+ - **写进输入框**:页签打开后,`ctx.get("conversation").input.shell(childId)` 拿到子会话的输入框外壳(`SessionInputShell`),走与主输入框同一条 `prefillQuoteDraft`:外壳有 `insertReference` 时写**原生引用芯片**(芯片的 `ref` 里带那条引用的线格式,发送时由 `codec.serialize` 还原,和主会话完全同形),没有芯片面时退回**纯块引用**(`>` 逐行前缀,不带 `❝` 标记——那两个标记是主会话胶囊增强器的识别符,侧边线程用不上)。输入框里已经有你写的内容时,引用接在下面、空行隔开;**不发送,也不碰别的东西**。子会话的输入框要等页签挂载后才存在(`shell(id)` 在此之前会抛「resolved no binding」),所以这里按 200ms 轮询等它出现;外壳在而 `setDraft` / `insertReference` 都不在(版本对不上)则立刻放弃,不空等 5 秒。
262
+ - **没发出去的引用不落到原文上**:第三项会为这条引用预先算好坐标(跳回与悬停卡片都要它),但坐标以 `draft` 记在表里,`paintAnchors` 跳过它——原文上**不会**出现高亮与序号徽标。读者把侧边对话关掉、或者根本不发送,主会话的文面上干干净净;只有这句引用真的以胶囊形态出现在某条 transcript 里(`capsuleFor` 认到同一个 ref),`promoteSource` 才把它转正并立刻补画标记。
263
+ - **放弃动作要把选区还回去**:浮层自己的 `mousedown` 调了 `preventDefault()`(否则点行的一瞬间选区就没了),所以它必须自己收尾——`close()` 见到 `state.keptSelection` 就 `removeAllRanges()`,取消 / `Esc` / 点别处都不会留下一块再也点不掉的高亮。
262
264
  - **一条主会话一条线程**:线程 id 按主会话记住,优先读本插件自己的 `dsh-client-ui-quote:v1:sidechat-thread`;隐藏线程的父会话记在 `dsh-client-ui-quote:v1:sidechat-target`。旧版 better-sidebar 的 `dsh-sidebar:v1:sidechat-thread` 只作为旧线程迁移的只读回退,不再优先于我们自己的记忆。第二次点第三项不再新建线程,只把新引用接进同一条线程的输入框。
263
265
  - **防重复**:同一会话的请求还在途中时,再次点击直接并入第一次(模块级 `Map<sessionId, Promise>` 记账,结算后自行清除)。
264
266
  - **失败路径**:隐藏线程创建被拒(父会话不在线 / 宿主服务缺失)→ 自动退回 `sessions.fork`,不重复建线程;`sessions.fork` 拒绝、或页签两种地址都打不开 → toast「侧边对话没有打开,请重试。」;线程已开、草稿没能写进去 → toast「侧边对话已打开,但引用没能写进它的输入框」。
@@ -326,7 +328,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
326
328
  1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
327
329
  2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标以「人可读来源头 + base64 尾巴」随消息走——前者让模型知道出处,后者让插件跨刷新回读。base64 确实占一点 prompt,这是用「不依赖本地表」换来的取舍。
328
330
  3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在同类里仍是少数。
329
- 4. **工程化**:667 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
331
+ 4. **工程化**:674 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
330
332
 
331
333
  反过来也要说清楚:**本插件不做的**——不做翻译/解释/改写/飞书投递类选区动作;不做图片/截图引用(**明确取舍**:图片引用要的是另一套胶囊/卡片/附件语义,是第二个产品的量级,塞进这里只会把「文本引用带坐标」这条主线冲淡;`dsh-selection-toolbar` 与 Kimi 桌面端已经做了,真要做建议独立成插件);不做完整 Topic 系统;不做常显大块引用。它只把「引用」这一件事做穿——但「做穿」包括发送后的管理、来源闭环和审阅,不只是浮层。
332
334
 
@@ -363,7 +365,7 @@ locale/zh.json 插件页卡片文案(中文)
363
365
  locale/en.json 插件页卡片文案(英文)
364
366
  test/check.mjs 离线自检(清单 + 组件契约)
365
367
  test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
366
- test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,451 项断言)
368
+ test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,458 项断言)
367
369
  test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 文件路径打开 + 侧边线程回流 + 审阅工作台(状态 / 标签 / 批量)+ JSON 导出 + 本地索引,160 项断言)
368
370
  test/host.mjs 离线自检(宿主半:标签 / 隐性上下文种子(切点 + 一个收口回合包着上下文消息 + descriptor;只带用户与助手的原话、8000 字上限、跳过注入上下文)/ 空日志兜底 / 继承切点与 descriptor 顺序 / agents.create 参数 / 探针 / 围栏 / 路由注册与卸载,43 项断言)
369
371
  test/capsule.html 真浏览器里跑同一套胶囊断言(51 项,可选)
@@ -415,11 +417,12 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
415
417
  旧胶囊挂上 `已修订` 徽标、它的卡片显示改后的评论(连正文改过的连改后原文一起显示)、空修订不算修订、
416
418
  修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出;
417
419
  **浮层不跟着座位生死**(`conversation.input.overlay` 没挂载也能选中出栏、挂上两个会话座位既不叠加也不丢监听、
418
- 座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份);
419
- (共 451 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、**表格单元格 / 表格行 / 段落 / 列表项 / `<br>` 边界的引用高亮**、**输入框已有文字时引用置顶并将原文另起一行**、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、原生 `quote-sidechat` 页签的注册 / 地址 / 标题 / retain-release、内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
420
+ 座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份、
421
+ **放弃动作不留高亮**(浮层 `mousedown` 保住的选区在 `Esc` / 取消退场时被还回去));
422
+ (共 458 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、**表格单元格 / 表格行 / 段落 / 列表项 / `<br>` 边界的引用高亮**、**侧聊坐标在发出前不着色、`promoteSource` 转正后才补画**、**输入框已有文字时引用置顶并将原文另起一行**、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、原生 `quote-sidechat` 页签的注册 / 地址 / 标题 / retain-release、内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
420
423
  `test/capsule.html` 是同一套断言的真浏览器版(51 项),用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。这一版修好了它**缺失的 `<body>` 标签**——脚本在 `<head>` 里执行时 `document.body` 为 null,这个页面以前在真浏览器里是直接坏的;并补上真 `Range` 的链接/行内代码断言(真浏览器的 `cloneContents()` 返回的是 DocumentFragment,不是元素——序列化器只认元素的话,链接在真浏览器里会退化成纯文本;离线桩现在也用真片段,不再放过这一类),另外补了 `CSS.highlights` 选区保持与关闭清理。
421
424
 
422
- `test/panel.mjs` 单独跑引用清单页与侧边线程回流(159 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
425
+ `test/panel.mjs` 单独跑引用清单页与侧边线程回流(160 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
423
426
  引用按原文逐行拆出来而评论跟着同一条记录、最新一条排最前、空查询保持全量、引用与评论都能被搜到且忽略大小写、
424
427
  Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超长引用在标题里被截断、非法时间导出为空;
425
428
  读取侧用桩 `sessions.retain` 真跑一遍——读出两行且 `hasMore` 透传、**读完必须 release**(记录 `source: "quoteList"`)、
@@ -437,7 +440,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
437
440
 
438
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` 的注册/卸载全部有断言。
439
442
 
440
- 合计 **667 组**(check 12 + capsule 451 + panel 160 + host 44),`npm test` 全绿。
443
+ 合计 **674 组**(check 12 + capsule 458 + panel 160 + host 44),`npm test` 全绿。
441
444
 
442
445
  ## 已知限制
443
446
 
@@ -465,6 +468,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
465
468
  - 收起状态的胶囊是**单行**:很长或分段的引用与评论会被省略号截断 —— 鼠标悬停会浮出完整内容,也可以点一下直接在原地展开。
466
469
  - 更新**之前**发出的老消息不会被变成胶囊:它们的文本里没有 `❝` 标记,插件无从辨认(重新引用一次即可)。
467
470
  - **原文高亮用完整引用文本定位**:页面刚刷新、那颗胶囊还没渲染时,插件手里只有坐标表里的前 120 字身份键,长引用会先亮前 120 字;胶囊进入视口后补上全文并自动重画。行不在视口、被虚拟列表收起、或文字已经变了就不画,绝不改宿主 DOM。支持 `CSS.highlights` 时高亮由浏览器绘制(跟手、不逐帧追滚动),不支持时退回浮层矩形;两种模式都不包 `mark`。
471
+ - **侧边对话的坐标要等它真的发出去才落色**:第三项存坐标时带 `draft` 标记,`paintAnchors` 跳过它,所以原文上不会先亮一块——放弃或关掉侧边对话都不留痕迹。转正靠胶囊:那句引用以胶囊形态出现在某条 transcript(同一个 ref)时才 `promoteSource` 并补画。因此**在另一个窗口**里把侧聊引用发出去时,本窗口要等下一次刷新(重新读 `localStorage` 里的表)才会补上这块高亮,而不是立刻出现。
468
472
  - **跨消息的选区不带来源头**:选区起点与终点落在不同消息行时,引用照常发出(原文、评论、代码形态都在),但不写 `引自第 N 轮 · 助手`,也不带可跳回的坐标——一句话横跨两轮时,任何单一轮次都是错的。想要带来源,就分两次引用。
469
473
  - **评论框期间的原选区高亮依赖 `CSS.highlights`**:Chromium/Electron 支持;不支持时静默跳过,评论框里仍有引用正文,不影响引用本身。
470
474
  - **聚合引用条的移除与编辑优先用宿主的 `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`),所以**刷新后**条、悬停卡与「打开文件」都还在——宿主本来就会恢复草稿里的芯片,索引跟着恢复,它们才重新算是本插件的引用。
@@ -481,7 +485,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
481
485
  - **Markdown 预览的行号**:要精修就得读源文件,而客户端必须声明 `remote` 才能拿到它;声明错会让 client bundle 在不匹配的宿主上**静默失效**(`dsh-quote-annotate` 上游就是这么死的)。所以先不声明——文件仍然打开,只是落在顶部而不是行上。
482
486
  - **表格/列表/LaTeX 的结构保真**:链接与行内代码已做;表格要还原成 Markdown 表格,列表要还原成缩进,成本明显更高,留到有人真的需要时再做。
483
487
  - **图片/截图引用**:明确不做(理由见「本插件不做的」)。真要做请独立成插件:图片引用要的是另一套胶囊/卡片/附件语义。
484
- - **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 667 项离线断言;隐藏线程的 `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 导出。
488
+ - **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 674 项离线断言;隐藏线程的 `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 导出。
485
489
  - **市场收录与首屏**:README 已有截图、徽章、「30 秒上手」与 24 秒静态轮播 GIF;还差把插件提交到 dsh.market / dsh-plugin.org / dsh.pub / awesome-deepseek-harness —— 这些需要外部账号,是人工动作。
486
490
 
487
491
  ## 变更记录
package/lib/client.js CHANGED
@@ -2317,6 +2317,9 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
2317
2317
  const isCode = segment.code !== null && segment.code !== undefined;
2318
2318
  const ref = segment.ref === undefined || segment.ref === null ? null : normalizeRef(segment.ref);
2319
2319
  if (ref !== null) rememberQuoteText(ref, segment.quote);
2320
+ // The quote is in a transcript with its ref: it was sent, so a mark
2321
+ // minted for its draft becomes permanent.
2322
+ if (ref !== null && promoteSource(ref, segment.quote)) scheduleAnchors();
2320
2323
  const jumpKey = capsuleJumpKey(segment.quote, sessionId, ref);
2321
2324
  const lost = lostJumps.has(jumpKey);
2322
2325
  const pair = comment === "" ? segment.quote : segment.quote + " · " + comment;
@@ -2748,7 +2751,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
2748
2751
  const turn = value.turn === undefined || value.turn === null ? "" : String(value.turn);
2749
2752
  const at = Number(value.at);
2750
2753
  const ordinal = Number(value.n);
2751
- return {
2754
+ const candidate = {
2752
2755
  sessionId: value.sessionId,
2753
2756
  attr: value.attr,
2754
2757
  key: value.key,
@@ -2758,6 +2761,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
2758
2761
  n: Number.isSafeInteger(ordinal) && ordinal >= 0 ? ordinal : 0,
2759
2762
  role: ROLE_CODES.has(value.role) ? value.role : ""
2760
2763
  };
2764
+ // Absent, not `false`, when it is not a draft: the stored table stays
2765
+ // the shape older builds wrote.
2766
+ if (value.draft === true) candidate.draft = true;
2767
+ return candidate;
2761
2768
  }
2762
2769
 
2763
2770
  /** Storage identity of a candidate: one row can carry two quoted sentences. */
@@ -3009,10 +3016,16 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
3009
3016
  * Remember where a quote was selected from. Returns the stored candidate
3010
3017
  * (its `n` is the ordinal inside the bin) so the caller can put the same
3011
3018
  * coordinate into the sent text as a self-carrying ref.
3019
+ *
3020
+ * `options.draft` marks a coordinate minted for a quote that has not been
3021
+ * sent anywhere yet — the side thread's chip. The row still resolves (the
3022
+ * card and the jump need its ordinal) but it is not painted on the source
3023
+ * until the quote turns up in a transcript; see `promoteSource`.
3012
3024
  */
3013
- function saveSource(quote, sessionId, source) {
3025
+ function saveSource(quote, sessionId, source, options) {
3014
3026
  const key = sourceKeyOf(quote);
3015
3027
  if (key.length < 4 || source === null || typeof source !== "object") return null;
3028
+ const draft = options !== null && typeof options === "object" && options.draft === true;
3016
3029
  const owner = typeof source.sessionId === "string" && source.sessionId !== "" ? source.sessionId : sessionId;
3017
3030
  if (typeof owner !== "string" || owner === "") return null;
3018
3031
  const attr = source.attr;
@@ -3031,11 +3044,15 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
3031
3044
  let ordinal = 1;
3032
3045
  for (const entry of list) ordinal = Math.max(ordinal, (Number(entry.n) || 0) + 1);
3033
3046
  candidate = { sessionId: owner, attr, key: rowKey, turn, quote: key, at, n: ordinal, role, origin };
3047
+ if (draft) candidate.draft = true;
3034
3048
  list.push(candidate);
3035
3049
  } else {
3036
3050
  const kept = role === "" && ROLE_CODES.has(list[index].role) ? list[index].role : role;
3037
3051
  const keptOrigin = origin === "" ? String(list[index].origin ?? "") : origin;
3038
3052
  candidate = { sessionId: owner, attr, key: rowKey, turn, quote: key, at, n: list[index].n, role: kept, origin: keptOrigin };
3053
+ // Only a draft save carries the mark: a send is what makes a
3054
+ // coordinate permanent, and it saves without the option.
3055
+ if (draft) candidate.draft = true;
3039
3056
  list[index] = candidate;
3040
3057
  }
3041
3058
  // Re-insert so the Map's own order agrees with "this was just used".
@@ -3143,6 +3160,32 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
3143
3160
  return out;
3144
3161
  }
3145
3162
 
3163
+ /**
3164
+ * A coordinate saved for a draft becomes permanent the moment the quote
3165
+ * shows up in a transcript: a capsule carrying the ref is proof the reader
3166
+ * actually sent it. Until then `saveSource(..., { draft: true })` keeps it
3167
+ * out of `paintAnchors`, so a side chat the reader backs out of leaves no
3168
+ * mark on the source.
3169
+ *
3170
+ * @returns {boolean} true when a draft was promoted (the caller repaints).
3171
+ */
3172
+ function promoteSource(ref, quote) {
3173
+ const wire = normalizeRef(ref);
3174
+ if (wire === null) return false;
3175
+ const id = [wire.s, wire.a, wire.k, wire.t, sourceKeyOf(quote)].join("\u0000");
3176
+ let promoted = false;
3177
+ for (const list of sourceIndex.values()) {
3178
+ for (const entry of list) {
3179
+ if (entry.draft !== true) continue;
3180
+ if (sourceCandidateId(entry) !== id) continue;
3181
+ delete entry.draft;
3182
+ promoted = true;
3183
+ }
3184
+ }
3185
+ if (promoted) persistSources();
3186
+ return promoted;
3187
+ }
3188
+
3146
3189
  /**
3147
3190
  * Identity of one capsule's coordinate; the lost state is scoped to it.
3148
3191
  * JSON keeps the parts unambiguous and the value printable as an attribute.
@@ -4281,6 +4324,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
4281
4324
  for (const list of sourceIndex.values()) {
4282
4325
  for (const candidate of list) {
4283
4326
  if (painted >= ANCHOR_LIMIT) break;
4327
+ // A coordinate minted for a draft (the side thread's chip) is
4328
+ // not on the page yet: painting it now would leave the reader
4329
+ // with a mark for a quote that was never sent.
4330
+ if (candidate.draft === true) continue;
4284
4331
  const row = index.get(candidate.sessionId + "\u0000" + candidate.attr + "\u0000" + candidate.key) ?? index.get("\u0000" + candidate.attr + "\u0000" + candidate.key);
4285
4332
  if (row === undefined || row === null || !row.isConnected || !rowShown(row)) continue;
4286
4333
  const rowRect = typeof row.getBoundingClientRect === "function" ? row.getBoundingClientRect() : null;
@@ -4856,6 +4903,8 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
4856
4903
  editing: null,
4857
4904
  settle: null,
4858
4905
  pointerOnTranscript: false,
4906
+ /** True while the bar holds a transcript selection alive on purpose. */
4907
+ keptSelection: false,
4859
4908
  suppress: false
4860
4909
  };
4861
4910
 
@@ -4905,6 +4954,9 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
4905
4954
  // except in the textarea, which needs the default focus behaviour.
4906
4955
  if (event.target instanceof Element && event.target.closest(".dshq-input") !== null) return;
4907
4956
  event.preventDefault();
4957
+ // The default is gone, so the browser will not collapse the selection
4958
+ // by itself. `close()` hands it back when the bar goes away unarmed.
4959
+ state.keptSelection = true;
4908
4960
  }
4909
4961
 
4910
4962
  function onBarClick(event) {
@@ -5149,9 +5201,26 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5149
5201
  }
5150
5202
  }
5151
5203
 
5204
+ /**
5205
+ * Kimi drops the transcript selection the moment an action is chosen (or
5206
+ * the bar is dismissed). The bar has to do it by hand: its own mousedown
5207
+ * calls `preventDefault()` to keep the selection while the reader aims,
5208
+ * so the browser never performs that default for us.
5209
+ */
5210
+ function dropTranscriptSelection() {
5211
+ const selection = typeof document === "undefined" || typeof document.getSelection !== "function" ? null : document.getSelection();
5212
+ if (selection === null || selection === undefined) return false;
5213
+ if (typeof selection.removeAllRanges !== "function") return false;
5214
+ if (selection.rangeCount === 0 || selection.isCollapsed === true) return false;
5215
+ selection.removeAllRanges();
5216
+ return true;
5217
+ }
5218
+
5152
5219
  function open(found, range = lastSelectionRange) {
5153
5220
  const bar = ensureBar(document);
5154
5221
  paintSourceHighlight(range);
5222
+ // A fresh selection owns the document selection; nothing to hand back.
5223
+ state.keptSelection = false;
5155
5224
  state.quote = found.text;
5156
5225
  state.anchor = { x: found.x, y: found.y, bottom: found.bottom };
5157
5226
  state.sessionId = found.sessionId;
@@ -5258,6 +5327,13 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5258
5327
 
5259
5328
  function close() {
5260
5329
  clearSourceHighlight();
5330
+ // The bar held the selection alive with `preventDefault()`; going away
5331
+ // without an action means handing it back, or the reader is left with a
5332
+ // selection they cannot get rid of by clicking anywhere.
5333
+ if (state.keptSelection === true) {
5334
+ state.keptSelection = false;
5335
+ dropTranscriptSelection();
5336
+ }
5261
5337
  lastSelectionRange = null;
5262
5338
  if (state.settle !== null) {
5263
5339
  clearTimeout(state.settle);
@@ -5369,8 +5445,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5369
5445
  if (revising !== null && trimmed !== "") rememberRevision(revising.quote, trimmed, quoteText, revising.ref);
5370
5446
 
5371
5447
  // Kimi drops the transcript selection the moment an action is chosen.
5372
- const selection = document.getSelection === undefined ? null : document.getSelection();
5373
- if (selection !== null) selection.removeAllRanges();
5448
+ dropTranscriptSelection();
5374
5449
 
5375
5450
  // An edited draft quote: the old chip leaves first, so the corrected one
5376
5451
  // lands where the old one stood. If the host does not act on the
@@ -6556,7 +6631,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
6556
6631
  // the reader's origin, and the wire coordinate (same shape `commit`
6557
6632
  // writes) so the side thread's chip still says where the quote came
6558
6633
  // from once it is sent there.
6559
- const saved = state.source === null || state.source === undefined ? null : saveSource(plainOf(text), sessionId, state.source);
6634
+ const saved = state.source === null || state.source === undefined ? null : saveSource(plainOf(text), sessionId, state.source, { draft: true });
6560
6635
  const meta = {
6561
6636
  path,
6562
6637
  lang,
@@ -6582,8 +6657,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
6582
6657
  const quoteKey = sourceKeyOf(text);
6583
6658
  close();
6584
6659
  // Kimi drops the transcript selection the moment an action is chosen.
6585
- const selection = document.getSelection === undefined ? null : document.getSelection();
6586
- if (selection !== null) selection.removeAllRanges();
6660
+ dropTranscriptSelection();
6587
6661
  if (config().sideChatBackend === "better") {
6588
6662
  const better = sideChatService();
6589
6663
  if (better !== null) {
@@ -8615,6 +8689,7 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
8615
8689
  prefillQuoteDraft,
8616
8690
  sourceKeyOf,
8617
8691
  saveSource,
8692
+ promoteSource,
8618
8693
  sourceFor,
8619
8694
  sourceCandidatesFor,
8620
8695
  sourceBinKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.12.3",
3
+ "version": "0.12.4-lab.40",
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/capsule.mjs CHANGED
@@ -1265,7 +1265,7 @@ try {
1265
1265
  forkCalls.push(opts);
1266
1266
  return "session-native-menu-1";
1267
1267
  };
1268
- testing.startRevision("端到端降级引用", "", "session-native-menu", null, false);
1268
+ testing.startRevision("端到端降级引用", "", "session-native-menu", null, false, { v: 1, s: "session-wire", a: "data-chat-anchor-key", k: "row-wire", t: "3", n: 1 });
1269
1269
  testing.openSideChat();
1270
1270
  await wait(120);
1271
1271
  check(
@@ -1276,6 +1276,30 @@ try {
1276
1276
  menuState.draft === "> 端到端降级引用",
1277
1277
  JSON.stringify({ forkCalls, openedResources, draft: menuState.draft })
1278
1278
  );
1279
+ // The quote only went into a draft: the source must stay unmarked until the
1280
+ // reader actually sends it there — the row below would paint, so the flag is
1281
+ // what is being measured, not a missing sentence.
1282
+ const wirePane = dom.document.createElement("div");
1283
+ wirePane.setAttribute("data-conversation-content", "");
1284
+ wirePane.setAttribute("data-conversation-session", "session-wire");
1285
+ const wireRow = dom.document.createElement("div");
1286
+ wireRow.setAttribute("data-chat-anchor-key", "row-wire");
1287
+ wireRow.setAttribute("data-chat-turn", "3");
1288
+ wireRow.setAttribute("data-chat-role", "assistant");
1289
+ wireRow.textContent = "前文 端到端降级引用 后文";
1290
+ wirePane.append(wireRow);
1291
+ dom.document.body.append(wirePane);
1292
+ testing.paintAnchors();
1293
+ const quotedButUnmarked = highlightRegistry.has("dshq-quote") === false;
1294
+ testing.promoteSource({ v: 1, s: "session-wire", a: "data-chat-anchor-key", k: "row-wire", t: "3", n: 1 }, "端到端降级引用");
1295
+ testing.paintAnchors();
1296
+ check(
1297
+ "a side chat leaves the source unmarked until its quote is sent",
1298
+ quotedButUnmarked && highlightRegistry.get("dshq-quote")?.ranges?.length === 1,
1299
+ JSON.stringify({ unmarked: quotedButUnmarked, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
1300
+ );
1301
+ wirePane.remove();
1302
+ testing.paintAnchors();
1279
1303
  openedResources.length = 0;
1280
1304
  nativeForkImpl = async (opts) => {
1281
1305
  forkCalls.push(opts);
@@ -2318,6 +2342,20 @@ try {
2318
2342
  dom.document.dispatch("keydown", { key: "Escape", isComposing: false, target: firstPane.run });
2319
2343
  check("Escape closes the bar", barOf()?.dataset.visible === "false", JSON.stringify(barOf()?.dataset.visible));
2320
2344
  check("closing the bar clears the source highlight", highlightRegistry.has("dshq-selection") === false);
2345
+ // The bar calls preventDefault() on its own mousedown so the transcript
2346
+ // selection survives while a row is aimed at — which is exactly why it has
2347
+ // to hand the selection back when it leaves without an action. Otherwise the
2348
+ // reader who picks a row and backs out is left with a selection nothing can
2349
+ // clear.
2350
+ selectIn(firstPane.run, "浮层要跟着手走的原文");
2351
+ check("the selection is live before a row is aimed at", dom.document.selection !== null);
2352
+ barOf().dispatch("mousedown", { target: barOf().querySelector('[data-action="add"]'), preventDefault() {} });
2353
+ dom.document.dispatch("keydown", { key: "Escape", isComposing: false, target: firstPane.run });
2354
+ check(
2355
+ "dismissing the bar hands back the selection it kept alive",
2356
+ dom.document.selection === null && barOf()?.dataset.visible === "false",
2357
+ JSON.stringify({ selection: dom.document.selection === null, visible: barOf()?.dataset.visible })
2358
+ );
2321
2359
  if (overlay !== undefined) {
2322
2360
  overlay.Component({ sessionId: "session-bar", inputActions: { captureInsertion: () => ({ draftRev: 1 }) } });
2323
2361
  overlay.Component({ sessionId: "session-bar-2", inputActions: { captureInsertion: () => ({ draftRev: 2 }) } });
@@ -3015,6 +3053,43 @@ try {
3015
3053
  JSON.stringify({ start: inlineRange?.start?.node?.nodeValue, end: inlineRange?.end?.node?.nodeValue })
3016
3054
  );
3017
3055
 
3056
+ // 侧边对话里那句引用还没发出去:坐标照样要存(卡片、跳回都要它),但原文上
3057
+ // 不许留下任何标记——读者可能把侧边对话关掉,那时高亮就成了无主的黄条。
3058
+ const draftRow = dom.document.createElement("div");
3059
+ draftRow.setAttribute("data-chat-anchor-key", "row-draft");
3060
+ draftRow.setAttribute("data-chat-turn", "7");
3061
+ draftRow.textContent = "前文 还没发出去的侧聊引用 后文";
3062
+ pane.append(draftRow);
3063
+ const draftQuote = "还没发出去的侧聊引用";
3064
+ const draftSaved = testing.saveSource(draftQuote, "session-anchor", { attr: "data-chat-anchor-key", key: "row-draft", turn: "7" }, { draft: true });
3065
+ check(
3066
+ "a coordinate saved for a draft still carries its ordinal",
3067
+ draftSaved !== null && draftSaved.n === 1 && draftSaved.draft === true,
3068
+ JSON.stringify(draftSaved)
3069
+ );
3070
+ check(
3071
+ "the draft flag is stored, so a reload cannot resurrect the mark",
3072
+ String(globalThis.localStorage.getItem("dsh-client-ui-quote:v2:quote-source") ?? "").includes(draftQuote) &&
3073
+ String(globalThis.localStorage.getItem("dsh-client-ui-quote:v2:quote-source") ?? "").includes('"draft":true'),
3074
+ String(globalThis.localStorage.getItem("dsh-client-ui-quote:v2:quote-source") ?? "").slice(0, 120)
3075
+ );
3076
+ testing.paintAnchors();
3077
+ check(
3078
+ "a draft coordinate is not painted on the source",
3079
+ highlightRegistry.get("dshq-quote")?.ranges?.length === 1 && layer?.querySelectorAll(".dshq-anchor-badge").length === 1,
3080
+ JSON.stringify({ ranges: highlightRegistry.get("dshq-quote")?.ranges?.length, badges: layer?.querySelectorAll(".dshq-anchor-badge").length })
3081
+ );
3082
+ const draftRef = { s: "session-anchor", a: "data-chat-anchor-key", k: "row-draft", t: "7", n: draftSaved?.n };
3083
+ const promoted = testing.promoteSource(draftRef, draftQuote);
3084
+ testing.paintAnchors();
3085
+ check(
3086
+ "the quote turning up in a transcript promotes its mark",
3087
+ promoted === true &&
3088
+ highlightRegistry.get("dshq-quote")?.ranges?.length === 2 &&
3089
+ layer?.querySelectorAll(".dshq-anchor-badge").length === 2,
3090
+ JSON.stringify({ promoted, ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
3091
+ );
3092
+
3018
3093
  const longQuote = "长".repeat(200);
3019
3094
  const longRef = { s: "session-anchor", a: "data-chat-anchor-key", k: "row-anchor", t: "5", n: 1 };
3020
3095
  testing.rememberQuoteText(longRef, longQuote);