dsh-client-ui-quote 0.12.3-lab.31 → 0.12.3-lab.32
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 +2 -1
- package/README.md +6 -6
- package/lib/client.js +70 -2
- package/package.json +1 -1
- package/test/panel.mjs +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
- 侧边对话默认内置隐藏线程,不再需要安装 dsh-better-sidebar:宿主半注册 `POST /ui-quote/api/sidechat.start`(同源围栏),从在线父会话复制种子,用宿主 `agents.create` 建 `origin: "subagent"` 子会话,并在宿主内置 `subagentchat` 页签打开;descriptor 与会话标题使用与 better-sidebar 共享的 `Side: ` 约定,装不装它都不冲突。宿主半不可用(旧宿主 / 父会话不在线 / 创建被拒)时自动退回 `sessions.fork` + 本插件原生 `quote-sidechat` 页签;`sideChatBackend: "better"` 才优先走 better-sidebar。
|
|
8
8
|
- 侧边线程记忆与清单:新增 `dsh-client-ui-quote:v1:sidechat-target` 记录隐藏线程的父会话;读取线程时用 subagent 地址 `{parentSessionId, childSessionId, mode:"continuable"}`;线程列表同时扫描会话列表里的 `Side:` 子会话,所以 better-sidebar(或旧版本)创建的线程也能在本插件里打开、带回和导出。
|
|
9
|
-
- 离线断言 **
|
|
9
|
+
- 离线断言 **656 组**(check 12 + capsule 443 + panel 159 + host 42);新增原生页签的地址解析、标题、资源 retain/release、页签注册与卸载断言,以及宿主半的种子复制 / 继承切点 / 回合收口 / 悬挂工具调用截断 / `agents.create` 参数 / 探针与围栏断言。
|
|
10
10
|
|
|
11
11
|
### Fixed
|
|
12
12
|
|
|
13
|
+
- 引用清单每行多一个「复制」:把**单条**引用复制成 Markdown(原文块引用或代码围栏、评论、`引自第 N 轮 · 助手` 出处),可以直接粘进 PR / issue / 别的会话。之前只有整页导出,单条引用拿不出去——这是竞品调研里唯一明确的能力缺口(Aider `/copy-context`、Zed/Cline 的「带出处」都是这个形态)。
|
|
13
14
|
- 侧聊页打开后是一片「探索未至之境」欢迎页、输入框里也没有引用:宿主把「一个 turn 都没有」的会话算成 blank,blank 会话渲染欢迎页而不是聊天页(没有输入框,`prefillDraft` 无处可落)。种子现在把隐性上下文包进一个**已收口的小回合**(`turn/start` → `step/start` → 上下文消息 → `step/end` → `turn/end{done}`),会话不再是 blank,页签进正常聊天态。
|
|
14
15
|
- 侧边对话只剩**一项**:浮层里「侧边对话」与「另开侧边对话」两个按钮长得一样、语义要读第二行才分得清,现在只留「侧边对话」,一条主会话一条线程(早期按引用分线程的记录仍会识别并复用)。
|
|
15
16
|
- 侧聊打开时不再把主会话的历史铺在界面上:种子从「复制父会话整条日志」改成**一条隐性上下文**——先是 session/end-seed{inherited:true} 切点,再是一条 source.kind = session-context 的 user/message(只收用户与助手的原话、最新在后、上限 8000 字,跳过工具事件与注入上下文),最后是 descriptor。模型照旧拿到上下文,侧聊里只折叠成一行「上下文」;同时不再为复制整条日志付一遍 token。
|
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
| **清单多选插入** | 引用清单每行一个勾选框,`插入所选` 一次把多条引用写进输入框——每条各成一颗芯片、各自带坐标,不合并;芯片通路不可用时退回一段包含多个引用块的文本。这一栏是只读导航 + 批量插入,不做就地删除——删除与编辑在**草稿上方的聚合条**里做(宿主暴露了按区间消费 token 的 `consumeToken`;旧宿主没有这个读取面时才退回退格路径)。 |
|
|
52
52
|
| **审阅工作台** | 清单页把引用当**对象**管:每行一个 **`待处理 / 已解决`** 状态钮(点一下切换,行整体随之变淡),页首的 `只看未解决 (N)` 一键筛出没处理完的;每行可以打 **`#标签`**(回车添加、点 × 移除、点标签按它筛选,页首列出全部标签及数量);勾选若干行后 `标记已解决 (N)` 批量落章,`插入所选` 批量送回输入框。状态与标签按引用坐标归一键(老引用退回按原文),记在本机 `localStorage`(`dsh-client-ui-quote:v1:quote-meta`,上限 800 条)——跨刷新仍在,不跨设备同步、不进模型上下文;`导出 Markdown` 的标题行会带上 `已解决` 与标签标记,没标过的行输出和从前逐字相同。 |
|
|
53
53
|
| **跳回原文** | 悬停卡片底部的 `跳回原文`:把引用当初所在的那条消息**停到视野上方 24px**(和宿主自己跳轮次一样的位置)并闪一下。坐标(消息行锚点 + 轮次 + 同句序号)在插入时**写进消息自己的文本**——一段只在线上存在的 `※ 引自第 N 轮 · 助手 │q1:…` 行(base64url 的 JSON),显示、胶囊、清单导出里都剥掉;它随消息本身走,所以跨刷新、跨窗口、以及清单页里根本没被本插件看着选中的引用都跳得回去。只在**引用自己那条会话的面板里**找,不跨会话误跳;被虚拟列表藏起来的行会让位给同轮次的可见行;压根没挂载的行靠滚动搜索一屏一屏地找出来——先往上找(引用总在比它更早的消息里),一路到顶还没找到就从最新处再来一遍;都找不到就明说(`原文已变`——消息还在、那段文字不在了;或 `原文不在当前视图里`)。 |
|
|
54
|
-
| **引用清单** |
|
|
54
|
+
| **引用清单** | 右侧栏的一个**页面**:本会话发出去的引用与评论的清单。搜索框按引用/评论过滤、点一条跳回原文、每行可以**单条复制成 Markdown**(粘贴到 PR / issue / 别的会话,带评论与 `引自第 N 轮 · 助手` 出处)、`导出 Markdown` 落一个文件、历史没读完时可 `加载更早的消息`。它作为**页面类型**注册进右侧栏(`ctx.sidebarRightTabs.register`),并在指南页留下一个胶囊作入口;`Alt+L` 同样能打开。数据来自会话自己的事件日志(`ctx.sessions.retain` → `session.eventSource`),不依赖屏幕上有没有那条消息;**本机还留一份索引**:上下文压缩把消息裁掉之后,这些引用仍然列得出来。清单可以导出 **Markdown**(给人读)或 **JSON**(给工具读:版本化的 schema,带每条引用的来源坐标、文件、审阅状态与标签);每行带着**自己那份坐标**(从消息文本的 `※ … │q1:…` 行读出来),所以没被本插件看着选过的引用也能跳。页面底部还有一行**宿主自检**:芯片精确删除是否可用、侧聊走内置隐藏线程 / fork / better-sidebar,不开控制台就能判断。 |
|
|
55
55
|
| **侧边线程回流** | 同一个页面底部是这条会话的**侧边线程**:显示它最后问的那个问题与消息条数,并给两个动作——**`带回主会话`** 把线程最后「问题 + 答案」写成主会话输入框里的草稿(**不发送**,你自己按发送),**`导出 Markdown`** 把整条线程落成一个文件。线程内容同样从它自己的事件日志读;没有线程时这一节只说明「还没有侧边线程」。 |
|
|
56
56
|
| **改这条引用** | 悬停卡片里的 `改这条引用`:把那条引用当初的**引用与评论都原样放回评论框**(引用正文、评论各占一个输入框,都能改),你改完按 `⏎` 就发出一条**新的**引用(DSH 没有编辑已发送消息的 API,这是唯一诚实的做法)。旧胶囊随即挂上 **`已修订`** 标记,卡片里直接显示改后的评论;**连正文一起改过的**,卡片里也把改后的原文亮出来。标记按引用原文记在本机,跨刷新仍在。 |
|
|
57
57
|
| **逐条回应** | 评论框里的一格**勾选项**(默认关):勾上,这条引用的块尾就多一行**只有模型读得到的指令**——请按出现顺序把引用记为「引用1」「引用2」…并在回复中逐条对应。**指令随界面语言**:英文界面下要求模型用 `「Quote N」`,模型的回复里 `「引用N」` 与 `「Quote N」` 都会被认成徽章。模型的回复里每个这样的标签会变成一枚**小徽章**:悬停就是那条引用的完整卡片(含 `跳回原文` 与 `改这条引用`),点了就跳;编号超出这条消息引用数的标签保持纯文本。指令行与围栏一样只是**线上形态**,胶囊、卡片、导出里都不出现。 |
|
|
@@ -139,7 +139,7 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
|
|
|
139
139
|
|
|
140
140
|
本机切换这三档(这台开发机专用的辅助脚本,不在仓库里):`node .dsh-quote-work/profile-mode.mjs link|lab|stable|status`。换完在窗口里 `Ctrl+Shift+R` 生效;只有宿主半有改动时才需要重启桌面端。
|
|
141
141
|
|
|
142
|
-
两条线的断言入口相同:`npm test`(
|
|
142
|
+
两条线的断言入口相同:`npm test`(656 项:check 12 + capsule 443 + panel 159 + host 42);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
|
|
143
143
|
|
|
144
144
|
**推送通道**:`git push` 优先,但本机 `github.com:443` **时通时不通**(2026-10-19 实测:短时可用、随后连续超时,每次失败等约 21 秒)。日常用工作区根的 `promote.ps1`:先试 git,失败自动转 REST 兜底(`.dsh-quote-work/publish-release.mjs`、`.dsh-quote-work/push-branch.mjs`,`-Channel rest` 可跳过探测),并核对远端 SHA。**别混用两条通道**:REST 重建的提交时区归一到 `+0000`,内容相同但 SHA 不同,同一分支混推会分叉(`git status` 看着干净,远端却多一条平行提交);恢复就先 `git fetch`、确认 `git diff <本地> <远端>` 为空,再把本地线 reset 到远端。
|
|
145
145
|
|
|
@@ -326,7 +326,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
326
326
|
1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
|
|
327
327
|
2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标以「人可读来源头 + base64 尾巴」随消息走——前者让模型知道出处,后者让插件跨刷新回读。base64 确实占一点 prompt,这是用「不依赖本地表」换来的取舍。
|
|
328
328
|
3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在同类里仍是少数。
|
|
329
|
-
4. **工程化**:
|
|
329
|
+
4. **工程化**:656 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
|
|
330
330
|
|
|
331
331
|
反过来也要说清楚:**本插件不做的**——不做翻译/解释/改写/飞书投递类选区动作;不做图片/截图引用(**明确取舍**:图片引用要的是另一套胶囊/卡片/附件语义,是第二个产品的量级,塞进这里只会把「文本引用带坐标」这条主线冲淡;`dsh-selection-toolbar` 与 Kimi 桌面端已经做了,真要做建议独立成插件);不做完整 Topic 系统;不做常显大块引用。它只把「引用」这一件事做穿——但「做穿」包括发送后的管理、来源闭环和审阅,不只是浮层。
|
|
332
332
|
|
|
@@ -419,7 +419,7 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
|
|
|
419
419
|
(共 443 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、**表格单元格 / 表格行 / 段落 / 列表项 / `<br>` 边界的引用高亮**、**输入框已有文字时引用置顶并将原文另起一行**、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、原生 `quote-sidechat` 页签的注册 / 地址 / 标题 / retain-release、内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
|
|
420
420
|
`test/capsule.html` 是同一套断言的真浏览器版(51 项),用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。这一版修好了它**缺失的 `<body>` 标签**——脚本在 `<head>` 里执行时 `document.body` 为 null,这个页面以前在真浏览器里是直接坏的;并补上真 `Range` 的链接/行内代码断言(真浏览器的 `cloneContents()` 返回的是 DocumentFragment,不是元素——序列化器只认元素的话,链接在真浏览器里会退化成纯文本;离线桩现在也用真片段,不再放过这一类),另外补了 `CSS.highlights` 选区保持与关闭清理。
|
|
421
421
|
|
|
422
|
-
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(
|
|
422
|
+
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(159 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
|
|
423
423
|
引用按原文逐行拆出来而评论跟着同一条记录、最新一条排最前、空查询保持全量、引用与评论都能被搜到且忽略大小写、
|
|
424
424
|
Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超长引用在标题里被截断、非法时间导出为空;
|
|
425
425
|
读取侧用桩 `sessions.retain` 真跑一遍——读出两行且 `hasMore` 透传、**读完必须 release**(记录 `source: "quoteList"`)、
|
|
@@ -437,7 +437,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
437
437
|
|
|
438
438
|
`test/host.mjs` 不需要浏览器:它直接 import 宿主半,用桩 `agents` / `agentPresets` / `sessionTitle` / `webServer` 驱动。断言包括:`Side: ` 标签的前缀与截断;种子固定三件事(`session/end-seed{inherited:true}` → 一条 `source.kind="session-context"` 的 `user/message` → `subagent/descriptor`)、`inheritedEventCount` 恒为 0、上下文字段只收用户与助手的原话(最新在后、8000 字上限、跳过 `runtime-context` 与工具事件)、空日志也有可读文案;descriptor 排在切点之后且 seq 连续;`agents.create` 收到 `origin: "subagent"` / `parentSession` / `isSeeded` / `inheritedEventCount` / 父会话当前模型路由 / preset setup / 清空继承 inbox / 固定标题;探针、409、503、403、405、404 与 `apply` 的注册/卸载全部有断言。
|
|
439
439
|
|
|
440
|
-
合计 **
|
|
440
|
+
合计 **656 组**(check 12 + capsule 443 + panel 159 + host 42),`npm test` 全绿。
|
|
441
441
|
|
|
442
442
|
## 已知限制
|
|
443
443
|
|
|
@@ -481,7 +481,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
481
481
|
- **Markdown 预览的行号**:要精修就得读源文件,而客户端必须声明 `remote` 才能拿到它;声明错会让 client bundle 在不匹配的宿主上**静默失效**(`dsh-quote-annotate` 上游就是这么死的)。所以先不声明——文件仍然打开,只是落在顶部而不是行上。
|
|
482
482
|
- **表格/列表/LaTeX 的结构保真**:链接与行内代码已做;表格要还原成 Markdown 表格,列表要还原成缩进,成本明显更高,留到有人真的需要时再做。
|
|
483
483
|
- **图片/截图引用**:明确不做(理由见「本插件不做的」)。真要做请独立成插件:图片引用要的是另一套胶囊/卡片/附件语义。
|
|
484
|
-
- **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有
|
|
484
|
+
- **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 656 项离线断言;隐藏线程的 `agents.create` / 种子格式已在**隔离的 DSH web 实例**(临时 `DSH_HOME`,端口 8791,CLI 0.2.1-rc.3)里确认:宿主半通过 `ctx.inject(["webServer"])` 晚到注册,`POST /ui-quote/api/sidechat.start` 的探针返回 `{"ok":true,"value":{"available":true,"version":1}}`,说明 `agents` 服务与同源围栏都就绪。本轮又在**运行中的桌面端**(link 模式,重启后加载 `lib/index.js`)核对过一次:`/ui-quote/api/*` 下任意路径都先被围栏拦成 `403 {"code":"forbidden"}`,而同样方法打一个不存在的顶层路径是 `404`,说明前缀路由确实挂上了、并且先过围栏。原生侧聊页签的 `SessionProvider` / `conversation.content` 嵌入契约还需要在真实窗口里重开一次侧边对话确认(离线桩只覆盖注册、地址、标题与 retain/release,不渲染宿主组件)。真浏览器(1600×2000 视口)同一套断言本轮复核 **51/51**。欢迎页的交互点击被安全闸门拦下,因此下面五条仍需在真实窗口逐项点:聚合条编辑、回合尾的两个按钮、打开文件与行范围标记、另开侧边对话、JSON 导出。
|
|
485
485
|
- **市场收录与首屏**:README 已有截图、徽章、「30 秒上手」与 24 秒静态轮播 GIF;还差把插件提交到 dsh.market / dsh-plugin.org / dsh.pub / awesome-deepseek-harness —— 这些需要外部账号,是人工动作。
|
|
486
486
|
|
|
487
487
|
## 变更记录
|
package/lib/client.js
CHANGED
|
@@ -6757,6 +6757,8 @@ color:var(--dshq-accent,#3b82f6)}
|
|
|
6757
6757
|
.dshq-list .dshq-row-status{padding:0;border:0;font:inherit;font-size:11px;cursor:pointer;background:none;
|
|
6758
6758
|
color:inherit;text-decoration:underline dotted;opacity:1}
|
|
6759
6759
|
.dshq-list .dshq-row-status[data-status="resolved"]{color:var(--dshq-accent,#3b82f6)}
|
|
6760
|
+
.dshq-list .dshq-row-copy{padding:0;border:0;font:inherit;font-size:11px;cursor:pointer;background:none;
|
|
6761
|
+
color:inherit;text-decoration:underline dotted;opacity:1}
|
|
6760
6762
|
.dshq-list .dshq-row-tags{display:flex;flex-wrap:wrap;align-items:center;gap:5px}
|
|
6761
6763
|
.dshq-list .dshq-tag{display:inline-flex;align-items:center;gap:2px;border-radius:99px;
|
|
6762
6764
|
border:1px solid color-mix(in srgb,currentColor 30%,transparent);padding:0 2px 0 7px;font-size:10.5px}
|
|
@@ -6866,6 +6868,34 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
6866
6868
|
return list.filter((row) => (row.quote + "\n" + row.comment).toLowerCase().includes(needle));
|
|
6867
6869
|
}
|
|
6868
6870
|
|
|
6871
|
+
/**
|
|
6872
|
+
* One quote as Markdown you can paste somewhere else — a PR comment, an
|
|
6873
|
+
* issue, another chat. The list export keeps its numbered layout; this is
|
|
6874
|
+
* the single-row unit it is built from, and the copy button's payload.
|
|
6875
|
+
* A code quote stays a real fenced block (a preview quote's file rides in
|
|
6876
|
+
* the info string); everything else is a blockquote. The provenance line
|
|
6877
|
+
* is the same human-readable `引自第 N 轮 · 助手` the wire carries.
|
|
6878
|
+
*
|
|
6879
|
+
* @param {object} row - one row from `quoteRowsFromEntries`.
|
|
6880
|
+
* @returns {string} Markdown for that quote alone.
|
|
6881
|
+
*/
|
|
6882
|
+
function quoteRowMarkdown(row) {
|
|
6883
|
+
if (row === null || row === undefined) return "";
|
|
6884
|
+
const lines = [];
|
|
6885
|
+
if (row.code === true) {
|
|
6886
|
+
const info = typeof row.fence === "string" && row.fence !== "" ? " " + row.fence : "";
|
|
6887
|
+
lines.push("```" + info, String(row.quote), "```");
|
|
6888
|
+
} else {
|
|
6889
|
+
for (const line of String(row.quote).split("\n")) lines.push("> " + line);
|
|
6890
|
+
}
|
|
6891
|
+
if (typeof row.comment === "string" && row.comment !== "") lines.push("", "**评论**:" + row.comment);
|
|
6892
|
+
const label = row.ref === undefined || row.ref === null ? "" : wireSourceLabel(row.ref);
|
|
6893
|
+
const when = formatQuoteTime(row.time);
|
|
6894
|
+
const meta = [label, when].filter((part) => typeof part === "string" && part !== "");
|
|
6895
|
+
if (meta.length > 0) lines.push("", "_" + meta.join(" · ") + "_");
|
|
6896
|
+
return lines.join("\n");
|
|
6897
|
+
}
|
|
6898
|
+
|
|
6869
6899
|
/** The list as Markdown: numbered sections, the quote as a blockquote. */
|
|
6870
6900
|
function quoteListMarkdown(rows, header) {
|
|
6871
6901
|
const list = Array.isArray(rows) ? rows : [];
|
|
@@ -6990,7 +7020,7 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
6990
7020
|
}
|
|
6991
7021
|
|
|
6992
7022
|
/** One list row: the quote, its comment, and whether it can be traced back. */
|
|
6993
|
-
function quoteRowElement(react, row, index, sessionId, onJump, selection, review) {
|
|
7023
|
+
function quoteRowElement(react, row, index, sessionId, onJump, selection, review, copy) {
|
|
6994
7024
|
const ref = row.ref === undefined ? null : row.ref;
|
|
6995
7025
|
const jumpable = sourceCandidatesFor(row.quote, sessionId, ref).length > 0;
|
|
6996
7026
|
const lost = jumpable && lostJumps.has(capsuleJumpKey(row.quote, sessionId, ref));
|
|
@@ -7144,6 +7174,27 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
7144
7174
|
);
|
|
7145
7175
|
}
|
|
7146
7176
|
footChildren.push(react.createElement("span", { key: "jump" }, jumpable ? translateFor(sessionId, lost ? "sourceLost" : "jump") : ""));
|
|
7177
|
+
// One quote in Markdown, ready to paste outside this plugin — the
|
|
7178
|
+
// capability every surveyed tool has and this list did not.
|
|
7179
|
+
if (copy !== null && copy !== undefined) {
|
|
7180
|
+
footChildren.push(
|
|
7181
|
+
react.createElement(
|
|
7182
|
+
"button",
|
|
7183
|
+
{
|
|
7184
|
+
key: "copy",
|
|
7185
|
+
type: "button",
|
|
7186
|
+
className: "dshq-row-copy",
|
|
7187
|
+
title: translateFor(sessionId, "copy"),
|
|
7188
|
+
onClick: (event) => {
|
|
7189
|
+
stop(event);
|
|
7190
|
+
if (typeof copy.onCopy === "function") copy.onCopy(row);
|
|
7191
|
+
},
|
|
7192
|
+
onKeyDown: stop
|
|
7193
|
+
},
|
|
7194
|
+
translateFor(sessionId, copy.copied === true ? "copied" : "copy")
|
|
7195
|
+
)
|
|
7196
|
+
);
|
|
7197
|
+
}
|
|
7147
7198
|
children.push(react.createElement("div", { className: "dshq-row-foot", key: "foot" }, footChildren));
|
|
7148
7199
|
return react.createElement(
|
|
7149
7200
|
"div",
|
|
@@ -7192,6 +7243,8 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
7192
7243
|
const [tagFilter, setTagFilter] = react.useState("");
|
|
7193
7244
|
const [tagEditing, setTagEditing] = react.useState("");
|
|
7194
7245
|
const [tagDraft, setTagDraft] = react.useState("");
|
|
7246
|
+
/** The row whose copy button just fired, for its short "已复制" label. */
|
|
7247
|
+
const [copiedKey, setCopiedKey] = react.useState("");
|
|
7195
7248
|
/** The session's side thread, as the footer needs it: null until read. */
|
|
7196
7249
|
const [thread, setThread] = react.useState(null);
|
|
7197
7250
|
const [threads, setThreads] = react.useState([]);
|
|
@@ -7207,6 +7260,16 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
7207
7260
|
});
|
|
7208
7261
|
};
|
|
7209
7262
|
|
|
7263
|
+
/** Copy one quote as Markdown, then flash its button for a moment. */
|
|
7264
|
+
const copyRow = (row) => {
|
|
7265
|
+
writeClipboard(quoteRowMarkdown(row));
|
|
7266
|
+
const key = quoteRowKey(row);
|
|
7267
|
+
setCopiedKey(key);
|
|
7268
|
+
setTimeout(() => {
|
|
7269
|
+
setCopiedKey((previous) => (previous === key ? "" : previous));
|
|
7270
|
+
}, 1200);
|
|
7271
|
+
};
|
|
7272
|
+
|
|
7210
7273
|
const metaOf = (row) => quoteMeta.get(quoteMetaKeyOf(row)) ?? { status: "open", tags: [], at: 0 };
|
|
7211
7274
|
const refreshMeta = () => setQuoteMeta(quoteMetaSnapshot());
|
|
7212
7275
|
|
|
@@ -7478,7 +7541,11 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
7478
7541
|
checked: selected.has(quoteRowKey(row)),
|
|
7479
7542
|
onToggle: toggleSelected
|
|
7480
7543
|
},
|
|
7481
|
-
review
|
|
7544
|
+
review,
|
|
7545
|
+
{
|
|
7546
|
+
copied: copiedKey !== "" && copiedKey === quoteRowKey(row),
|
|
7547
|
+
onCopy: copyRow
|
|
7548
|
+
}
|
|
7482
7549
|
);
|
|
7483
7550
|
})
|
|
7484
7551
|
);
|
|
@@ -8466,6 +8533,7 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
8466
8533
|
quoteRowsFromEntries,
|
|
8467
8534
|
filterQuoteRows,
|
|
8468
8535
|
quoteListMarkdown,
|
|
8536
|
+
quoteRowMarkdown,
|
|
8469
8537
|
quoteListJson,
|
|
8470
8538
|
rememberIndexedQuotes,
|
|
8471
8539
|
indexedQuotes,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-client-ui-quote",
|
|
3
|
-
"version": "0.12.3-lab.
|
|
3
|
+
"version": "0.12.3-lab.32",
|
|
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/panel.mjs
CHANGED
|
@@ -490,6 +490,60 @@ check(
|
|
|
490
490
|
JSON.stringify(openedResources)
|
|
491
491
|
);
|
|
492
492
|
|
|
493
|
+
// ---- one quote, copied as Markdown ------------------------------------------
|
|
494
|
+
// The list export is a document; this is the unit someone pastes into a PR or
|
|
495
|
+
// another chat, so it is asserted on its own.
|
|
496
|
+
const copiedRow = testing.quoteRowMarkdown({
|
|
497
|
+
quote: "第一行\n第二行",
|
|
498
|
+
comment: "我的评论",
|
|
499
|
+
time: "",
|
|
500
|
+
code: false,
|
|
501
|
+
fence: "",
|
|
502
|
+
ref: { v: 1, s: "session-far", a: "data-chat-anchor-key", k: "row-2", t: "9", n: 0, r: "a" }
|
|
503
|
+
});
|
|
504
|
+
check(
|
|
505
|
+
"one quote copies as a blockquote with its comment and provenance",
|
|
506
|
+
copiedRow.startsWith("> 第一行\n> 第二行") &&
|
|
507
|
+
copiedRow.includes("**评论**:我的评论") &&
|
|
508
|
+
copiedRow.includes("_引自第 9 轮 · 助手_"),
|
|
509
|
+
copiedRow
|
|
510
|
+
);
|
|
511
|
+
check(
|
|
512
|
+
"a code quote copies as a real fenced block",
|
|
513
|
+
testing.quoteRowMarkdown({ quote: "SELECT 1;", comment: "", time: "", code: true, fence: "src/app.ts#L12-L48" }) ===
|
|
514
|
+
"``` src/app.ts#L12-L48\nSELECT 1;\n```",
|
|
515
|
+
JSON.stringify(testing.quoteRowMarkdown({ quote: "SELECT 1;", comment: "", time: "", code: true, fence: "src/app.ts#L12-L48" }))
|
|
516
|
+
);
|
|
517
|
+
check(
|
|
518
|
+
"a quote with no coordinates still copies as a plain blockquote",
|
|
519
|
+
testing.quoteRowMarkdown({ quote: "只有原文", comment: "", time: "", code: false, fence: "" }) === "> 只有原文",
|
|
520
|
+
JSON.stringify(testing.quoteRowMarkdown({ quote: "只有原文", comment: "", time: "", code: false, fence: "" }))
|
|
521
|
+
);
|
|
522
|
+
let copiedRowArg = null;
|
|
523
|
+
const copyRowElement = testing.quoteRowElement(reactStub, rows[0], 0, "session-far", () => {}, null, null, {
|
|
524
|
+
copied: false,
|
|
525
|
+
onCopy: (row) => {
|
|
526
|
+
copiedRowArg = row.quote;
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
const rowCopyButton = findClass(copyRowElement, "dshq-row-copy");
|
|
530
|
+
check(
|
|
531
|
+
"the row offers copying one quote",
|
|
532
|
+
rowCopyButton !== null && String(rowCopyButton?.props?.children) === "复制",
|
|
533
|
+
JSON.stringify(rowCopyButton?.props?.children)
|
|
534
|
+
);
|
|
535
|
+
rowCopyButton?.props?.onClick({ stopPropagation() {} });
|
|
536
|
+
check("clicking it copies that row", copiedRowArg === rows[0].quote, JSON.stringify(copiedRowArg));
|
|
537
|
+
const copiedRowElement = testing.quoteRowElement(reactStub, rows[0], 0, "session-far", () => {}, null, null, {
|
|
538
|
+
copied: true,
|
|
539
|
+
onCopy: () => {}
|
|
540
|
+
});
|
|
541
|
+
check(
|
|
542
|
+
"the button flashes 已复制 after copying",
|
|
543
|
+
String(findClass(copiedRowElement, "dshq-row-copy")?.props?.children) === "已复制",
|
|
544
|
+
JSON.stringify(findClass(copiedRowElement, "dshq-row-copy")?.props?.children)
|
|
545
|
+
);
|
|
546
|
+
|
|
493
547
|
// ---- the body component -----------------------------------------------------
|
|
494
548
|
const body = testing.QuoteListBody({ sessionId: "session-far", load: () => Promise.resolve({ ok: true, rows: [], hasMore: false }) });
|
|
495
549
|
check("the body renders the panel shell", body?.props?.className === "dshq-list", JSON.stringify(body?.props?.className));
|