dsh-client-ui-quote 0.10.0 → 0.11.0

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/README.md CHANGED
@@ -16,8 +16,9 @@
16
16
  | **引用清单** | 右侧栏的一个**页面**:本会话发出去的引用与评论的清单。搜索框按引用/评论过滤、点一条跳回原文、`导出 Markdown` 落一个文件、历史没读完时可 `加载更早的消息`。它作为**页面类型**注册进右侧栏(`ctx.sidebarRightTabs.register`),并在指南页留下一个胶囊作入口;`Alt+L` 同样能打开。数据来自会话自己的事件日志(`ctx.sessions.retain` → `session.eventSource`),不依赖屏幕上有没有那条消息。 |
17
17
  | **侧边线程回流** | 同一个页面底部是这条会话的**侧边线程**:显示它最后问的那个问题与消息条数,并给两个动作——**`带回主会话`** 把线程最后「问题 + 答案」写成主会话输入框里的草稿(**不发送**,你自己按发送),**`导出 Markdown`** 把整条线程落成一个文件。线程内容同样从它自己的事件日志读;没有线程时这一节只说明「还没有侧边线程」。 |
18
18
  | **改这条引用** | 悬停卡片里的 `改这条引用`:把那条引用当初的**引用与评论都原样放回评论框**(引用正文、评论各占一个输入框,都能改),你改完按 `⏎` 就发出一条**新的**引用(DSH 没有编辑已发送消息的 API,这是唯一诚实的做法)。旧胶囊随即挂上 **`已修订`** 标记,卡片里直接显示改后的评论;**连正文一起改过的**,卡片里也把改后的原文亮出来。标记按引用原文记在本机,跨刷新仍在。 |
19
+ | **逐条回应** | 评论框里的一格**勾选项**(默认关):勾上,这条引用的块尾就多一行**只有模型读得到的指令**——请按出现顺序把引用记为「引用1」「引用2」…并在回复中逐条对应。模型的回复里每个 `「引用N」` 会变成一枚**小徽章**:悬停就是那条引用的完整卡片(含 `跳回原文` 与 `改这条引用`),点了就跳;编号超出这条消息引用数的标签保持纯文本。指令行与围栏一样只是**线上形态**,胶囊、卡片、导出里都不出现。 |
19
20
  - 引用以 DSH 原生行内原子节点 `reference-chip` 插入(带图标的胶囊);走「评论」时评论一起放进这颗芯片(芯片标签显示为 `引用摘要 · 评论摘要`),引用与评论始终是一体,草稿里不会散成两段。
20
- - **从文件预览或终端划选的引用是代码**:线格式不再是纯块引用,而是 `> ❝ ``` ` / `> 代码行` / `> ``` ` —— 模型看到的是真正的围栏代码块;胶囊与悬停卡片随之换等宽字体、按代码的空白排版,引用清单导出时也落成 ``` 围栏而非 `>`。围栏只是线格式(传输时剥掉),胶囊上显示的仍是原始代码文本;正文里本身就有 ``` 时改用 `~~~` 围栏。
21
+ - **从文件预览或终端划选的引用是代码**:线格式不再是纯块引用,而是 `> ❝ ``` ` / `> 代码行` / `> ``` ` —— 模型看到的是真正的围栏代码块;胶囊与悬停卡片随之换等宽字体、按代码的空白排版,引用清单导出时也落成 ``` 围栏而非 `>`。围栏只是线格式(传输时剥掉),胶囊上显示的仍是原始代码文本;正文里本身就有 ``` 时改用 `~~~` 围栏。**文件预览的引用还带着它出自哪个文件**:宿主在预览 DOM 上标了路径时,路径就写进围栏的信息位(`> ❝ ``` src/app.ts`)——模型由此可以直接打开这个文件动手,清单导出的围栏行同样带着它;探测不到路径时就是原来的样子,什么也不损失。
21
22
  - **引用正文可以编辑**:评论框上半格是引用自己的输入框(评论在下半格),划选带进了提示符噪音或多选了几行,当场裁掉再发;「改这条引用」时旧正文回填同一格,改完随新引用发出。
22
23
  - 发送时由 DSH 回调本插件取「模型形态」:**`> ❝ …` 引用行 + 同一个块里的 `> ❞ …` 评论行**。消息里那段块会被就地换成 Kimi 那样的一颗胶囊
23
24
  (`❝ 引用摘要 │ 评论摘要`,鼠标移上去就能读到引用与评论的全文、点开可展开、悬停出现 `复制`),你自己写的话保持原样,模型收到的仍是完整引用文本。
@@ -52,18 +53,23 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
52
53
  name: dsh-client-ui-quote
53
54
  ```
54
55
 
55
- ### 方式三:npm 包(发布通道已就位,待 NPM_TOKEN)
56
+ ### 方式三:npm 包(已上架)
56
57
 
57
- `package.json` 已经是可发布状态:`private` 已移除、`files` 白名单只带 `lib` / `locale` / `test` / `icon.svg` / `cordis.patch.yml` / 文档,`prepublishOnly` 会在发布前跑完 253 项离线断言,`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
58
+ 已发布到 npm:`dsh-client-ui-quote@0.10.0`。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 285 项断言会先在 runner 上跑完,任何一项失败都不会发出去。`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
58
59
 
59
- **发布有两条路**(都只差一个 npm Access Token):
60
+ 装:
60
61
 
61
- 1. **GitHub Actions(推荐)**:`.github/workflows/publish.yml` 已就位——往仓库里配一个 secret `NPM_TOKEN`(npmjs.com → Access Tokens → Generate New Token,选 **Automation** 类型;或 `gh secret set NPM_TOKEN --repo BangBang-03/dsh-client-ui-quote`),然后推 `v*` 标签或在 Actions 页手动 Run workflow 即发布。之后的版本:改版本号 → 打 `vX.Y.Z` 标签推上去,发布全自动。
62
- 2. **本机 npm**:`npm login && npm publish`——注意本机直连 `registry.npmjs.org` 不可达(系统 DNS 返回污染地址、真实 Cloudflare IP 也握手超时),要挂一个能通 npmjs 的代理再发。
62
+ ```bash
63
+ dsh plugin --profile desktop add dsh-client-ui-quote
64
+ ```
63
65
 
64
- 装:`dsh plugin --profile desktop add dsh-client-ui-quote`。npmjs 侧发布后 npmmirror 一般 10 分钟内同步,DSH 若配置了镜像源即可国内直装。
66
+ npmjs 上架后 npmmirror 一般 10 分钟内同步,DSH 配置了镜像源即可国内直装。
65
67
 
66
- 在那之前请用方式一或方式二。
68
+ **发布运维备忘**(给未来的自己):
69
+
70
+ - 发布用的是 90 天期 granular token(勾选 **Bypass 2FA**——npm 2026-08 起强制 CI 直发必须带这个标记),**2027-01-09 到期**。到期前重签:npmjs.com → Access Tokens → Generate New Token → Granular → 勾 Bypass 2FA、Read and write (publish and stage)、All packages、90 days → 生成后 `gh secret set NPM_TOKEN --repo BangBang-03/dsh-client-ui-quote`(gh 需已登录)。
71
+ - npm 对**新账号的首包**有安全审核 hold:发布成功后包页会短暂显示 `0.0.0-stage` 占位,属于正常现象,审核自动放行(本包 0.10.0 实测约 1 小时内过审),无需人工操作。
72
+ - npm 官方的长期方向是 Trusted Publishing(OIDC 直发):2027-01 废弃 bypass-2FA 直发之前,建议在包 Settings → Trusted Publisher 登记 `BangBang-03/dsh-client-ui-quote` + `publish.yml`,并在 workflow 里加 `id-token: write` 权限与 `--provenance` 参数。
67
73
 
68
74
  ### 卸载
69
75
 
@@ -73,7 +79,7 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
73
79
 
74
80
  1. 在会话正文、**文件预览**或**终端**里**拖动选中**一段文字(后两者没有自己的会话 id,引用会落进当前屏幕上那条会话,并且以**围栏代码块**的线格式发给模型;输入框内的选区属于改稿,不触发)。
75
81
  2. 选区**下方**浮出浮层(下方空间不够时才改到上方),选择 **评论**、**添加到对话** 或 **侧边对话**。同一组动作也有键位:**`Alt+Q`** 直接引用、**`Alt+C`** 直接开评论框、**`Alt+B`** 直接开侧边对话(都在设置里可改;没有选区时按键原样让给其它快捷键)。
76
- 3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**),页面选区被清除、焦点交给输入框,可以接着写正式提问。
82
+ 3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**;评论框里还有一格 **`让模型逐条回应`** 勾选项,勾上这条引用就带着逐条指令发出去),页面选区被清除、焦点交给输入框,可以接着写正式提问。
77
83
  4. **侧边对话**写的是另一条会话的输入框:右侧栏打开该线程的页签(标题取引用首行),引用以纯块引用形态落在它的草稿里,**不发送**;你接着在这条线程里写自己的问题、自己按发送。同一条主会话只会有一条侧边线程,第二次点第三项就把新引用接在草稿后面。同一会话上一次请求还在进行时,再次点击会并入第一次,不会开出两条重复线程。
78
84
  5. 浮层收起条件:单击别处、选区清空、按 `Esc`、滚动会话、窗口尺寸变化,以及动作完成之后。双击选词不会把浮层关掉。
