dsh-client-ui-quote 0.12.1-lab.16 → 0.12.1
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 +5 -2
- package/README.md +14 -11
- package/docs/distribution.md +1 -1
- package/lib/client.js +151 -22
- package/package.json +1 -1
- package/test/capsule.mjs +66 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.12.1 — 2026-10-20
|
|
4
4
|
|
|
5
5
|
### Added
|
|
6
6
|
|
|
@@ -9,16 +9,19 @@
|
|
|
9
9
|
- 芯片插入事件兜底:`shell.insertReference` 优先,`slash/input-insert-reference` 作为兼容通道;两者都不可用才退回纯文本引用块。
|
|
10
10
|
- 引用清单页的宿主自检:显示芯片精确删除能力与侧聊后端(better-sidebar / fork 兜底 / 不可用)。
|
|
11
11
|
- 评论框打开期间用 `CSS.highlights` 保持原选区高亮,关闭时清理。
|
|
12
|
+
- 持久引用高亮改用 `CSS.highlights`:浏览器直接绘制原句,滚动/流式输出/虚拟列表重排都跟手;不支持该 API 的宿主退回浮层矩形,序号徽章两条路径都有。
|
|
13
|
+
- `probe().lastFailure`:记录最后一个失败的环节(`source` / `insert` / `remove` / `sidechat`)与一句原因,只记阶段不记正文。
|
|
12
14
|
- 24 秒界面轮播 GIF、市场提交 YAML(`submission/`)与 `docs/distribution.md`。
|
|
13
15
|
|
|
14
16
|
### Changed
|
|
15
17
|
|
|
16
18
|
- `package.json` 描述不再以 Kimi 开头,改为「把任意选区变成可审阅的引用对象」。
|
|
17
|
-
- 离线断言 **
|
|
19
|
+
- 离线断言 **549 组**(check 12 + capsule 384 + panel 153);真浏览器 **51/51**(1600×2000 视口);隔离 DSH web 实例中 `probe().nativeSideChat === true`。
|
|
18
20
|
|
|
19
21
|
### Fixed
|
|
20
22
|
|
|
21
23
|
- 修正「宿主没有删除 reference-chip 的 API」这一旧结论:conversation shell 实际暴露 `consumeToken`。
|
|
24
|
+
- 跨消息的选区不再谎报来源:起点与终点不在同一行时,不带轮次/角色、也不写可跳回坐标,原文照常发出。
|
|
22
25
|
|
|
23
26
|
## 0.12.0 — 2026-10-19
|
|
24
27
|
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
| **添加到对话** | 直接把引用加进输入框,不弹评论框。 |
|
|
43
43
|
| **侧边对话** | 把引用写进一条**独立的侧边对话**的输入框:优先用 dsh-better-sidebar 的侧边线程;没装/禁用时自动降级为**fork 一个继承主会话历史的子会话**,用宿主内置的 `subagentchat` 页在右侧栏打开,再把引用写进它的草稿。两种后端都**什么都不发送**,线程独立运行、不污染主会话,页签自动在右侧栏打开。同一条主会话只开一条线程,之后每次点都在同一条线程的草稿后面接着追加。鲸鱼蓝(`#4d6bfe`)的图标是它在浮层里的标识。**一条引用一条线程**:浮层里还有一项「另开侧边对话」,给这条引用单独开一条线程;之后对同一条引用再选侧边对话,回到的就是它自己的那条。引用清单页底部会列出这个会话的**全部侧边线程**,每条都能重新打开(原生兜底时同样用内置页打开)。 |
|
|
44
44
|
| **键盘直达** | `Alt+Q` 引用 / `Alt+C` 评论 / `Alt+B` 侧边对话——同一个浮层的三个动作,不弹浮层直接办。走 DSH 的快捷键服务注册,所以出现在设置里、可改键、与宿主键位冲突会在注册时报错;没有选区时返回 `pass`,按键不会被吞掉。 |
|
|
45
|
-
| **原文侧高亮** | 引用落进草稿或发出后,被引用的原句回到**来源消息**里留下淡黄高亮与**序号角标**(序号就是坐标里的同句序号,和 `「引用N」`
|
|
45
|
+
| **原文侧高亮** | 引用落进草稿或发出后,被引用的原句回到**来源消息**里留下淡黄高亮与**序号角标**(序号就是坐标里的同句序号,和 `「引用N」` 对得上)。支持 `CSS.highlights` 的宿主由浏览器**直接绘制高亮**,滚动、流式输出、虚拟列表重排都跟手,不靠逐帧重画去追;不支持时才退回浮层矩形(只画序号徽章)。行不在视口里、文字已经变了、宿主不给 `Range` 时就静默跳过,**不改宿主的 DOM**(高亮不包 `mark`,也不搬节点)。 |
|
|
46
46
|
| **聚合引用条** | 输入框上方一条 **`引用 ×N`**:每颗芯片一个摘要胶囊,点一下跳回原文(跳不到就把光标放到芯片上,一次退格删掉),悬停就是完整卡片;每条末尾有 **`✎` 编辑**与 **`×` 移除**:编辑把原引用与评论回填进注释框,确认后旧芯片让位、新芯片原地插入;移除优先走宿主自己的 `consumeToken`(按芯片的 detect 区间与 `draftRev` 做 CAS 精确删除,不会碰错别的文本),宿主没有这个读取面或同名芯片无法区分时才退回「聚焦芯片 + 退格」并提示。**整条索引存在本机**:宿主会跨刷新恢复草稿里的芯片,插件的索引也一起恢复,所以刷新后条、卡片与「打开文件」都还在。主会话与侧边线程各自的输入框各有一条;芯片删光时条自己消失。整条带 `role=toolbar`,每颗胶囊的可访问名是完整引用+评论,不是截断后的标签。 |
|
|
47
47
|
| **来源头** | 发送块尾部的坐标行前面多一段人可读的出处:**`※ 引自第 12 轮 · 助手 │q1:…`**,模型不解码也知道来自哪一轮、谁说的;角色属性宿主没给时就只写轮次。胶囊悬停卡片里同样显示 `引自第 12 轮 · 助手`,按界面语言本地化。 |
|
|
48
48
|
| **Diff 划选** | 在 diff / 每轮改动面板里划选也走同一个浮层:引用按**代码块**发,围栏信息位带探测到的文件路径与行号(`src/app.ts#L12`);路径、行号是宿主 DOM 上的探测,拿不到就只带代码,不影响引用本身。有路径时卡片/清单可直接打开文件并定位到起始行。 |
|
|
@@ -82,16 +82,18 @@
|
|
|
82
82
|
__dshQuote.config() // 当前开关
|
|
83
83
|
__dshQuote.setConfig({ minChars: 3 }) // 改开关(立即生效,落本机)
|
|
84
84
|
__dshQuote.state() // 浮层此刻的状态:选区、模式、路径、芯片数
|
|
85
|
-
__dshQuote.probe() // 宿主服务可见性(inputTriggers / conversation / sidebarRight / betterSidebar
|
|
85
|
+
__dshQuote.probe() // 宿主服务可见性(inputTriggers / conversation / sidebarRight / betterSidebar…)、索引规模,以及 lastFailure:最后一个失败的环节
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
`probe().lastFailure` 是给排障用的:`stage` 只会是 `source` / `insert` / `remove` / `sidechat` 之一,`detail` 是一句原因(例如「insertReference 与 scoped 事件都拒绝了芯片」),`count` 是这个页面累计次数。它只记阶段与原因,不记引用正文,可以直接贴进 issue。
|
|
89
|
+
|
|
88
90
|
## 安装
|
|
89
91
|
|
|
90
92
|
要求:DSH ≥ 0.2.0-rc.2 的 **web / 桌面** 客户端(`package.json` 里 `engines.dsh` 声明的就是实测版本;插件运行在渲染进程里,headless profile 没有 UI)。
|
|
91
93
|
|
|
92
94
|
### 方式一:npm 包(推荐)
|
|
93
95
|
|
|
94
|
-
已发布到 npm:`dsh-client-ui-quote`(0.10.0 起上架;稳定版当前 **0.12.
|
|
96
|
+
已发布到 npm:`dsh-client-ui-quote`(0.10.0 起上架;稳定版当前 **0.12.1**)。`lab` 分支始终领先稳定包一个补丁:日常改动先在 lab 上发 `-lab.N` 预发布,验收后再提升;两边的差异看 `CHANGELOG.md` 的 `Unreleased` 段。
|
|
95
97
|
|
|
96
98
|
```bash
|
|
97
99
|
dsh plugin --profile desktop add dsh-client-ui-quote
|
|
@@ -105,7 +107,7 @@ npmmirror 与 npmjs 一般 10 分钟内同步,DSH 配置了镜像源即可国
|
|
|
105
107
|
|
|
106
108
|
1. 把包装进 profile:`cd <DSH_HOME>/profiles/<profile> && pnpm add dsh-client-ui-quote`;
|
|
107
109
|
2. 编辑 `<DSH_HOME>/profiles/<profile>/package.json`:
|
|
108
|
-
- `dependencies` 里应有 `"dsh-client-ui-quote": "^0.12.
|
|
110
|
+
- `dependencies` 里应有 `"dsh-client-ui-quote": "^0.12.1"`(安装器会写);
|
|
109
111
|
- `dsh.profile.bundles` 追加 `"dsh-client-ui-quote"`;
|
|
110
112
|
3. 重启 DSH(profile 打开 `patchReload` 时也会自动重载)。
|
|
111
113
|
|
|
@@ -134,7 +136,7 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
|
|
|
134
136
|
|
|
135
137
|
本机切换这三档(这台开发机专用的辅助脚本,不在仓库里):`node .dsh-quote-work/profile-mode.mjs link|lab|stable|status`。换完在窗口里 `Ctrl+Shift+R` 生效;只有宿主半有改动时才需要重启桌面端。
|
|
136
138
|
|
|
137
|
-
两条线的断言入口相同:`npm test`(
|
|
139
|
+
两条线的断言入口相同:`npm test`(549 项:check 12 + capsule 384 + panel 153);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
|
|
138
140
|
|
|
139
141
|
**推送通道**:`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 到远端。
|
|
140
142
|
|
|
@@ -320,7 +322,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
320
322
|
1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
|
|
321
323
|
2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标以「人可读来源头 + base64 尾巴」随消息走——前者让模型知道出处,后者让插件跨刷新回读。base64 确实占一点 prompt,这是用「不依赖本地表」换来的取舍。
|
|
322
324
|
3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在同类里仍是少数。
|
|
323
|
-
4. **工程化**:
|
|
325
|
+
4. **工程化**:549 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
|
|
324
326
|
|
|
325
327
|
反过来也要说清楚:**本插件不做的**——不做翻译/解释/改写/飞书投递类选区动作;不做图片/截图引用(**明确取舍**:图片引用要的是另一套胶囊/卡片/附件语义,是第二个产品的量级,塞进这里只会把「文本引用带坐标」这条主线冲淡;`dsh-selection-toolbar` 与 Kimi 桌面端已经做了,真要做建议独立成插件);不做完整 Topic 系统;不做常显大块引用。它只把「引用」这一件事做穿——但「做穿」包括发送后的管理、来源闭环和审阅,不只是浮层。
|
|
326
328
|
|
|
@@ -356,7 +358,7 @@ locale/zh.json 插件页卡片文案(中文)
|
|
|
356
358
|
locale/en.json 插件页卡片文案(英文)
|
|
357
359
|
test/check.mjs 离线自检(清单 + 组件契约)
|
|
358
360
|
test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
|
|
359
|
-
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,
|
|
361
|
+
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,384 项断言)
|
|
360
362
|
test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 文件路径打开 + 侧边线程回流 + 审阅工作台(状态 / 标签 / 批量)+ JSON 导出 + 本地索引,152 项断言)
|
|
361
363
|
test/capsule.html 真浏览器里跑同一套胶囊断言(51 项,可选)
|
|
362
364
|
```
|
|
@@ -407,7 +409,7 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
|
|
|
407
409
|
修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出;
|
|
408
410
|
**浮层不跟着座位生死**(`conversation.input.overlay` 没挂载也能选中出栏、挂上两个会话座位既不叠加也不丢监听、
|
|
409
411
|
座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份);
|
|
410
|
-
(共
|
|
412
|
+
(共 384 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、无 better-sidebar 时 `sessions.fork` + 内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
|
|
411
413
|
`test/capsule.html` 是同一套断言的真浏览器版(51 项),用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。这一版修好了它**缺失的 `<body>` 标签**——脚本在 `<head>` 里执行时 `document.body` 为 null,这个页面以前在真浏览器里是直接坏的;并补上真 `Range` 的链接/行内代码断言(真浏览器的 `cloneContents()` 返回的是 DocumentFragment,不是元素——序列化器只认元素的话,链接在真浏览器里会退化成纯文本;离线桩现在也用真片段,不再放过这一类),另外补了 `CSS.highlights` 选区保持与关闭清理。
|
|
412
414
|
|
|
413
415
|
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(152 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
|
|
@@ -426,7 +428,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
426
428
|
`bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
|
|
427
429
|
`exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
|
|
428
430
|
|
|
429
|
-
合计 **
|
|
431
|
+
合计 **549 组**(check 12 + capsule 384 + panel 153),`npm test` 全绿。
|
|
430
432
|
|
|
431
433
|
## 已知限制
|
|
432
434
|
|
|
@@ -456,7 +458,8 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
456
458
|
(`[data-conversation-content]`、纯文本 run 的形态),需要跟着改 `isQuoteRun` / `capsuleFor`。
|
|
457
459
|
- 收起状态的胶囊是**单行**:很长或分段的引用与评论会被省略号截断 —— 鼠标悬停会浮出完整内容,也可以点一下直接在原地展开。
|
|
458
460
|
- 更新**之前**发出的老消息不会被变成胶囊:它们的文本里没有 `❝` 标记,插件无从辨认(重新引用一次即可)。
|
|
459
|
-
- **原文高亮用完整引用文本定位**:页面刚刷新、那颗胶囊还没渲染时,插件手里只有坐标表里的前 120 字身份键,长引用会先亮前 120 字;胶囊进入视口后补上全文并自动重画。行不在视口、被虚拟列表收起、或文字已经变了就不画,绝不改宿主 DOM
|
|
461
|
+
- **原文高亮用完整引用文本定位**:页面刚刷新、那颗胶囊还没渲染时,插件手里只有坐标表里的前 120 字身份键,长引用会先亮前 120 字;胶囊进入视口后补上全文并自动重画。行不在视口、被虚拟列表收起、或文字已经变了就不画,绝不改宿主 DOM。支持 `CSS.highlights` 时高亮由浏览器绘制(跟手、不逐帧追滚动),不支持时退回浮层矩形;两种模式都不包 `mark`。
|
|
462
|
+
- **跨消息的选区不带来源头**:选区起点与终点落在不同消息行时,引用照常发出(原文、评论、代码形态都在),但不写 `引自第 N 轮 · 助手`,也不带可跳回的坐标——一句话横跨两轮时,任何单一轮次都是错的。想要带来源,就分两次引用。
|
|
460
463
|
- **评论框期间的原选区高亮依赖 `CSS.highlights`**:Chromium/Electron 支持;不支持时静默跳过,评论框里仍有引用正文,不影响引用本身。
|
|
461
464
|
- **聚合引用条的移除与编辑优先用宿主的 `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`),所以**刷新后**条、悬停卡与「打开文件」都还在——宿主本来就会恢复草稿里的芯片,索引跟着恢复,它们才重新算是本插件的引用。
|
|
462
465
|
|
|
@@ -472,7 +475,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
472
475
|
- **Markdown 预览的行号**:要精修就得读源文件,而客户端必须声明 `remote` 才能拿到它;声明错会让 client bundle 在不匹配的宿主上**静默失效**(`dsh-quote-annotate` 上游就是这么死的)。所以先不声明——文件仍然打开,只是落在顶部而不是行上。
|
|
473
476
|
- **表格/列表/LaTeX 的结构保真**:链接与行内代码已做;表格要还原成 Markdown 表格,列表要还原成缩进,成本明显更高,留到有人真的需要时再做。
|
|
474
477
|
- **图片/截图引用**:明确不做(理由见「本插件不做的」)。真要做请独立成插件:图片引用要的是另一套胶囊/卡片/附件语义。
|
|
475
|
-
- **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有
|
|
478
|
+
- **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 549 项离线断言;真浏览器(1600×2000 视口)同一套断言本轮复核 **51/51**。另外在**隔离的 DSH web 实例**(临时 `DSH_HOME`,端口 8790)里确认:工作区 link 与 **npm lab 发布包 `0.12.1-lab.15`** 都能进入 boot 列表,`window.__dshQuote` 可用、`conversation` / `inputTriggers` / `sidebarRight` / `sidebarRightTabs` 可见,`betterSidebar` 缺失而 `probe().nativeSideChat === true`——原生 fork 降级链的前置条件成立,发布产物的 manifest/打包路径也随真宿主一起验证过。欢迎页的交互点击被安全闸门拦下,因此下面五条仍需在真实窗口逐项点:聚合条编辑、回合尾的两个按钮、打开文件与行范围标记、另开侧边对话、JSON 导出。
|
|
476
479
|
- **市场收录与首屏**:README 已有截图、徽章、「30 秒上手」与 24 秒静态轮播 GIF;还差把插件提交到 dsh.market / dsh-plugin.org / dsh.pub / awesome-deepseek-harness —— 这些需要外部账号,是人工动作。
|
|
477
480
|
|
|
478
481
|
## 变更记录
|
package/docs/distribution.md
CHANGED
package/lib/client.js
CHANGED
|
@@ -690,8 +690,10 @@ background:transparent;color:inherit;font:inherit;line-height:16px;opacity:.5;cu
|
|
|
690
690
|
.dshq-turn-btn{padding:1px 7px;border:1px solid var(--dshq-border,rgba(127,127,127,.34));border-radius:99px;
|
|
691
691
|
background:transparent;color:inherit;font:inherit;font-size:10.5px;line-height:1.6;opacity:.7;cursor:pointer}
|
|
692
692
|
.dshq-turn-btn:hover,.dshq-turn-btn:focus-visible{opacity:1;background:color-mix(in srgb,currentColor 10%,transparent)}
|
|
693
|
-
/* 原文侧高亮 —
|
|
694
|
-
|
|
693
|
+
/* 原文侧高亮 — the sentence is painted by the browser's Custom Highlight API
|
|
694
|
+
when it is there, so the mark follows scrolling and reflow on its own; the
|
|
695
|
+
overlay below is the fallback, plus the ordinal badge in both modes. */
|
|
696
|
+
::highlight(dshq-quote){background-color:rgba(242,193,78,.32);color:inherit}
|
|
695
697
|
.dshq-anchor-layer{position:fixed;inset:0;z-index:5;display:none;pointer-events:none}
|
|
696
698
|
.dshq-anchor-rect{position:absolute;border-radius:2px;background:rgba(242,193,78,.26);
|
|
697
699
|
box-shadow:0 0 0 1px rgba(242,193,78,.22) inset}
|
|
@@ -1285,10 +1287,18 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
1285
1287
|
// A chat selection inside one rendered code block: "" when the block
|
|
1286
1288
|
// announces no language, the tag otherwise. null for every other host.
|
|
1287
1289
|
lang: codeBlock === null ? null : codeBlock.lang,
|
|
1288
|
-
// Only a transcript selection has a row to jump back to
|
|
1290
|
+
// Only a transcript selection has a row to jump back to, and only
|
|
1291
|
+
// when both ends of the selection sit in the same row: a selection
|
|
1292
|
+
// that spans messages has no single turn, and claiming the first
|
|
1293
|
+
// one would put "引自第 3 轮" on a sentence half of which came from
|
|
1294
|
+
// turn 4. The quote still travels; only the provenance is dropped.
|
|
1289
1295
|
source: (() => {
|
|
1290
|
-
|
|
1291
|
-
|
|
1296
|
+
if (chat === null) return null;
|
|
1297
|
+
const start = chatRowOf(range.startContainer);
|
|
1298
|
+
if (start === null) return null;
|
|
1299
|
+
const end = chatRowOf(range.endContainer);
|
|
1300
|
+
if (end === null || end.attr !== start.attr || end.key !== start.key) return null;
|
|
1301
|
+
return { ...start, origin: codeBlock === null ? hostKind : "code" };
|
|
1292
1302
|
})()
|
|
1293
1303
|
};
|
|
1294
1304
|
}
|
|
@@ -1775,6 +1785,25 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
1775
1785
|
let sourceDisposer = null;
|
|
1776
1786
|
/** Someone (us) holds a live registration with the host service. */
|
|
1777
1787
|
let sourceRegistered = false;
|
|
1788
|
+
/**
|
|
1789
|
+
* Where the pipeline last gave up, for support. "The quote did not land"
|
|
1790
|
+
* costs a screenshot and a guess; this says which step refused — the
|
|
1791
|
+
* source registration, the shell, the event path, the exact delete, or
|
|
1792
|
+
* the side thread's composer. It holds a stage and a short reason, never
|
|
1793
|
+
* the quoted text, so it is safe to paste into an issue.
|
|
1794
|
+
*/
|
|
1795
|
+
const lastFailure = { stage: "", detail: "", at: 0, count: 0 };
|
|
1796
|
+
|
|
1797
|
+
function noteFailure(stage, detail) {
|
|
1798
|
+
lastFailure.stage = String(stage);
|
|
1799
|
+
lastFailure.detail = detail === undefined || detail === null ? "" : String(detail).slice(0, 160);
|
|
1800
|
+
lastFailure.at = Date.now();
|
|
1801
|
+
lastFailure.count += 1;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function failureSnapshot() {
|
|
1805
|
+
return { stage: lastFailure.stage, detail: lastFailure.detail, at: lastFailure.at, count: lastFailure.count };
|
|
1806
|
+
}
|
|
1778
1807
|
/**
|
|
1779
1808
|
* How many `apply()` instances share that one registration. The host
|
|
1780
1809
|
* mounts this plugin more than once per page and tears each mount down
|
|
@@ -1814,14 +1843,20 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
1814
1843
|
/** Register the chip owner, once the input-trigger service is reachable. */
|
|
1815
1844
|
function ensureSource() {
|
|
1816
1845
|
if (sourceRegistered) return true;
|
|
1817
|
-
if (pluginCtx === null)
|
|
1846
|
+
if (pluginCtx === null) {
|
|
1847
|
+
noteFailure("source", "no host context yet");
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1818
1850
|
let service = null;
|
|
1819
1851
|
try {
|
|
1820
1852
|
service = pluginCtx.get("inputTriggers");
|
|
1821
1853
|
} catch (error) {
|
|
1822
1854
|
service = null;
|
|
1823
1855
|
}
|
|
1824
|
-
if (service === null || service === undefined || typeof service.registerSource !== "function")
|
|
1856
|
+
if (service === null || service === undefined || typeof service.registerSource !== "function") {
|
|
1857
|
+
noteFailure("source", "inputTriggers.registerSource is missing");
|
|
1858
|
+
return false;
|
|
1859
|
+
}
|
|
1825
1860
|
try {
|
|
1826
1861
|
const disposer = service.registerSource(quoteSource);
|
|
1827
1862
|
sourceDisposer = typeof disposer === "function" ? disposer : () => {};
|
|
@@ -1935,11 +1970,17 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
1935
1970
|
* Every step is optional — a missing service falls back to the Markdown form.
|
|
1936
1971
|
*/
|
|
1937
1972
|
function insertChip(entry, sessionId, text, comment, code, meta) {
|
|
1938
|
-
if (!ensureSource() || pluginCtx === null)
|
|
1973
|
+
if (!ensureSource() || pluginCtx === null) {
|
|
1974
|
+
noteFailure("insert", "the reference source is not registered");
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1939
1977
|
const shell = inputShellFor(sessionId);
|
|
1940
1978
|
const canShell = shell !== null && shell !== undefined && typeof shell.insertReference === "function";
|
|
1941
1979
|
const actx = sessionActx(sessionId);
|
|
1942
|
-
if (!canShell && actx === null)
|
|
1980
|
+
if (!canShell && actx === null) {
|
|
1981
|
+
noteFailure("insert", "no composer shell and no session context");
|
|
1982
|
+
return false;
|
|
1983
|
+
}
|
|
1943
1984
|
const model = chipRef(text, comment, code, meta);
|
|
1944
1985
|
const attachment = {
|
|
1945
1986
|
source: SOURCE_NAME,
|
|
@@ -1948,9 +1989,13 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
1948
1989
|
appearance: "session",
|
|
1949
1990
|
clipboardText: model
|
|
1950
1991
|
};
|
|
1992
|
+
let refusal = "";
|
|
1951
1993
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1952
1994
|
const actions = entry.actions();
|
|
1953
|
-
if (actions === null || typeof actions.captureInsertion !== "function")
|
|
1995
|
+
if (actions === null || typeof actions.captureInsertion !== "function") {
|
|
1996
|
+
noteFailure("insert", "the composer face has no captureInsertion");
|
|
1997
|
+
return false;
|
|
1998
|
+
}
|
|
1954
1999
|
const span = actions.captureInsertion();
|
|
1955
2000
|
if (canShell) {
|
|
1956
2001
|
try {
|
|
@@ -1964,6 +2009,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
1964
2009
|
}
|
|
1965
2010
|
} catch (error) {
|
|
1966
2011
|
// The event path below may still be available.
|
|
2012
|
+
refusal = error instanceof Error ? error.message : String(error);
|
|
1967
2013
|
}
|
|
1968
2014
|
}
|
|
1969
2015
|
if (dispatchInputEvent(actx, "slash/input-insert-reference", { reference: attachment, span })) {
|
|
@@ -1971,6 +2017,12 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
1971
2017
|
return true;
|
|
1972
2018
|
}
|
|
1973
2019
|
}
|
|
2020
|
+
noteFailure(
|
|
2021
|
+
"insert",
|
|
2022
|
+
canShell
|
|
2023
|
+
? "insertReference and the scoped event both refused the chip" + (refusal === "" ? "" : ": " + refusal)
|
|
2024
|
+
: "the scoped event refused the chip"
|
|
2025
|
+
);
|
|
1974
2026
|
return false;
|
|
1975
2027
|
}
|
|
1976
2028
|
|
|
@@ -3729,7 +3781,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3729
3781
|
}
|
|
3730
3782
|
scheduleDraftBars();
|
|
3731
3783
|
setTimeout(() => {
|
|
3732
|
-
if (chip.isConnected)
|
|
3784
|
+
if (chip.isConnected) {
|
|
3785
|
+
noteFailure("remove", "the synthetic Backspace did not remove the chip");
|
|
3786
|
+
toast(translateFor(sessionId, "draftRemoveHint"));
|
|
3787
|
+
}
|
|
3733
3788
|
}, 120);
|
|
3734
3789
|
}
|
|
3735
3790
|
|
|
@@ -3917,10 +3972,43 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3917
3972
|
return range;
|
|
3918
3973
|
}
|
|
3919
3974
|
|
|
3975
|
+
/**
|
|
3976
|
+
* Whether the browser can paint a text range itself. Chromium's Custom
|
|
3977
|
+
* Highlight API does, and a browser-painted range follows scrolling and
|
|
3978
|
+
* reflow without a repaint. The overlay path has to chase both, and a
|
|
3979
|
+
* fast scroll leaves its rectangles a frame behind — the bug the
|
|
3980
|
+
* annotation plugins are chasing right now.
|
|
3981
|
+
*/
|
|
3982
|
+
function quoteHighlightSupported() {
|
|
3983
|
+
return (
|
|
3984
|
+
typeof CSS !== "undefined" &&
|
|
3985
|
+
CSS !== null &&
|
|
3986
|
+
CSS.highlights !== undefined &&
|
|
3987
|
+
CSS.highlights !== null &&
|
|
3988
|
+
typeof CSS.highlights.set === "function" &&
|
|
3989
|
+
typeof Highlight === "function"
|
|
3990
|
+
);
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
/** The registry we painted into, kept so a clear still works if `CSS` moves. */
|
|
3994
|
+
let quoteRegistry = null;
|
|
3995
|
+
|
|
3996
|
+
/** Drop the browser-painted quote marks; a no-op without the API. */
|
|
3997
|
+
function clearQuoteHighlight() {
|
|
3998
|
+
const registry = quoteRegistry ?? (typeof CSS !== "undefined" && CSS !== null ? CSS.highlights : null);
|
|
3999
|
+
quoteRegistry = null;
|
|
4000
|
+
try {
|
|
4001
|
+
if (registry !== null && registry !== undefined && typeof registry.delete === "function") registry.delete(QUOTE_HIGHLIGHT);
|
|
4002
|
+
} catch (error) {
|
|
4003
|
+
// A registry that refuses a delete has nothing left to clear.
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
|
|
3920
4007
|
function paintAnchors() {
|
|
3921
4008
|
if (typeof document === "undefined" || typeof document.createRange !== "function") return;
|
|
3922
4009
|
const highlight = config().highlight !== false;
|
|
3923
4010
|
if ((sourceIndex.size === 0 || highlight === false) && openedRange === null) {
|
|
4011
|
+
clearQuoteHighlight();
|
|
3924
4012
|
if (anchorLayer !== null) {
|
|
3925
4013
|
anchorLayer.textContent = "";
|
|
3926
4014
|
anchorLayer.style.display = "none";
|
|
@@ -3929,6 +4017,11 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3929
4017
|
}
|
|
3930
4018
|
const layer = ensureAnchorLayer();
|
|
3931
4019
|
if (layer === null) return;
|
|
4020
|
+
// The sentence itself is painted by the browser when it can be; the
|
|
4021
|
+
// overlay is then only the ordinal badge (and the file line marks,
|
|
4022
|
+
// which are row-level rectangles, not text ranges).
|
|
4023
|
+
const native = highlight === true && quoteHighlightSupported();
|
|
4024
|
+
const ranges = [];
|
|
3932
4025
|
const viewportHeight = typeof window === "undefined" || !Number.isFinite(Number(window.innerHeight)) ? 800 : Number(window.innerHeight);
|
|
3933
4026
|
const index = rowIndexForAnchors();
|
|
3934
4027
|
const marks = [];
|
|
@@ -3952,22 +4045,25 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3952
4045
|
const rects = range.getClientRects();
|
|
3953
4046
|
if (rects === null || rects.length === 0) continue;
|
|
3954
4047
|
let first = null;
|
|
3955
|
-
let
|
|
3956
|
-
for (let at = 0; at < rects.length &&
|
|
4048
|
+
let lines = 0;
|
|
4049
|
+
for (let at = 0; at < rects.length && lines < 12; at += 1) {
|
|
3957
4050
|
const rect = rects[at] ?? (typeof rects.item === "function" ? rects.item(at) : null);
|
|
3958
4051
|
if (rect === null || rect === undefined || rect.width <= 0 || rect.height <= 0) continue;
|
|
3959
4052
|
if (rect.bottom < 0 || rect.top > viewportHeight) continue;
|
|
3960
4053
|
if (clip !== null && (rect.bottom <= clip.top || rect.top >= clip.bottom)) continue;
|
|
3961
4054
|
if (first === null) first = rect;
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
4055
|
+
if (native === false) {
|
|
4056
|
+
const box = el(document, "div", { class: "dshq-anchor-rect" });
|
|
4057
|
+
box.style.left = Math.round(rect.left) + "px";
|
|
4058
|
+
box.style.top = Math.round(rect.top) + "px";
|
|
4059
|
+
box.style.width = Math.round(rect.width) + "px";
|
|
4060
|
+
box.style.height = Math.round(rect.height) + "px";
|
|
4061
|
+
marks.push(box);
|
|
4062
|
+
}
|
|
4063
|
+
lines += 1;
|
|
3969
4064
|
}
|
|
3970
4065
|
if (first === null) continue;
|
|
4066
|
+
if (native) ranges.push(range);
|
|
3971
4067
|
const badge = el(document, "div", { class: "dshq-anchor-badge", text: String(Number(candidate.n) || 1) });
|
|
3972
4068
|
badge.style.left = Math.round(Math.max(EDGE, first.left - 18)) + "px";
|
|
3973
4069
|
badge.style.top = Math.round(Math.max(clip === null ? EDGE : Math.max(clip.top, 0) + 2, first.top + (first.height - 16) / 2)) + "px";
|
|
@@ -3976,6 +4072,18 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
3976
4072
|
}
|
|
3977
4073
|
if (painted >= ANCHOR_LIMIT) break;
|
|
3978
4074
|
}
|
|
4075
|
+
if (native && ranges.length > 0) {
|
|
4076
|
+
try {
|
|
4077
|
+
quoteRegistry = CSS.highlights;
|
|
4078
|
+
CSS.highlights.set(QUOTE_HIGHLIGHT, new Highlight(...ranges));
|
|
4079
|
+
} catch (error) {
|
|
4080
|
+
// A refused set is the same as an unsupported browser: the marks
|
|
4081
|
+
// simply do not appear, and the capsules still carry the quote.
|
|
4082
|
+
clearQuoteHighlight();
|
|
4083
|
+
}
|
|
4084
|
+
} else {
|
|
4085
|
+
clearQuoteHighlight();
|
|
4086
|
+
}
|
|
3979
4087
|
layer.textContent = "";
|
|
3980
4088
|
for (const mark of marks) layer.append(mark);
|
|
3981
4089
|
if (highlight === true) {
|
|
@@ -4021,10 +4129,14 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4021
4129
|
|
|
4022
4130
|
function scheduleAnchors() {
|
|
4023
4131
|
if (anchorTimer !== null) return;
|
|
4132
|
+
// With a browser-painted highlight only the small ordinal badges are
|
|
4133
|
+
// rebuilt, so the repaint can follow a scroll sooner; the overlay path
|
|
4134
|
+
// rebuilds every rectangle and keeps its old, cheaper cadence.
|
|
4135
|
+
const delay = quoteHighlightSupported() && config().highlight !== false ? 32 : 64;
|
|
4024
4136
|
anchorTimer = setTimeout(() => {
|
|
4025
4137
|
anchorTimer = null;
|
|
4026
4138
|
paintAnchors();
|
|
4027
|
-
},
|
|
4139
|
+
}, delay);
|
|
4028
4140
|
}
|
|
4029
4141
|
|
|
4030
4142
|
function uninstallAnchors() {
|
|
@@ -4032,6 +4144,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4032
4144
|
clearTimeout(anchorTimer);
|
|
4033
4145
|
anchorTimer = null;
|
|
4034
4146
|
}
|
|
4147
|
+
clearQuoteHighlight();
|
|
4035
4148
|
if (anchorLayer !== null && anchorLayer.isConnected) anchorLayer.remove();
|
|
4036
4149
|
anchorLayer = null;
|
|
4037
4150
|
}
|
|
@@ -4761,6 +4874,8 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4761
4874
|
* quote box still carries the wording either way.
|
|
4762
4875
|
*/
|
|
4763
4876
|
const SOURCE_HIGHLIGHT = "dshq-selection";
|
|
4877
|
+
/** The persistent marks left by quotes, painted by the browser when it can. */
|
|
4878
|
+
const QUOTE_HIGHLIGHT = "dshq-quote";
|
|
4764
4879
|
let sourceHighlight = false;
|
|
4765
4880
|
function clearSourceHighlight() {
|
|
4766
4881
|
if (sourceHighlight !== true) return;
|
|
@@ -4962,6 +5077,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4962
5077
|
if (entry === null) {
|
|
4963
5078
|
// Returning silently here is what made "clicked and nothing happened"
|
|
4964
5079
|
// reports: the user gets told instead.
|
|
5080
|
+
noteFailure("insert", "no composer face for this session");
|
|
4965
5081
|
toast(translate("failed"));
|
|
4966
5082
|
return;
|
|
4967
5083
|
}
|
|
@@ -5024,7 +5140,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
5024
5140
|
return;
|
|
5025
5141
|
}
|
|
5026
5142
|
if (insertPayload(entry, buildPayload(quoteText, trimmed, code, meta))) focusComposer();
|
|
5027
|
-
else
|
|
5143
|
+
else {
|
|
5144
|
+
noteFailure("insert", "the plain-text fallback failed too");
|
|
5145
|
+
toast(translate("failed"));
|
|
5146
|
+
}
|
|
5028
5147
|
}
|
|
5029
5148
|
|
|
5030
5149
|
/* ------------------------------------------------------------------ *
|
|
@@ -5131,6 +5250,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
5131
5250
|
if (threadId === null) {
|
|
5132
5251
|
const sessions = serviceOf("sessions");
|
|
5133
5252
|
if (sessions === null || sessions === undefined || typeof sessions.fork !== "function") {
|
|
5253
|
+
noteFailure("sidechat", "sessions.fork is unavailable");
|
|
5134
5254
|
toast(translateFor(sessionId, "sideChatFailed"));
|
|
5135
5255
|
return;
|
|
5136
5256
|
}
|
|
@@ -5138,10 +5258,12 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
5138
5258
|
threadId = await sessions.fork({ sessionId, increaseTitle: true });
|
|
5139
5259
|
} catch (error) {
|
|
5140
5260
|
console.warn("[ui-quote] native sidechat fork failed:", error);
|
|
5261
|
+
noteFailure("sidechat", "sessions.fork failed: " + (error instanceof Error ? error.message : String(error)));
|
|
5141
5262
|
toast(translateFor(sessionId, "sideChatFailed"));
|
|
5142
5263
|
return;
|
|
5143
5264
|
}
|
|
5144
5265
|
if (typeof threadId !== "string" || threadId === "") {
|
|
5266
|
+
noteFailure("sidechat", "sessions.fork returned no id");
|
|
5145
5267
|
toast(translateFor(sessionId, "sideChatFailed"));
|
|
5146
5268
|
return;
|
|
5147
5269
|
}
|
|
@@ -5154,6 +5276,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
5154
5276
|
}
|
|
5155
5277
|
}
|
|
5156
5278
|
if (!openNativeThreadTab(sessionId, threadId)) {
|
|
5279
|
+
noteFailure("sidechat", "the side thread tab did not open");
|
|
5157
5280
|
toast(translateFor(sessionId, "sideChatFailed"));
|
|
5158
5281
|
return;
|
|
5159
5282
|
}
|
|
@@ -5438,10 +5561,12 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
5438
5561
|
const next = kept === "" ? text : kept + "\n\n" + text;
|
|
5439
5562
|
if (writeDraft(shell, next)) return true;
|
|
5440
5563
|
console.warn("[ui-quote] sidechat shell has no draft face");
|
|
5564
|
+
noteFailure("sidechat", "the side thread's shell has no draft face");
|
|
5441
5565
|
return false;
|
|
5442
5566
|
}
|
|
5443
5567
|
await sleep(200);
|
|
5444
5568
|
}
|
|
5569
|
+
noteFailure("sidechat", "the side thread's composer did not mount in 5s");
|
|
5445
5570
|
return false;
|
|
5446
5571
|
}
|
|
5447
5572
|
|
|
@@ -7129,6 +7254,9 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
7129
7254
|
bars: draftBars.size,
|
|
7130
7255
|
sources: sourceIndex.size,
|
|
7131
7256
|
nativeSideChat: nativeSideChatAvailable(),
|
|
7257
|
+
// The last step that refused, and how many times this page has
|
|
7258
|
+
// seen a refusal. Stages: source / insert / remove / sidechat.
|
|
7259
|
+
lastFailure: failureSnapshot(),
|
|
7132
7260
|
running: { source: sourceRegistered, runtime: runtimeHolders }
|
|
7133
7261
|
})
|
|
7134
7262
|
};
|
|
@@ -7363,6 +7491,7 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
7363
7491
|
sideChatAvailable,
|
|
7364
7492
|
nativeSideChatAvailable,
|
|
7365
7493
|
nativeSideChatRequest,
|
|
7494
|
+
failureSnapshot,
|
|
7366
7495
|
openNativeThreadTab,
|
|
7367
7496
|
openSideChat,
|
|
7368
7497
|
insertChip,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-client-ui-quote",
|
|
3
|
-
"version": "0.12.1
|
|
3
|
+
"version": "0.12.1",
|
|
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
|
@@ -1573,6 +1573,32 @@ try {
|
|
|
1573
1573
|
crossingSelection?.hostKind === "chat" && crossingSelection?.lang === null,
|
|
1574
1574
|
JSON.stringify(crossingSelection)
|
|
1575
1575
|
);
|
|
1576
|
+
// Two messages in one selection: the text still travels, but no single row
|
|
1577
|
+
// can claim it — a turn/role here would be a lie about half the sentence.
|
|
1578
|
+
const secondRow = dom.document.createElement("div");
|
|
1579
|
+
secondRow.setAttribute("data-chat-anchor-key", "row-chatcode-2");
|
|
1580
|
+
secondRow.setAttribute("data-chat-turn", "4");
|
|
1581
|
+
secondRow.setAttribute("data-chat-role", "assistant");
|
|
1582
|
+
secondRow.textContent = "第二条消息里的文字";
|
|
1583
|
+
codePane.append(secondRow);
|
|
1584
|
+
dom.document.selection = {
|
|
1585
|
+
isCollapsed: false,
|
|
1586
|
+
rangeCount: 1,
|
|
1587
|
+
toString: () => "const x = 1;第二条消息里的文字",
|
|
1588
|
+
removeAllRanges() {},
|
|
1589
|
+
getRangeAt: () => ({
|
|
1590
|
+
startContainer: chatCode.firstChild,
|
|
1591
|
+
endContainer: secondRow.firstChild,
|
|
1592
|
+
intersectsNode: () => false,
|
|
1593
|
+
getBoundingClientRect: () => ({ left: 100, top: 300, right: 220, bottom: 318, width: 120, height: 18 })
|
|
1594
|
+
})
|
|
1595
|
+
};
|
|
1596
|
+
const spanningSelection = testing.readSelection();
|
|
1597
|
+
check(
|
|
1598
|
+
"a selection spanning two messages carries no source",
|
|
1599
|
+
spanningSelection?.hostKind === "chat" && spanningSelection?.source === null && spanningSelection?.text.includes("第二条"),
|
|
1600
|
+
JSON.stringify({ source: spanningSelection?.source, text: spanningSelection?.text })
|
|
1601
|
+
);
|
|
1576
1602
|
dom.document.selection = null;
|
|
1577
1603
|
codePane.remove();
|
|
1578
1604
|
|
|
@@ -2403,8 +2429,30 @@ try {
|
|
|
2403
2429
|
testing.paintAnchors();
|
|
2404
2430
|
const layer = dom.document.querySelector(".dshq-anchor-layer");
|
|
2405
2431
|
check("the highlight layer is shown", layer?.style?.display === "block", JSON.stringify(layer?.style?.display));
|
|
2406
|
-
|
|
2432
|
+
const painted = highlightRegistry.get("dshq-quote");
|
|
2433
|
+
check(
|
|
2434
|
+
"the sentence is painted by the browser when the API is there",
|
|
2435
|
+
painted?.ranges?.length === 1 && layer?.querySelectorAll(".dshq-anchor-rect").length === 0,
|
|
2436
|
+
JSON.stringify({ ranges: painted?.ranges?.length, rects: layer?.querySelectorAll(".dshq-anchor-rect").length })
|
|
2437
|
+
);
|
|
2407
2438
|
check("the ordinal sits in a badge", layer?.querySelector(".dshq-anchor-badge")?.textContent === "1", JSON.stringify(layer?.querySelector(".dshq-anchor-badge")?.textContent));
|
|
2439
|
+
check("the stylesheet paints the browser's mark", source.includes("::highlight(dshq-quote)"), "no ::highlight(dshq-quote) rule");
|
|
2440
|
+
// A browser without the API still gets the old overlay, rectangles and all.
|
|
2441
|
+
const savedCss = globalThis.CSS;
|
|
2442
|
+
globalThis.CSS = undefined;
|
|
2443
|
+
testing.paintAnchors();
|
|
2444
|
+
check(
|
|
2445
|
+
"a browser without the API falls back to a rectangle",
|
|
2446
|
+
layer?.querySelectorAll(".dshq-anchor-rect").length === 1 && highlightRegistry.has("dshq-quote") === false,
|
|
2447
|
+
JSON.stringify({ rects: layer?.querySelectorAll(".dshq-anchor-rect").length, registry: highlightRegistry.has("dshq-quote") })
|
|
2448
|
+
);
|
|
2449
|
+
globalThis.CSS = savedCss;
|
|
2450
|
+
testing.paintAnchors();
|
|
2451
|
+
check(
|
|
2452
|
+
"the switch back repaints with the browser API",
|
|
2453
|
+
highlightRegistry.get("dshq-quote")?.ranges?.length === 1 && layer?.querySelectorAll(".dshq-anchor-rect").length === 0,
|
|
2454
|
+
JSON.stringify({ ranges: highlightRegistry.get("dshq-quote")?.ranges?.length })
|
|
2455
|
+
);
|
|
2408
2456
|
check("the source label follows the reader's language", testing.uiSourceLabel({ t: "5", r: "a" }, "session-anchor") === "引自第 5 轮 · 助手", testing.uiSourceLabel({ t: "5", r: "a" }, "session-anchor"));
|
|
2409
2457
|
|
|
2410
2458
|
const dupRow = dom.document.createElement("div");
|
|
@@ -2427,6 +2475,7 @@ try {
|
|
|
2427
2475
|
pane.remove();
|
|
2428
2476
|
testing.paintAnchors();
|
|
2429
2477
|
check("the layer empties when the source row unmounts", dom.document.querySelector(".dshq-anchor-layer")?.style?.display === "none");
|
|
2478
|
+
check("the browser-painted mark goes with the row", highlightRegistry.has("dshq-quote") === false, JSON.stringify([...highlightRegistry.keys()]));
|
|
2430
2479
|
}
|
|
2431
2480
|
|
|
2432
2481
|
/* ------------------------------------------------------------------ *
|
|
@@ -2463,6 +2512,22 @@ try {
|
|
|
2463
2512
|
);
|
|
2464
2513
|
check("the probe reads the live state", probe.state() !== undefined, JSON.stringify(probe.state()));
|
|
2465
2514
|
check("the probe reads the native side-chat fallback", probe.probe().nativeSideChat === true, JSON.stringify(probe.probe()));
|
|
2515
|
+
|
|
2516
|
+
// A refused insert says which step refused, not just "it did not work".
|
|
2517
|
+
const failuresBefore = testing.failureSnapshot().count;
|
|
2518
|
+
const refused = testing.insertChip({ actions: () => ({ captureInsertion: () => null }) }, "session-probe", "探针引用", "", false, {});
|
|
2519
|
+
const failed = testing.failureSnapshot();
|
|
2520
|
+
check(
|
|
2521
|
+
"a refused insert names the step that refused",
|
|
2522
|
+
refused === false && failed.stage === "insert" && failed.detail.includes("composer shell") && failed.count === failuresBefore + 1,
|
|
2523
|
+
JSON.stringify(failed)
|
|
2524
|
+
);
|
|
2525
|
+
check(
|
|
2526
|
+
"the probe exposes the last failure to support",
|
|
2527
|
+
probe.probe().lastFailure.stage === "insert" && probe.probe().lastFailure.count >= 1,
|
|
2528
|
+
JSON.stringify(probe.probe().lastFailure)
|
|
2529
|
+
);
|
|
2530
|
+
check("the failure record carries no quoted text", JSON.stringify(failed).includes("探针引用") === false, JSON.stringify(failed));
|
|
2466
2531
|
}
|
|
2467
2532
|
|
|
2468
2533
|
/* ------------------------------------------------------------------ *
|