79
85
  6. 发送出去以后,消息里那段块会显示成一颗**胶囊**:收起时是单行 `❝ 引用摘要 │ 评论摘要`,**鼠标移上去**会浮出一张卡片显示完整的引用与评论
@@ -129,6 +135,10 @@ DSH 的输入框是 Lexical,没有 Kimi 的 `quote` 节点,但有一个通
129
135
  模型读到块引用里一个真正的围栏代码块;`splitQuoteRuns` 解析时把首行的围栏(和收尾那一行)剥掉,胶囊拿到的仍是原始代码文本,
130
136
  渲染加 `data-code="true"`(等宽、`pre`、展开时横向滚动),卡片加 `dshq-pop-code`,侧边对话落进子线程草稿的则是不带标记的裸围栏;
131
137
  正文本身含 ``` 时序列化自动换 `~~~` 围栏。没有围栏的块照旧按普通引用解析,新旧消息互不影响;
138
+ **预览引用的文件路径**走的是围栏自带的信息位(`> ❝ ``` src/app.ts`)——Markdown 里这本来就是「渲染器不显示、模型读得到」的位置,
139
+ `sanitizePath` 把带空白/围栏字符的伪路径挡掉,`previewPathOf` 在预览 DOM 与其几层祖先上探测一组候选属性,探测不到就原样发;
140
+ - **逐条回应的指令行**同样只是线上形态:勾了评论框里的开关,块尾多一行 `> ※ 指令:…`,`splitQuoteRuns` 按 `※ 指令` 前缀把它从块里剥掉,
141
+ 所以胶囊、卡片、清单导出全都看不到它(用户自己的评论/引用恰好以 `※ 指令` 开头的概率,比它带来的清晰收益小得多,已记入已知限制);
132
142
  - 客户端插件用 `MutationObserver` 找「只含一个文本子节点、内部有 `❝`、位于 `[data-conversation-content]` 里」的那个 run,
133
143
  把整条消息拆成 `前文 / 引用 / 中间文字 / 引用 / 后文` 这样一串分段(`splitQuoteRuns`),给原 run 加 `display:none`,
134
144
  在它**前面**按顺序插入自己的元素:每段引用一颗胶囊,每段用户自己的话一个 `span.dshq-body`(保持 `pre-wrap`)——
@@ -147,6 +157,15 @@ DSH 的输入框是 Lexical,没有 Kimi 的 `quote` 节点,但有一个通
147
157
  卡片跟着锚点活:锚点一旦离开文档(发消息会把芯片拿走,而被移除的节点不会再发 `mouseleave`)卡片立刻收起,
148
158
  React 重渲染换掉胶囊时同理 —— 不会留下一张浮在空处的卡片。
149
159
 
160
+ ### 逐条回应的回复徽章
161
+
162
+ 指令行只是让模型**肯按编号回答**;让回答**连得回来**的是另一套覆盖层。模型按指令把每条回应标成 `「引用1」…「引用N」` 之后:
163
+
164
+ - 徽章增强器在**同一条会话面板**里找这条回复之前**最近的一条带胶囊的消息**(一次树序遍历同时解决「最近」和「在前」:走到徽章文本节点之前最后记账的那条 run 就是它),把它发出去的引用按顺序编号——第 N 颗胶囊就是「引用N」。
165
+ - 回复文本里的 `「引用N」` 于是被切成一枚 `span.dshq-ref` 徽章(**原文一个字不改**,只是套上底色和悬停行为);编号超出那条消息引用数的标签(模型数错了,或有人在正文里自己写了「引用1」)保持纯文本——一枚点了没反应的徽章比没有徽章更糟。
166
+ - 徽章复用悬停卡片:`mouseenter` 就是那张卡(引用、评论全文,`跳回原文` 与 `改这条引用` 按坐标有无自动出现),点击等同清单页里点一行——有坐标就跳,没坐标无声。切分文本节点比胶囊那套「只隐藏、再旁边画」更进一步,规则也补得更紧:一个文本节点只切一次(账本记着原文);React 重写了文本(值不再是账本里的首块)就整体撤掉重切;卸载时原文放回。这层只挂在**助手消息**的文本上——用户自己消息里的「引用1」是草稿的一部分,谁也不该动。
167
+ - 侧边线程(第三项)的围栏同样带路径(``` src/app.ts 的裸围栏),子线程的模型由此也知道代码出自哪个文件。
168
+
150
169
  ### 侧边对话(第三项)
151
170
 
152
171
  第三项是本插件自己的扩展(Kimi 的浮层只有两项),融合了三家 agent 同类组件的优点:**Kimi Code 的 `/btw` 侧边聊天**——一键在侧边栏开一条继承主会话上下文的线程;**Qoder 的「在侧边任务中提问」**——同一会话进行中的创建请求自动并入第一次,失败只弹一条提示,绝不大张旗鼓;**WorkBuddy 的划选引用**——引用是线程里持久的内容,而不是一次性的草稿附件。与前两家不同的是**问什么由你来写**:引用只落进那条线程的输入框,不会被当成第一条消息替你发出去。样式上第三项用 DeepSeek 的鲸鱼蓝(`#4d6bfe`)画图标与悬停色,白底浅色主题是主基调,深色主题同一色相。
@@ -206,7 +225,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
206
225
  1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
207
226
  2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标不随引用文本发给模型(不污染 prompt)。
208
227
  3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在 4471 条里没有对应物。
209
- 4. **工程化**:253 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
228
+ 4. **工程化**:285 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
210
229
 
211
230
  反过来也要说清楚:**本插件不做的**——不做翻译/解释类选区动作(81 条同类在做)、不做多选合并引用(7 条有)、不做选区折叠/行内 diff 这类渲染花样。它只把「引用」这一件事做穿。
212
231
 
@@ -239,8 +258,8 @@ locale/zh.json 插件页卡片文案(中文)
239
258
  locale/en.json 插件页卡片文案(英文)
240
259
  test/check.mjs 离线自检(清单 + 组件契约)
241
260
  test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
242
- test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用,159 项断言)
243
- test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,94 项断言)
261
+ test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章,189 项断言)
262
+ test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,96 项断言)
244
263
  test/capsule.html 真浏览器里跑同一套胶囊断言(可选)
245
264
  ```
246
265
 
@@ -287,10 +306,10 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
287
306
  卡片给出 `跳回原文` 与 `改这条引用` 两个动作、点后者把栏切到评论模式并**预填旧评论与旧正文**、
288
307
  按确认后 `insertReference` 恰好一次(新芯片的 `ref` 带改后的评论)、旧引用被记为「已修订」并落进 `localStorage`、
289
308
  旧胶囊挂上 `已修订` 徽标、它的卡片显示改后的评论(连正文改过的连改后原文一起显示)、空修订不算修订、
290
- 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 159 项)。
309
+ 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 189 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用)。
291
310
  `test/capsule.html` 是同一套断言的真浏览器版,用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。
292
311
 
293
- `test/panel.mjs` 单独跑引用清单页与侧边线程回流(94 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
312
+ `test/panel.mjs` 单独跑引用清单页与侧边线程回流(96 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
294
313
  引用按原文逐行拆出来而评论跟着同一条记录、最新一条排最前、空查询保持全量、引用与评论都能被搜到且忽略大小写、
295
314
  Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超长引用在标题里被截断、非法时间导出为空;
296
315
  读取侧用桩 `sessions.retain` 真跑一遍——读出两行且 `hasMore` 透传、**读完必须 release**(记录 `source: "quoteList"`)、
@@ -306,7 +325,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
306
325
  `bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
307
326
  `exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
308
327
 
309
- 合计 **253 项**(check.mjs 的契约断言 + capsule 159 + panel 94),`npm test` 全绿。
328
+ 合计 **285 项**(check.mjs 的契约断言 + capsule 189 + panel 96),`npm test` 全绿。
310
329
 
311
330
  ## 已知限制
312
331
 
@@ -318,6 +337,9 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
318
337
  线程创建要求父会话处于运行态(历史归档会话会收到 409,同样落 toast)。
319
338
  - **「改这条引用」是追加,不是原地编辑**:DSH 的 transcript 只追加(侦察确认全树没有 `editMessage` / `updateMessage` / `rewriteMessage` / 回收 API,只有**尚未进入会话**的队列条目可改)。所以修订发出一条新引用(评论、引用正文都可改),旧胶囊只挂 `已修订` 标记——标记存在**本机** `localStorage`(`dsh-client-ui-quote:v1:quote-revised`,按引用原文归一键、上限 60 条),因此不会跨标签页、跨设备同步,也不进入模型上下文;模型看到的是那条改后的新引用。正文改过后键仍是旧原文:`已修订` 挂在**旧**胶囊上(它的卡片里显示改后的原文与评论),改后的新胶囊不挂标。
320
339
  - **代码引用的识别靠首行围栏**:`> ❝ ``` ` 开头的块按代码解析(围栏剥掉、胶囊等宽)。因此一条**普通**引用如果正文恰好以 ``` 或 `~~~` 开头(比如在 Markdown 文件预览里引用了源码),回读时也会被当成代码引用——显示上是等宽排版,文字不丢。
340
+ - **逐条回应的指令行按 `※ 指令` 前缀剥离**:剥离规则收得很窄,但一条引用或评论如果恰好以 `※ 指令` 开头,回读时那一行会被当成指令剥掉(胶囊里看不到它)。要让这样的内容活着,别勾逐条回应即可——指令行只在勾选时才写入。
341
+ - **文件预览的路径是探测来的**:宿主没有文档承诺预览 DOM 的路径属性,插件按一组候选属性在预览节点及其几层祖先上探测,拿不到就不带路径(引用形态与从前完全一样);终端来源的引用没有路径可言。路径写进围栏信息位,`sanitizePath` 把带空白或围栏字符的值挡掉。
342
+ - **回复徽章依赖模型守格式**:模型没按指令输出 `「引用N」` 就没有徽章——无损,只是少了联动;输出越界的编号(数错了)保持纯文本。徽章的编号以回复**之前最近的一条带胶囊的消息**为准;切分的是助手消息的文本节点,DSH 若改了助手消息的渲染形态,徽章增强器要跟着调(与胶囊的 `isQuoteRun` 同一条既有限制)。
321
343
  - 依赖 DSH 客户端的内部接口(`conversation.input.overlay` 槽位、`inputTriggers.registerSource`、`conversation.input.shell(id).insertReference`、
322
344
  `reference-chip` 节点),DSH 升级有可能失效。
323
345
  - **引用清单页依赖右侧栏的两个服务**(`sidebarRightTabs` 与 `sidebarRight`,实测 0.2.0-rc.2);宿主没有它们时页面不注册、`Alt+L` 给一条 toast
@@ -355,3 +377,8 @@ and it closes with the chip as soon as the message is sent. Each chip resolves i
355
377
  reuses a chip node for the next quote, so a card can never show the previous one's text.
356
378
  Install with `dsh plugin --profile desktop add link:<clone-path>` (DSH >= 0.2.0-rc.2, web/desktop client).
357
379
  Test with `npm test`. MIT licensed.
380
+
381
+ Two wire-only extras ride on the same pipeline: a per-quote reply toggle in the comment box (the block carries a
382
+ transport-only instruction, the model answers `「引用N」` by `「引用N」`, and each label becomes a badge that reopens
383
+ the quote's hover card), and a preview quote that puts its file into the fence's info string so the model can open
384
+ the file itself.
package/lib/client.js CHANGED
@@ -83,7 +83,8 @@ window.__ModuleLoader__.load({
83
83
  threadBackFailed: "没读到这个线程的内容",
84
84
  threadCount: "{n} 条消息",
85
85
  revise: "改这条引用",
86
- revised: "已修订"
86
+ revised: "已修订",
87
+ replyToggle: "让模型逐条回应"
87
88
  };
88
89
 
89
90
  /** English copy, verbatim from Kimi Code's `selection` namespace. */
@@ -119,7 +120,8 @@ window.__ModuleLoader__.load({
119
120
  threadBackFailed: "Could not read that thread",
120
121
  threadCount: "{n} messages",
121
122
  revise: "Revise this quote",
122
- revised: "Revised"
123
+ revised: "Revised",
124
+ replyToggle: "Ask for per-quote replies"
123
125
  };
124
126
 
125
127
  /* ------------------------------------------------------------------ *
@@ -150,6 +152,35 @@ window.__ModuleLoader__.load({
150
152
  */
151
153
  const COMMENT_MARK = "\u275E";
152
154
 
155
+ /**
156
+ * The mark that opens the transport-only reply instruction line. When the
157
+ * comment box's 逐条回应 toggle is on, the block ends with one more
158
+ * blockquote line starting with this mark; the model reads it and answers
159
+ * per quote (`「引用1」…「引用N」`), while `splitQuoteRuns` strips the line
160
+ * on the way back out — a capsule, a card and the export never show it.
161
+ * The `指令:` phrase after the mark is part of the strip rule, so a line
162
+ * that happens to start with a bare `※` is left alone.
163
+ */
164
+ const REPLY_MARK = "\u203B";
165
+ const REPLY_INSTRUCTION =
166
+ "指令:请把本条消息里的引用块按出现顺序记为「引用1」「引用2」…,回复时逐条对应回应,每条回应前标注「引用N」。";
167
+
168
+ /**
169
+ * Attributes a file preview may carry its path in. The preview DOM is a
170
+ * host detail this plugin does not own, so the lookup walks a few ancestors
171
+ * and takes the first hit; when none matches, the quote travels without a
172
+ * path — exactly the shape it had before the probe existed.
173
+ */
174
+ const PREVIEW_PATH_ATTRS = [
175
+ "data-textpreview-path",
176
+ "data-preview-path",
177
+ "data-document-path",
178
+ "data-file-path",
179
+ "data-path",
180
+ "data-file",
181
+ "data-filename"
182
+ ];
183
+
153
184
  /** Transcript host: a selection outside it is none of our business. */
154
185
  const CONTENT_SELECTOR = "[data-conversation-content]";
155
186
  /** The composer card: a selection inside it is a draft edit, not a quote. */
@@ -300,6 +331,10 @@ background:transparent;color:inherit;font:inherit;cursor:default}
300
331
  #${BAR_ID} .dshq-btn-primary:hover{background:var(--dshq-accent);opacity:.9}
301
332
  #${BAR_ID} .dshq-btn:disabled{opacity:.45}
302
333
  #${BAR_ID} .dshq-enter{margin-left:4px;opacity:.65}
334
+ #${BAR_ID} .dshq-reply{display:flex;align-items:center;gap:6px;font-size:11.5px;line-height:1.5;
335
+ opacity:.78;cursor:pointer;user-select:none}
336
+ #${BAR_ID} .dshq-reply:hover{opacity:1}
337
+ #${BAR_ID} .dshq-reply-check{margin:0;accent-color:var(--dshq-accent,currentColor)}
303
338
  #${TOAST_ID}{position:fixed;z-index:2147483001;display:none;padding:4px 10px;border-radius:6px;
304
339
  background:rgba(24,24,27,.92);color:#f4f4f5;font-size:12px;line-height:1.5;pointer-events:none;
305
340
  -webkit-app-region:no-drag}
@@ -334,6 +369,9 @@ text-overflow:clip;overflow-wrap:anywhere}
334
369
  .dshq-cap[data-expanded="true"] .dshq-cap-comment{flex:1 1 100%;max-width:none;margin-top:5px;
335
370
  padding-left:9px;white-space:pre-wrap;overflow:visible;text-overflow:clip;overflow-wrap:anywhere}
336
371
  .dshq-body{display:block;white-space:pre-wrap;overflow-wrap:anywhere}
372
+ .dshq-ref{display:inline;padding:0 4px;border-radius:6px;cursor:pointer;color:inherit;font-weight:600;
373
+ background:rgba(127,127,127,.12);background:color-mix(in srgb,currentColor 9%,transparent)}
374
+ .dshq-ref:hover{background:rgba(127,127,127,.2);background:color-mix(in srgb,currentColor 16%,transparent)}
337
375
  /* Hover card: a collapsed pill keeps one clipped line per half and the chip in
338
376
  the composer is clipped even harder, so hovering either one shows the quote and
339
377
  the comment in full. It is parked on <body> with position:fixed because a chat
@@ -447,12 +485,44 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
447
485
  * ------------------------------------------------------------------ */
448
486
 
449
487
  /**
450
- * @returns {{ text: string, x: number, y: number, bottom: number, sessionId: string, hostKind: "chat" | "preview" | "terminal" } | null}
488
+ * @returns {{ text: string, x: number, y: number, bottom: number, sessionId: string, hostKind: "chat" | "preview" | "terminal", path: string | null } | null}
451
489
  * the selected text plus its viewport anchor, or null when the current
452
490
  * selection is not a quotable transcript selection. `hostKind` says where
453
491
  * the selection lives: a preview/terminal quote is code and travels in a
454
- * fenced block, a chat quote stays a plain blockquote.
492
+ * fenced block, a chat quote stays a plain blockquote. `path` is the file
493
+ * a preview selection came from, when the host exposes one.
494
+ */
495
+ /**
496
+ * A path is only worth sending when it reads as one: no whitespace (the
497
+ * fence info string cannot carry it), no fence characters, not absurdly
498
+ * long. Anything else degrades to no path at all.
499
+ */
500
+ function sanitizePath(value) {
501
+ const path = String(value === null || value === undefined ? "" : value).trim();
502
+ if (path === "" || path.length > 200 || /\s|[`~]/u.test(path)) return null;
503
+ return path;
504
+ }
505
+
506
+ /**
507
+ * The file a preview selection came from, when the host exposes it. The
508
+ * probe walks a few ancestors and takes the first path-looking attribute;
509
+ * the preview DOM belongs to the host, so a miss is the normal case and
510
+ * costs nothing — the quote then travels exactly as before.
455
511
  */
512
+ function previewPathOf(host) {
513
+ let node = host;
514
+ for (let hop = 0; node !== null && node !== undefined && hop < 6; hop += 1, node = node.parentElement) {
515
+ if (typeof node.getAttribute !== "function") continue;
516
+ for (const attr of PREVIEW_PATH_ATTRS) {
517
+ const value = node.getAttribute(attr);
518
+ if (typeof value !== "string") continue;
519
+ const clean = sanitizePath(value);
520
+ if (clean !== null) return clean;
521
+ }
522
+ }
523
+ return null;
524
+ }
525
+
456
526
  function readSelection() {
457
527
  const selection = document.getSelection === undefined ? null : document.getSelection();
458
528
  if (selection === null || selection.rangeCount === 0 || selection.isCollapsed) return null;
@@ -492,6 +562,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
492
562
  const rect = range.getBoundingClientRect();
493
563
  if (rect.width === 0 && rect.height === 0) return null;
494
564
 
565
+ // A preview selection may carry the file it came from: the path rides
566
+ // in the fence's info string so the model can open the file itself.
567
+ const path = hostKind === "preview" ? previewPathOf(host) : null;
568
+
495
569
  return {
496
570
  text,
497
571
  x: rect.left + rect.width / 2,
@@ -499,6 +573,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
499
573
  bottom: rect.bottom,
500
574
  sessionId,
501
575
  hostKind,
576
+ path: typeof path === "string" ? path : null,
502
577
  // Only a transcript selection has a row to jump back to.
503
578
  source: chat === null ? null : chatRowOf(range.startContainer)
504
579
  };
@@ -530,8 +605,18 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
530
605
  * `> ❝ ``` ` / `> …` / `> ``` ` — so the model sees a real code block
531
606
  * while the block (and its capsule) still opens at the ❝ mark. The fence
532
607
  * is transport only: `splitQuoteRuns` strips it again on the way out.
608
+ * A preview quote can carry its file the same way: `meta.path` rides in
609
+ * the fence's info string (`> ❝ ``` src/app.ts`), which is where Markdown
610
+ * keeps metadata the model reads but renderers never show as content.
611
+ *
612
+ * `meta.reply === true` appends one transport line at the block's tail —
613
+ * the 逐条回应 instruction the model answers quote by quote. Like the
614
+ * fence it is wire-only: `splitQuoteRuns` strips it before anything
615
+ * downstream (capsule, card, export) ever sees it.
533
616
  */
534
- function quoteBlock(text, comment, code) {
617
+ function quoteBlock(text, comment, code, meta) {
618
+ const options = meta === null || meta === undefined || typeof meta !== "object" ? {} : meta;
619
+ const path = sanitizePath(options.path) ?? "";
535
620
  const halves = [
536
621
  { mark: QUOTE_MARK, lines: blockLines(text) },
537
622
  { mark: COMMENT_MARK, lines: blockLines(comment) }
@@ -540,7 +625,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
540
625
  for (const half of halves) {
541
626
  if (code === true && half.mark === QUOTE_MARK && half.lines.length > 0) {
542
627
  const fence = half.lines.some((line) => /^\s*```/u.test(line)) ? "~~~" : "```";
543
- block.push("> " + half.mark + " " + fence);
628
+ block.push("> " + half.mark + " " + fence + (path === "" ? "" : " " + path));
544
629
  for (const line of half.lines) block.push(line.trim() === "" ? ">" : "> " + line);
545
630
  block.push("> " + fence);
546
631
  continue;
@@ -551,6 +636,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
551
636
  else block.push(line.trim() === "" ? ">" : "> " + line);
552
637
  }
553
638
  }
639
+ if (options.reply === true) block.push("> " + REPLY_MARK + " " + REPLY_INSTRUCTION);
554
640
  return block.join("\n");
555
641
  }
556
642
 
@@ -594,14 +680,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
594
680
  body.push(lines[index].replace(/^>\s?/u, ""));
595
681
  index += 1;
596
682
  }
683
+ // The 逐条回应 instruction is transport for the model: it rides at
684
+ // the block's tail and never reaches a capsule, a card or the
685
+ // export. The `指令:` phrase keeps the strip narrow — a quote or
686
+ // comment that merely starts with a `※` is left alone.
687
+ const carried = body.filter((entry) => !/^\s*※\s*指令/u.test(entry));
597
688
  const before = loose.replace(/^\s+/u, "").replace(/\s+$/u, "");
598
689
  if (before !== "") segments.push({ type: "text", text: before });
599
690
  loose = "";
600
691
  // `> ❞ …` opens the comment half of the same block: everything from
601
692
  // there down is the comment, everything above it is the quote.
602
- const cut = body.findIndex((entry) => entry.replace(/^\s+/u, "").startsWith(COMMENT_MARK));
603
- let quoteLines = cut === -1 ? body : body.slice(0, cut);
604
- const commentLines = cut === -1 ? [] : body.slice(cut);
693
+ const cut = carried.findIndex((entry) => entry.replace(/^\s+/u, "").startsWith(COMMENT_MARK));
694
+ let quoteLines = cut === -1 ? carried : carried.slice(0, cut);
695
+ const commentLines = cut === -1 ? [] : carried.slice(cut);
605
696
  if (commentLines.length > 0) {
606
697
  commentLines[0] = commentLines[0]
607
698
  .replace(/^\s+/u, "")
@@ -638,9 +729,9 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
638
729
  }
639
730
 
640
731
  /** Quote (+ optional comment) exactly as it should land in the draft. */
641
- function buildPayload(text, comment, code) {
732
+ function buildPayload(text, comment, code, meta) {
642
733
  const prefix = composerText().trim() === "" ? "" : "\n";
643
- return prefix + quoteBlock(text, comment, code) + "\n\n";
734
+ return prefix + quoteBlock(text, comment, code, meta) + "\n\n";
644
735
  }
645
736
 
646
737
  /** Kimi inserts at the caret; the composer answers a collapsed span at the end. */
@@ -667,11 +758,11 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
667
758
  * comment rides in that same ref, which is what lets the transcript keep
668
759
  * the pair in one capsule instead of two unrelated pieces of text.
669
760
  */
670
- function chipRef(text, comment, code) {
761
+ function chipRef(text, comment, code, meta) {
671
762
  // The leading newline puts the block on its own paragraph when the chip
672
763
  // lands after typed text; the send path trims it when the quote is
673
764
  // the whole message.
674
- return "\n" + quoteBlock(text, comment, code) + "\n\n";
765
+ return "\n" + quoteBlock(text, comment, code, meta) + "\n\n";
675
766
  }
676
767
 
677
768
  /** The chip label spells the pair out the way Kimi's `display_text` does. */
@@ -729,7 +820,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
729
820
  * shell owns the editor that can place an atomic node. Every step is
730
821
  * optional — a missing service just falls back to the Markdown form.
731
822
  */
732
- function insertChip(entry, sessionId, text, comment, code) {
823
+ function insertChip(entry, sessionId, text, comment, code, meta) {
733
824
  if (!ensureSource() || pluginCtx === null) return false;
734
825
  let hub = null;
735
826
  try {
@@ -746,7 +837,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
746
837
  shell = null;
747
838
  }
748
839
  if (shell === null || shell === undefined || typeof shell.insertReference !== "function") return false;
749
- const model = chipRef(text, comment, code);
840
+ const model = chipRef(text, comment, code, meta);
750
841
  const attachment = {
751
842
  source: SOURCE_NAME,
752
843
  ref: model,
@@ -1541,7 +1632,9 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1541
1632
 
1542
1633
  const parent = run.parentNode;
1543
1634
  if (parent === null) return;
1544
- capsules.set(run, { text, nodes });
1635
+ // The segments ride along in the ledger: the reply badges need the
1636
+ // numbered quotes of the nearest preceding capsule-carrying message.
1637
+ capsules.set(run, { text, nodes, segments });
1545
1638
  run.style.display = "none";
1546
1639
  for (const node of nodes) parent.insertBefore(node, run);
1547
1640
  }
@@ -1572,9 +1665,13 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1572
1665
  for (const record of records) {
1573
1666
  if (record.type === "characterData") {
1574
1667
  collectRuns(record.target, capsuleDirty);
1668
+ collectRefRuns(record.target, refDirty);
1575
1669
  continue;
1576
1670
  }
1577
- for (const node of record.addedNodes) collectRuns(node, capsuleDirty);
1671
+ for (const node of record.addedNodes) {
1672
+ collectRuns(node, capsuleDirty);
1673
+ collectRefRuns(node, refDirty);
1674
+ }
1578
1675
  for (const node of record.removedNodes) {
1579
1676
  if (node.nodeType === 1 && capsules.has(node)) capsuleDirty.add(node);
1580
1677
  }
@@ -1601,6 +1698,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1601
1698
  if (run.isConnected) enhance(run);
1602
1699
  else if (capsules.has(run)) dropCapsule(run);
1603
1700
  }
1701
+ // Badges come last: the quotes they link to must be in the ledger first.
1702
+ flushRefBadges();
1604
1703
  wireChips();
1605
1704
  }
1606
1705
 
@@ -1626,7 +1725,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1626
1725
  capsuleObserver.observe(target, { childList: true, subtree: true, characterData: true });
1627
1726
  }
1628
1727
  if (typeof document.querySelectorAll === "function") {
1629
- for (const host of document.querySelectorAll(CONTENT_SELECTOR)) collectRuns(host, capsuleDirty);
1728
+ for (const host of document.querySelectorAll(CONTENT_SELECTOR)) {
1729
+ collectRuns(host, capsuleDirty);
1730
+ collectRefRuns(host, refDirty);
1731
+ }
1630
1732
  }
1631
1733
  // A fixed card would otherwise hang in place after the page moved.
1632
1734
  if (!popoverWired && typeof window !== "undefined" && typeof window.addEventListener === "function") {
@@ -1649,6 +1751,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1649
1751
  }
1650
1752
  capsuleDirty.clear();
1651
1753
  for (const run of Array.from(capsules.keys())) dropCapsule(run);
1754
+ uninstallRefBadges();
1652
1755
  cancelPopoverHide();
1653
1756
  unhookChips();
1654
1757
  if (popover !== null) {
@@ -1668,6 +1771,199 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1668
1771
  }
1669
1772
  }
1670
1773
 
1774
+ /* ------------------------------------------------------------------ *
1775
+ * Reply badges — the model's per-quote answers, linked back
1776
+ * ------------------------------------------------------------------ */
1777
+
1778
+ /**
1779
+ * The other half of 逐条回应: when the instruction rode along with the
1780
+ * quotes, the model labels its answers `「引用1」…「引用N」`, and each
1781
+ * label becomes a badge — hover it and the quote's card opens (jump and
1782
+ * revise included, exactly as on a capsule), because the reply is only
1783
+ * useful while it stays tied to the passage it answers.
1784
+ *
1785
+ * The label lives inside a markdown-rendered assistant run, so this pass
1786
+ * splits the text node it sits in — one step more invasive than the
1787
+ * capsule overlay, which only hides and re-reads whole runs. The rules
1788
+ * that keep it safe: a text node is split only once (the ledger holds the
1789
+ * original), a rewrite by React is detected by the value no longer being
1790
+ * our head chunk (our nodes go, the node is re-read fresh), and uninstall
1791
+ * puts the original text back. A label whose number outruns the quotes of
1792
+ * the nearest preceding quoted message is left as plain text — the model
1793
+ * may miscount, and a dead badge would be worse than no badge.
1794
+ */
1795
+
1796
+ /** text node -> { text, head, nodes } for every split reply run. */
1797
+ const refBadges = new Map();
1798
+ const refDirty = new Set();
1799
+ const REF_LABEL = /\u300c\u5f15\u7528\d+\u300d/u;
1800
+
1801
+ function isRefCandidate(node) {
1802
+ if (node === null || node.nodeType !== 3) return false;
1803
+ const value = typeof node.nodeValue === "string" ? node.nodeValue : "";
1804
+ if (REF_LABEL.test(value) !== true) return false;
1805
+ const parent = node.parentElement;
1806
+ if (parent === null || typeof parent.closest !== "function") return false;
1807
+ if (parent.closest("[data-dshq]") !== null) return false;
1808
+ return parent.closest(CONTENT_SELECTOR) !== null;
1809
+ }
1810
+
1811
+ /** Gather candidate text nodes out of a mutation's added subtree. */
1812
+ function collectRefRuns(node, into) {
1813
+ if (node.nodeType === 3) {
1814
+ if (isRefCandidate(node) === true) into.add(node);
1815
+ return;
1816
+ }
1817
+ const element = elementOf(node);
1818
+ if (element === null) return;
1819
+ const stack = [element];
1820
+ while (stack.length > 0) {
1821
+ const current = stack.pop();
1822
+ for (const child of current.childNodes) {
1823
+ if (child.nodeType === 3) {
1824
+ if (isRefCandidate(child) === true) into.add(child);
1825
+ } else if (child.nodeType === 1 && child.childNodes.length > 0) {
1826
+ stack.push(child);
1827
+ }
1828
+ }
1829
+ }
1830
+ }
1831
+
1832
+ /**
1833
+ * The quotes of the nearest preceding capsule-carrying run, in message
1834
+ * order. One walk over the session host settles both "nearest" and
1835
+ * "before" — the run that was last seen when the walk reached the badge's
1836
+ * text node is the one that precedes it.
1837
+ */
1838
+ function precedingQuoteSegments(node, host) {
1839
+ let best = null;
1840
+ let seen = false;
1841
+ const walk = (current) => {
1842
+ if (seen === true) return;
1843
+ if (current === node) {
1844
+ seen = true;
1845
+ return;
1846
+ }
1847
+ if (capsules.has(current)) best = current;
1848
+ for (const child of current.childNodes) walk(child);
1849
+ };
1850
+ walk(host);
1851
+ if (best === null) return [];
1852
+ const known = capsules.get(best);
1853
+ const segments = known === undefined || known === null ? null : known.segments;
1854
+ if (!Array.isArray(segments)) return [];
1855
+ return segments.filter((segment) => segment !== null && segment !== undefined && segment.type === "quote");
1856
+ }
1857
+
1858
+ function removeRefNodes(known) {
1859
+ if (known === undefined || known === null) return;
1860
+ for (const node of known.nodes) {
1861
+ if (node !== null && node !== undefined && node.isConnected) node.remove();
1862
+ }
1863
+ }
1864
+
1865
+ function refBadgeFor(doc, sessionId, label, segment) {
1866
+ const info = {
1867
+ quote: segment.quote,
1868
+ comment: typeof segment.comment === "string" ? segment.comment : "",
1869
+ sessionId,
1870
+ code: segment.code !== null && segment.code !== undefined
1871
+ };
1872
+ const badge = el(doc, "span", { class: "dshq-ref", "data-dshq": "ref", text: label });
1873
+ badge.addEventListener("mouseenter", () => showPopover(badge, info));
1874
+ badge.addEventListener("mouseleave", hidePopoverSoon);
1875
+ badge.addEventListener("click", (event) => {
1876
+ // A drag-select over the badge must not fire a jump.
1877
+ const selection = doc.getSelection === undefined ? null : doc.getSelection();
1878
+ if (selection !== null && selection.isCollapsed === false) return;
1879
+ jumpToSource(segment.quote, sessionId);
1880
+ });
1881
+ return badge;
1882
+ }
1883
+
1884
+ /** Split one text node into plain text and badges, once. */
1885
+ function enhanceRefNode(node) {
1886
+ if (refBadges.has(node)) return;
1887
+ if (isRefCandidate(node) !== true) return;
1888
+ const value = node.nodeValue;
1889
+ const parts = value.split(/(\u300c\u5f15\u7528\d+\u300d)/u);
1890
+ if (parts.length < 3) return;
1891
+ const parent = node.parentNode;
1892
+ const doc = node.ownerDocument === null || node.ownerDocument === undefined ? (parent === null ? null : parent.ownerDocument) : node.ownerDocument;
1893
+ if (parent === null || doc === null || doc === undefined) return;
1894
+ const host = parent.closest !== undefined ? parent.closest(CONTENT_SELECTOR) : null;
1895
+ if (host === null) return;
1896
+ const sessionId = (() => {
1897
+ const id = host.getAttribute("data-conversation-session");
1898
+ return typeof id === "string" ? id : null;
1899
+ })();
1900
+ const quotes = precedingQuoteSegments(node, host);
1901
+ if (quotes.length === 0) return;
1902
+
1903
+ const nodes = [];
1904
+ // The original node keeps the first chunk; everything else is inserted
1905
+ // after it, in order, so React's node stays where reconciliation left it.
1906
+ node.nodeValue = parts[0];
1907
+ let previous = node;
1908
+ for (let index = 1; index < parts.length; index += 1) {
1909
+ const part = parts[index];
1910
+ let created = null;
1911
+ if (index % 2 === 1) {
1912
+ const number = Number(part.slice(3, -1));
1913
+ const segment = number >= 1 && number <= quotes.length ? quotes[number - 1] : null;
1914
+ created = segment === null ? doc.createTextNode(part) : refBadgeFor(doc, sessionId, part, segment);
1915
+ } else {
1916
+ created = doc.createTextNode(part);
1917
+ }
1918
+ if (created === null) continue;
1919
+ parent.insertBefore(created, previous.nextSibling);
1920
+ previous = created;
1921
+ nodes.push(created);
1922
+ }
1923
+ refBadges.set(node, { text: value, head: parts[0], nodes });
1924
+ }
1925
+
1926
+ /** Reconcile the ledger, then split whatever is still pending. */
1927
+ function flushRefBadges() {
1928
+ for (const [node, known] of Array.from(refBadges)) {
1929
+ if (node.isConnected === false) {
1930
+ removeRefNodes(known);
1931
+ refBadges.delete(node);
1932
+ continue;
1933
+ }
1934
+ // React rewrote the text: our chunks duplicate the tail now. Drop
1935
+ // everything we added and re-read the node's fresh value.
1936
+ if (node.nodeValue !== known.head) {
1937
+ removeRefNodes(known);
1938
+ refBadges.delete(node);
1939
+ refDirty.add(node);
1940
+ }
1941
+ }
1942
+ if (refDirty.size === 0) return;
1943
+ const candidates = Array.from(refDirty);
1944
+ refDirty.clear();
1945
+ for (const node of candidates) {
1946
+ if (node.isConnected === true) enhanceRefNode(node);
1947
+ }
1948
+ }
1949
+
1950
+ function uninstallRefBadges() {
1951
+ for (const [node, known] of Array.from(refBadges)) {
1952
+ removeRefNodes(known);
1953
+ // Restoring is only honest while React never touched the value
1954
+ // after the split; otherwise the fresh value is the truth.
1955
+ if (node.isConnected && node.nodeValue === known.head) {
1956
+ try {
1957
+ node.nodeValue = known.text;
1958
+ } catch (error) {
1959
+ // A read-only node only costs its original wording.
1960
+ }
1961
+ }
1962
+ }
1963
+ refBadges.clear();
1964
+ refDirty.clear();
1965
+ }
1966
+
1671
1967
  /* ------------------------------------------------------------------ *
1672
1968
  * Bar element
1673
1969
  * ------------------------------------------------------------------ */
@@ -1683,11 +1979,15 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1683
1979
  mode: null,
1684
1980
  sessionId: null,
1685
1981
  comment: "",
1686
- /** True when the selection came from the preview or the terminal:
1687
- * the quote travels as a fenced code block, not a plain quote. */
1688
- code: false,
1689
- /** Where the current selection was picked up, for the jump-back row. */
1690
- source: null,
1982
+ /** True when the selection came from the preview or the terminal:
1983
+ * the quote travels as a fenced code block, not a plain quote. */
1984
+ code: false,
1985
+ /** The file a preview selection came from, when the host exposes it. */
1986
+ path: null,
1987
+ /** 逐条回应: the current quote asks the model to answer per quote. */
1988
+ reply: false,
1989
+ /** Where the current selection was picked up, for the jump-back row. */
1990
+ source: null,
1691
1991
  /** The quote key being corrected, when the box was opened by 改这条引用. */
1692
1992
  revising: null,
1693
1993
  settle: null,
@@ -1871,6 +2171,20 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1871
2171
  quoteInput.addEventListener("keydown", onKey);
1872
2172
  input.addEventListener("keydown", onKey);
1873
2173
 
2174
+ // 逐条回应: one opt-in checkbox. On, the block carries a transport
2175
+ // instruction asking the model to answer per quote, and the reply's
2176
+ // `「引用N」` labels become badges that link back to these quotes.
2177
+ // Off (the default), the wire stays exactly the shape it always was.
2178
+ const replyCheck = el(doc, "input", { type: "checkbox", class: "dshq-reply-check" });
2179
+ replyCheck.checked = state.reply === true;
2180
+ replyCheck.addEventListener("change", () => {
2181
+ state.reply = replyCheck.checked === true;
2182
+ });
2183
+ const replyToggle = el(doc, "label", { class: "dshq-reply" }, [
2184
+ replyCheck,
2185
+ el(doc, "span", { text: translate("replyToggle") })
2186
+ ]);
2187
+
1874
2188
  grow(quoteInput);
1875
2189
  syncConfirm();
1876
2190
 
@@ -1878,6 +2192,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1878
2192
  el(doc, "div", { class: "dshq-comment" }, [
1879
2193
  quoteInput,
1880
2194
  input,
2195
+ replyToggle,
1881
2196
  el(doc, "div", { class: "dshq-actions" }, [
1882
2197
  el(doc, "button", { type: "button", class: "dshq-btn", "data-action": "cancel" }, [translate("cancel")]),
1883
2198
  confirm
@@ -1921,6 +2236,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1921
2236
  state.sessionId = found.sessionId;
1922
2237
  state.comment = "";
1923
2238
  state.code = found.hostKind === "preview" || found.hostKind === "terminal";
2239
+ state.path = typeof found.path === "string" ? found.path : null;
2240
+ state.reply = false;
1924
2241
  state.source = found.source === undefined ? null : found.source;
1925
2242
  state.mode = "menu";
1926
2243
  state.suppress = false;
@@ -1952,6 +2269,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1952
2269
  state.quote = quote;
1953
2270
  state.comment = typeof comment === "string" ? comment : "";
1954
2271
  state.code = code === true;
2272
+ state.path = null;
2273
+ state.reply = false;
1955
2274
  state.sessionId = sessionId === null || sessionId === undefined ? null : sessionId;
1956
2275
  state.source = sourceFor(quote);
1957
2276
  state.revising = sourceKeyOf(quote);
@@ -1977,6 +2296,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1977
2296
  state.sessionId = null;
1978
2297
  state.comment = "";
1979
2298
  state.code = false;
2299
+ state.path = null;
2300
+ state.reply = false;
1980
2301
  state.source = null;
1981
2302
  state.revising = null;
1982
2303
  state.suppress = true;
@@ -2014,6 +2335,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2014
2335
  const source = state.source;
2015
2336
  const revising = state.revising;
2016
2337
  const code = state.code;
2338
+ const meta = { path: state.path, reply: state.reply === true };
2017
2339
  const entry = sessionId === null ? null : sessions.get(sessionId);
2018
2340
  const trimmed = typeof comment === "string" ? comment.trim() : "";
2019
2341
  close();
@@ -2034,11 +2356,11 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2034
2356
  // the fallback for every build where that service face is missing.
2035
2357
  // A comment rides inside that chip — one node, one capsule after the
2036
2358
  // send — so 评论 writes nothing else into the draft.
2037
- if (insertChip(entry, sessionId, text, trimmed, code)) {
2359
+ if (insertChip(entry, sessionId, text, trimmed, code, meta)) {
2038
2360
  focusComposer();
2039
2361
  return;
2040
2362
  }
2041
- if (insertPayload(entry, buildPayload(text, trimmed, code))) focusComposer();
2363
+ if (insertPayload(entry, buildPayload(text, trimmed, code, meta))) focusComposer();
2042
2364
  else toast(translate("failed"));
2043
2365
  }
2044
2366
 
@@ -2093,13 +2415,15 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2093
2415
  * `❝` marks stay out on purpose — they exist for the MAIN transcript's
2094
2416
  * capsule enhancer, and the side thread renders its own way. A code quote
2095
2417
  * keeps its fence instead: that composer talks to the model, and a fenced
2096
- * block is what the model reads as code.
2418
+ * block is what the model reads as code. A preview quote puts its file
2419
+ * into the fence's info string, the same slot the main wire uses.
2097
2420
  */
2098
- function sideQuestion(text, code) {
2421
+ function sideQuestion(text, code, path) {
2099
2422
  const lines = blockLines(text);
2100
2423
  if (code === true && lines.length > 0) {
2101
2424
  const fence = lines.some((line) => /^\s*```/u.test(line)) ? "~~~" : "```";
2102
- return [fence].concat(lines, [fence]).join("\n");
2425
+ const clean = sanitizePath(path) ?? "";
2426
+ return [fence + (clean === "" ? "" : " " + clean)].concat(lines, [fence]).join("\n");
2103
2427
  }
2104
2428
  return lines
2105
2429
  .map((line) => (line.trim() === "" ? ">" : "> " + line))
@@ -2301,9 +2625,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2301
2625
  * @param {string} sessionId - the session the quote came from.
2302
2626
  * @param {string} text - the quoted text.
2303
2627
  * @param {boolean} [code] - true when the quote is code (preview/terminal).
2628
+ * @param {string | null} [path] - the file a preview quote came from.
2304
2629
  * @returns {Promise<void>} resolves once the draft is written or refused.
2305
2630
  */
2306
- function sideChatRequest(service, sessionId, text, code) {
2631
+ function sideChatRequest(service, sessionId, text, code, path) {
2307
2632
  if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text.trim() === "") {
2308
2633
  return Promise.resolve();
2309
2634
  }
@@ -2335,7 +2660,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2335
2660
  rememberSideThread(sessionId, childId);
2336
2661
  const threadId = await displayedThreadId(sessionId, childId);
2337
2662
  rememberSideThread(sessionId, threadId);
2338
- if (!(await prefillDraft(threadId, sideQuestion(text, code === true)))) {
2663
+ if (!(await prefillDraft(threadId, sideQuestion(text, code === true, path)))) {
2339
2664
  console.warn("[ui-quote] sidechat draft not written:", threadId);
2340
2665
  toast(translateFor(sessionId, "sideChatDraftFailed"));
2341
2666
  }
@@ -2360,6 +2685,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2360
2685
  if (sessionId === null || text === null) return;
2361
2686
  const service = sideChatService();
2362
2687
  const code = state.code;
2688
+ const path = state.path;
2363
2689
  close();
2364
2690
  // Kimi drops the transcript selection the moment an action is chosen.
2365
2691
  const selection = document.getSelection === undefined ? null : document.getSelection();
@@ -2368,7 +2694,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2368
2694
  toast(translateFor(sessionId, "sideChatFailed"));
2369
2695
  return;
2370
2696
  }
2371
- sideChatRequest(service, sessionId, text, code);
2697
+ sideChatRequest(service, sessionId, text, code, path);
2372
2698
  }
2373
2699
 
2374
2700
  /* ------------------------------------------------------------------ *
@@ -2624,7 +2950,10 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
2624
2950
  time: typeof event.time === "string" ? event.time : "",
2625
2951
  quote: segment.quote,
2626
2952
  comment: typeof segment.comment === "string" ? segment.comment : "",
2627
- code: segment.code !== null && segment.code !== undefined
2953
+ code: segment.code !== null && segment.code !== undefined,
2954
+ // The fence's info string (a preview quote carries its file
2955
+ // there) — the export puts it back on its own fence line.
2956
+ fence: typeof segment.code === "string" ? segment.code : ""
2628
2957
  });
2629
2958
  }
2630
2959
  }
@@ -2651,9 +2980,11 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
2651
2980
  }
2652
2981
  list.forEach((row, index) => {
2653
2982
  lines.push("## " + (index + 1) + ". " + firstLineOf(row.quote, 80), "");
2654
- // A code quote exports as a real fenced block, not a blockquote.
2983
+ // A code quote exports as a real fenced block, not a blockquote; a
2984
+ // preview quote's file rides in the info string, as it did on the wire.
2655
2985
  if (row.code === true) {
2656
- lines.push("```", String(row.quote), "```", "");
2986
+ const info = typeof row.fence === "string" && row.fence !== "" ? " " + row.fence : "";
2987
+ lines.push("```" + info, String(row.quote), "```", "");
2657
2988
  } else {
2658
2989
  for (const line of String(row.quote).split("\n")) lines.push("> " + line);
2659
2990
  lines.push("");
@@ -3384,6 +3715,8 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
3384
3715
  splitQuoteRuns,
3385
3716
  chipRef,
3386
3717
  chipLabel,
3718
+ sanitizePath,
3719
+ previewPathOf,
3387
3720
  rememberChip,
3388
3721
  normalizeLabel,
3389
3722
  barTop,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.10.0",
4
- "description": "Select text in the DSH transcript, a file preview or the terminal to quote it into the composer or into a side thread's composer, Kimi-Code style (comment / add to conversation / side chat), with Alt+Q/C/B, a jump back to the quoted source, a searchable quote list page that exports as Markdown, one-click back-flow of a side thread's conclusion, and revisions of a sent quote that mark the old capsule as superseded. The quoted text itself is editable in the comment box (trim a selection, correct a revision's wording), and a selection from the file preview or the terminal travels to the model as a fenced code block.",
3
+ "version": "0.11.0",
4
+ "description": "Select text in the DSH transcript, a file preview or the terminal to quote it into the composer or into a side thread's composer, Kimi-Code style (comment / add to conversation / side chat), with Alt+Q/C/B, a jump back to the quoted source, a searchable quote list page that exports as Markdown, one-click back-flow of a side thread's conclusion, and revisions of a sent quote that mark the old capsule as superseded. The quoted text itself is editable in the comment box (trim a selection, correct a revision's wording), a selection from the file preview or the terminal travels to the model as a fenced code block carrying its file path, and a per-quote reply toggle has the model answer quote by quote, each 「引用N」 label in the reply becoming a badge linked back to the quote.",
5
5
  "icon": "icon.svg",
6
6
  "license": "MIT",
7
7
  "author": "BangBang-03",
package/test/capsule.mjs CHANGED
@@ -1047,6 +1047,253 @@ try {
1047
1047
  codeCap.dispatch("mouseleave", {});
1048
1048
  }
1049
1049
 
1050
+ /* ------------------------------------------------------------------ *
1051
+ * 文件路径 + 逐条回应 — the wire's two new transport-only forms
1052
+ * ------------------------------------------------------------------ */
1053
+
1054
+ {
1055
+ // 预览引用: the file rides in the fence's info string, where Markdown keeps
1056
+ // metadata the model reads but renderers never show as content.
1057
+ check(
1058
+ "a preview quote puts its file into the fence info string",
1059
+ testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts" }) === "> ❝ ``` src/app.ts\n> const x = 1;\n> ```",
1060
+ JSON.stringify(testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts" }))
1061
+ );
1062
+ const pathSegments = testing.splitQuoteRuns(testing.quoteBlock("const x = 1;", "看一下", true, { path: "src/app.ts" }));
1063
+ check(
1064
+ "the path parses back as the fence's info string",
1065
+ pathSegments?.length === 1 && pathSegments[0].code === "src/app.ts" && pathSegments[0].quote === "const x = 1;" && pathSegments[0].comment === "看一下",
1066
+ JSON.stringify(pathSegments)
1067
+ );
1068
+ check(
1069
+ "a path rides along when the content forces ~~~",
1070
+ testing.quoteBlock("```\nx = 1\n```", "", true, { path: "src/a.ts" }) === "> ❝ ~~~ src/a.ts\n> ```\n> x = 1\n> ```\n> ~~~",
1071
+ JSON.stringify(testing.quoteBlock("```\nx = 1\n```", "", true, { path: "src/a.ts" }))
1072
+ );
1073
+ check(
1074
+ "a path with whitespace is dropped, not smuggled",
1075
+ testing.quoteBlock("x", "", true, { path: "my file.ts" }) === "> ❝ ```\n> x\n> ```",
1076
+ JSON.stringify(testing.quoteBlock("x", "", true, { path: "my file.ts" }))
1077
+ );
1078
+ check(
1079
+ "sanitizePath takes the obvious rejects",
1080
+ testing.sanitizePath(" src/a.ts ") === "src/a.ts" &&
1081
+ testing.sanitizePath("a b") === null &&
1082
+ testing.sanitizePath("a`b") === null &&
1083
+ testing.sanitizePath("a~b") === null &&
1084
+ testing.sanitizePath("") === null,
1085
+ JSON.stringify([testing.sanitizePath(" src/a.ts "), testing.sanitizePath("a b"), testing.sanitizePath("a`b"), testing.sanitizePath("a~b"), testing.sanitizePath("")])
1086
+ );
1087
+
1088
+ // 逐条回应: one instruction line at the block's tail, wire-only.
1089
+ const replyWire = testing.quoteBlock("正文", "看看这里", false, { reply: true });
1090
+ check(
1091
+ "逐条回应 appends one instruction line to the block",
1092
+ replyWire === "> ❝ 正文\n> ❞ 看看这里\n> ※ 指令:请把本条消息里的引用块按出现顺序记为「引用1」「引用2」…,回复时逐条对应回应,每条回应前标注「引用N」。",
1093
+ JSON.stringify(replyWire)
1094
+ );
1095
+ const replySegments = testing.splitQuoteRuns(replyWire);
1096
+ check(
1097
+ "the instruction never reaches the capsule",
1098
+ replySegments?.length === 1 && replySegments[0].comment === "看看这里" && JSON.stringify(replySegments).includes("指令") === false,
1099
+ JSON.stringify(replySegments)
1100
+ );
1101
+ const replyBare = testing.splitQuoteRuns(testing.quoteBlock("正文", "", false, { reply: true }));
1102
+ check(
1103
+ "a reply instruction without a comment leaves the comment half empty",
1104
+ replyBare?.length === 1 && replyBare[0].comment === "" && replyBare[0].quote === "正文",
1105
+ JSON.stringify(replyBare)
1106
+ );
1107
+ const replyCode = testing.splitQuoteRuns(testing.quoteBlock("x=1", "改这里", true, { path: "src/a.ts", reply: true }));
1108
+ check(
1109
+ "path and instruction travel in one code block",
1110
+ replyCode?.length === 1 && replyCode[0].code === "src/a.ts" && replyCode[0].comment === "改这里" && JSON.stringify(replyCode).includes("指令") === false,
1111
+ JSON.stringify(replyCode)
1112
+ );
1113
+ check(
1114
+ "the side thread's fence carries the file too",
1115
+ testing.sideQuestion("x=1", true, "src/app.ts") === "``` src/app.ts\nx=1\n```",
1116
+ JSON.stringify(testing.sideQuestion("x=1", true, "src/app.ts"))
1117
+ );
1118
+ check(
1119
+ "the side thread drops a whitespace path",
1120
+ testing.sideQuestion("x=1", true, "my file.ts") === "```\nx=1\n```",
1121
+ JSON.stringify(testing.sideQuestion("x=1", true, "my file.ts"))
1122
+ );
1123
+
1124
+ // previewPathOf: walk the ancestors, skip values that cannot be a path.
1125
+ const outer = dom.document.createElement("div");
1126
+ outer.setAttribute("data-file-path", "src/app.ts");
1127
+ const mid = dom.document.createElement("div");
1128
+ mid.setAttribute("data-textpreview-body", "");
1129
+ mid.setAttribute("data-path", "bad path");
1130
+ outer.append(mid);
1131
+ check(
1132
+ "previewPathOf finds the ancestor's path and skips a whitespace one",
1133
+ testing.previewPathOf(mid) === "src/app.ts",
1134
+ JSON.stringify(testing.previewPathOf(mid))
1135
+ );
1136
+ check("previewPathOf misses cleanly on a bare host", testing.previewPathOf(dom.document.createElement("div")) === null);
1137
+ }
1138
+
1139
+ /* ------------------------------------------------------------------ *
1140
+ * 回复徽章 — the model's 「引用N」 labels link back to the quotes
1141
+ * ------------------------------------------------------------------ */
1142
+
1143
+ {
1144
+ const badgeHost = dom.document.createElement("div");
1145
+ badgeHost.setAttribute("data-conversation-content", "");
1146
+ badgeHost.setAttribute("data-conversation-session", "session-badge");
1147
+ const userBubble = dom.document.createElement("div");
1148
+ const quoteRun = dom.document.createElement("span");
1149
+ quoteRun.className = "plainRun";
1150
+ const replyValue = "回答一「引用1」;回答二「引用2」;越界「引用9」;结尾";
1151
+ quoteRun.textContent = "问题" + testing.quoteBlock("第一处引用", "注一") + "\n\n中间的话\n\n" + testing.quoteBlock("第二处引用", "注二") + "\n\n";
1152
+ userBubble.append(quoteRun);
1153
+ badgeHost.append(userBubble);
1154
+ const replyParagraph = dom.document.createElement("p");
1155
+ const replyText = dom.document.createTextNode(replyValue);
1156
+ replyParagraph.append(replyText);
1157
+ badgeHost.append(replyParagraph);
1158
+ dom.document.body.append(badgeHost);
1159
+ dom.document.deliver();
1160
+ await wait(120);
1161
+
1162
+ const badges = () => replyParagraph.querySelectorAll(".dshq-ref");
1163
+ check("two quoted messages come back as two capsules", badgeHost.querySelectorAll(".dshq-cap").length === 2, badgeHost.querySelectorAll(".dshq-cap").length + " capsule(s)");
1164
+ check("a reply label becomes a badge", badges().length === 2, badges().length + " badge(s)");
1165
+ check(
1166
+ "the reply reads the same with badges in place",
1167
+ replyParagraph.textContent === replyValue,
1168
+ JSON.stringify(replyParagraph.textContent)
1169
+ );
1170
+ check(
1171
+ "only labels the quotes can answer are badged",
1172
+ JSON.stringify(Array.from(badges()).map((badge) => badge.textContent)) === JSON.stringify(["「引用1」", "「引用2」"]),
1173
+ JSON.stringify(Array.from(badges()).map((badge) => badge.textContent))
1174
+ );
1175
+
1176
+ // An assistant reply in a conversation with no quoted message stays plain.
1177
+ const bareHost = dom.document.createElement("div");
1178
+ bareHost.setAttribute("data-conversation-content", "");
1179
+ bareHost.setAttribute("data-conversation-session", "session-bare");
1180
+ const bareParagraph = dom.document.createElement("p");
1181
+ bareParagraph.textContent = "回应「引用1」但没有先前的引用";
1182
+ bareHost.append(bareParagraph);
1183
+ dom.document.body.append(bareHost);
1184
+ dom.document.deliver();
1185
+ await wait(120);
1186
+ check("a reply without a preceding quote is not badged", bareParagraph.querySelectorAll(".dshq-ref").length === 0, bareParagraph.querySelectorAll(".dshq-ref").length + " badge(s)");
1187
+
1188
+ // The badge reuses the hover card, quote and comment in full.
1189
+ const firstBadge = badges()[0];
1190
+ firstBadge.rect = { left: 80, top: 300, width: 64, height: 20 };
1191
+ firstBadge.dispatch("mouseenter", {});
1192
+ check(
1193
+ "hovering a badge opens the quote's card",
1194
+ pop()?.querySelector(".dshq-pop-quote")?.textContent === "第一处引用",
1195
+ JSON.stringify(pop()?.querySelector(".dshq-pop-quote")?.textContent)
1196
+ );
1197
+ check(
1198
+ "the badge's card shows the comment too",
1199
+ pop()?.querySelector(".dshq-pop-comment")?.textContent === "注一",
1200
+ JSON.stringify(pop()?.querySelector(".dshq-pop-comment")?.textContent)
1201
+ );
1202
+ firstBadge.dispatch("mouseleave", {});
1203
+ await wait(200);
1204
+ check("leaving a badge closes the card", pop()?.dataset.visible === "false", JSON.stringify(pop()?.dataset.visible));
1205
+
1206
+ // A quote the plugin never watched has no coordinates: clicking the badge
1207
+ // must not pretend to jump — no throw, no toast. (An earlier section left a
1208
+ // jump-failure toast mid-display; hide it first so only this click counts.)
1209
+ const leftoverToast = dom.document.getElementById("dsh-quote-toast");
1210
+ if (leftoverToast !== null) leftoverToast.dataset.visible = "false";
1211
+ check("the badge's quote was never traced", testing.sourceFor("第一处引用") === null);
1212
+ firstBadge.dispatch("click", { stopPropagation() {} });
1213
+ check(
1214
+ "a badge without coordinates jumps nowhere quietly",
1215
+ dom.document.getElementById("dsh-quote-toast")?.dataset.visible !== "true",
1216
+ JSON.stringify(dom.document.getElementById("dsh-quote-toast")?.dataset.visible)
1217
+ );
1218
+
1219
+ dom.document.deliver();
1220
+ await wait(120);
1221
+ check("an unrelated flush does not duplicate badges", badges().length === 2, badges().length + " badge(s)");
1222
+
1223
+ // React rewrites the text node: the stale split goes and the fresh value
1224
+ // is re-read, so the badges always match what the reply says now.
1225
+ replyText.textContent = "改写后只提「引用2」一次";
1226
+ dom.document.deliver();
1227
+ await wait(120);
1228
+ check(
1229
+ "a rewritten reply re-badges from the fresh text",
1230
+ badges().length === 1 && badges()[0].textContent === "「引用2」" && replyParagraph.textContent === "改写后只提「引用2」一次",
1231
+ JSON.stringify([badges().length, replyParagraph.textContent])
1232
+ );
1233
+
1234
+ // Uninstall puts the original text back and takes every badge with it.
1235
+ capsuleEffect.dispose();
1236
+ check(
1237
+ "uninstall restores the reply text and drops the badges",
1238
+ replyParagraph.querySelectorAll(".dshq-ref").length === 0 && replyParagraph.textContent === "改写后只提「引用2」一次",
1239
+ JSON.stringify(replyParagraph.textContent)
1240
+ );
1241
+ }
1242
+
1243
+ /* ------------------------------------------------------------------ *
1244
+ * 逐条回应的开关 — the comment box checkbox drives the wire
1245
+ * ------------------------------------------------------------------ */
1246
+
1247
+ {
1248
+ const overlay = registered.find((entry) => entry.spec.name === "conversation.input.overlay");
1249
+ const toggleInsertions = [];
1250
+ shells.set("session-toggle", {
1251
+ insertReference(attachment) {
1252
+ toggleInsertions.push(attachment);
1253
+ return true;
1254
+ }
1255
+ });
1256
+ if (overlay !== undefined) overlay.Component({ sessionId: "session-toggle", inputActions: { captureInsertion: () => ({ draftRev: 1 }) } });
1257
+
1258
+ testing.startRevision("一处引用", "", "session-toggle", null, false);
1259
+ const bar = dom.document.getElementById("dsh-quote-bar");
1260
+ const replyCheck = bar?.querySelector(".dshq-reply-check");
1261
+ check("the comment box carries the per-quote toggle", replyCheck !== null && replyCheck !== undefined);
1262
+ check("the toggle starts off", replyCheck?.checked === false, String(replyCheck?.checked));
1263
+ const quoteBox = bar.querySelector(".dshq-quote-input");
1264
+ const commentBox = bar.querySelector(".dshq-comment-input");
1265
+ const confirmBtn = bar.querySelector('[data-action="confirm"]');
1266
+ quoteBox.value = "一处引用";
1267
+ quoteBox.dispatch("input", {});
1268
+ commentBox.value = "请照着改";
1269
+ commentBox.dispatch("input", {});
1270
+ bar.dispatch("click", { target: confirmBtn });
1271
+ check(
1272
+ "toggle off: the wire carries no instruction",
1273
+ toggleInsertions.length === 1 && String(toggleInsertions[0]?.ref ?? "").includes("指令") === false,
1274
+ JSON.stringify(String(toggleInsertions[0]?.ref ?? "").trim())
1275
+ );
1276
+
1277
+ testing.startRevision("一处引用", "", "session-toggle", null, false);
1278
+ const bar2 = dom.document.getElementById("dsh-quote-bar");
1279
+ const replyCheck2 = bar2.querySelector(".dshq-reply-check");
1280
+ replyCheck2.checked = true;
1281
+ replyCheck2.dispatch("change", {});
1282
+ const quoteBox2 = bar2.querySelector(".dshq-quote-input");
1283
+ const commentBox2 = bar2.querySelector(".dshq-comment-input");
1284
+ const confirmBtn2 = bar2.querySelector('[data-action="confirm"]');
1285
+ quoteBox2.value = "一处引用";
1286
+ quoteBox2.dispatch("input", {});
1287
+ commentBox2.value = "请照着改";
1288
+ commentBox2.dispatch("input", {});
1289
+ bar2.dispatch("click", { target: confirmBtn2 });
1290
+ check(
1291
+ "toggle on: the wire carries the per-quote instruction",
1292
+ toggleInsertions.length === 2 && String(toggleInsertions[1]?.ref ?? "").includes("※ 指令:"),
1293
+ JSON.stringify(String(toggleInsertions[1]?.ref ?? "").trim().slice(-40))
1294
+ );
1295
+ }
1296
+
1050
1297
  console.log(
1051
1298
  failures.length === 0
1052
1299
  ? "\ndsh-client-ui-quote capsule: " + passed + " checks passed"
package/test/dom.mjs CHANGED
@@ -35,6 +35,12 @@ class ShimNode {
35
35
  return this.childNodes.length === 0 ? null : this.childNodes[this.childNodes.length - 1];
36
36
  }
37
37
 
38
+ get nextSibling() {
39
+ if (this.parentNode === null) return null;
40
+ const siblings = this.parentNode.childNodes;
41
+ return siblings[siblings.indexOf(this) + 1] ?? null;
42
+ }
43
+
38
44
  append(child) {
39
45
  this.insertBefore(child, null);
40
46
  }
package/test/panel.mjs CHANGED
@@ -240,6 +240,28 @@ check(
240
240
  testing.quoteRowsFromEntries(entries).every((row) => row.code === false),
241
241
  JSON.stringify(testing.quoteRowsFromEntries(entries).map((row) => row.code))
242
242
  );
243
+ const pathEntries = [
244
+ {
245
+ type: "event",
246
+ event: {
247
+ type: "user/message",
248
+ seq: 5,
249
+ time: "",
250
+ data: { id: "e6", content: [{ type: "text", text: "> ❝ ``` src/app.ts\n> const x = 1;\n> ```\n> ❞ 看一下" }] }
251
+ }
252
+ }
253
+ ];
254
+ const pathRows = testing.quoteRowsFromEntries(pathEntries);
255
+ check(
256
+ "a preview quote's file rides in the row",
257
+ pathRows.length === 1 && pathRows[0].code === true && pathRows[0].fence === "src/app.ts" && pathRows[0].comment === "看一下",
258
+ JSON.stringify(pathRows[0])
259
+ );
260
+ check(
261
+ "the export puts the file on its fence line",
262
+ testing.quoteListMarkdown(pathRows, "引用清单").includes("``` src/app.ts\nconst x = 1;\n```"),
263
+ JSON.stringify(testing.quoteListMarkdown(pathRows, "引用清单"))
264
+ );
243
265
 
244
266
  // ---- reading a session that is not on screen -------------------------------
245
267
  sessionFixture = {