dsh-client-ui-quote 0.12.3-lab.35 → 0.12.3-lab.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -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
- - 离线断言 **659 组**(check 12 + capsule 446 + panel 159 + host 42);新增原生页签的地址解析、标题、资源 retain/release、页签注册与卸载断言,以及宿主半的种子复制 / 继承切点 / 回合收口 / 悬挂工具调用截断 / `agents.create` 参数 / 探针与围栏断言。
9
+ - 离线断言 **662 组**(check 12 + capsule 449 + panel 159 + host 42);新增原生页签的地址解析、标题、资源 retain/release、页签注册与卸载断言,以及宿主半的种子复制 / 继承切点 / 回合收口 / 悬挂工具调用截断 / `agents.create` 参数 / 探针与围栏断言。
10
10
 
11
11
  ### Fixed
12
12
 
13
+ - 侧聊草稿里的引用看起来像「我自己打的字」:之前只把引用写成 `> 原文` 的块引用塞进输入框,发出去就成了用户自己的话。现在侧聊草稿**优先用原生引用芯片**(`insertReference`,与主输入框同一条路径),芯片可见地属于引用来源、发送时也会还原成线格式;只有宿主没有芯片面时才退回块引用,且第二次追加不会覆盖已有草稿。
13
14
  - 胶囊的悬停卡片也加了「复制为 Markdown」:同一个 `quoteRowMarkdown` 载荷,无论这条引用有没有坐标都能用(有坐标时出处行就是 `引自第 N 轮 · 助手`)。
14
15
  - 引用清单每行多一个「复制」:把**单条**引用复制成 Markdown(原文块引用或代码围栏、评论、`引自第 N 轮 · 助手` 出处),可以直接粘进 PR / issue / 别的会话。之前只有整页导出,单条引用拿不出去——这是竞品调研里唯一明确的能力缺口(Aider `/copy-context`、Zed/Cline 的「带出处」都是这个形态)。
15
16
  - 侧聊页打开后是一片「探索未至之境」欢迎页、输入框里也没有引用:宿主把「一个 turn 都没有」的会话算成 blank,blank 会话渲染欢迎页而不是聊天页(没有输入框,`prefillDraft` 无处可落)。种子现在把隐性上下文包进一个**已收口的小回合**(`turn/start` → `step/start` → 上下文消息 → `step/end` → `turn/end{done}`),会话不再是 blank,页签进正常聊天态;形状版本戳同时升到 v4,之前建出来的空白线程会在下一次点击时被替换。
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  2. 在消息、文件预览、终端或 diff 里**选中一段文字**,浮层选「评论」或「添加到对话」。
13
13
  3. 发送后回原文看高亮,悬停胶囊可**跳回**、可**复制为 Markdown**;右侧栏「引用清单」(`Alt+L`)可搜索、导出、审阅。
14
14
 
15
- 「侧边对话」默认**不需要 `dsh-better-sidebar`**:本插件的宿主半会建一个 `origin: "subagent"` 的隐藏子会话(不出现在会话列表),再把主会话**最近几轮的原话**作为一条**隐性上下文**注进去——模型有背景,侧聊里却不会把整段历史铺出来。页签用宿主内置的 `subagentchat` 打开,**仍然只写草稿,不替你发送**。宿主半不可用时自动退回 fork + 本插件页签;想优先用 better-sidebar,把 `sideChatBackend` 设成 `"better"`。
15
+ 「侧边对话」默认**不需要 `dsh-better-sidebar`**:本插件的宿主半会建一个 `origin: "subagent"` 的隐藏子会话(不出现在会话列表),再把主会话**最近几轮的原话**作为一条**隐性上下文**注进去——模型有背景,侧聊里却不会把整段历史铺出来。页签用宿主内置的 `subagentchat` 打开,引用以**原生芯片**落进它的草稿(宿主没有芯片面时退回块引用),**仍然只写草稿,不替你发送**。宿主半不可用时自动退回 fork + 本插件页签;想优先用 better-sidebar,把 `sideChatBackend` 设成 `"better"`。
16
16
 
17
17
  选中正文 → 浮层动作 → 引用变成输入框里的行内胶囊(不是纯文本),评论也放在同一颗芯片里;发送之后,引用和评论**合成一颗胶囊**留在消息里,不会摊成一屏 `>` 行。第三项「侧边对话」则把引用写进右侧栏一条**独立的侧边线程的输入框**(一条主会话一条线程,反复点就反复追加),问什么由你写,主会话一点都不动。评论框里**引用正文本身也可编辑**(选多了当场裁);从**文件预览、终端或 diff**划选的引用以**围栏代码块**的线格式发给模型,模型看到的是真正的代码块。引用落进草稿后,**原句在来源消息里留下高亮与序号**,输入框上方出现 **`引用 ×N`** 的聚合条;发送块里的坐标行前面还带一段人可读的 **`引自第 N 轮 · 助手`**,模型不用解码就知道这段话从哪来。
18
18
 
@@ -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`(659 项:check 12 + capsule 446 + panel 159 + host 42);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
142
+ 两条线的断言入口相同:`npm test`(662 项:check 12 + capsule 449 + 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. **工程化**:659 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
329
+ 4. **工程化**:662 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
330
330
 
331
331
  反过来也要说清楚:**本插件不做的**——不做翻译/解释/改写/飞书投递类选区动作;不做图片/截图引用(**明确取舍**:图片引用要的是另一套胶囊/卡片/附件语义,是第二个产品的量级,塞进这里只会把「文本引用带坐标」这条主线冲淡;`dsh-selection-toolbar` 与 Kimi 桌面端已经做了,真要做建议独立成插件);不做完整 Topic 系统;不做常显大块引用。它只把「引用」这一件事做穿——但「做穿」包括发送后的管理、来源闭环和审阅,不只是浮层。
332
332
 
@@ -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
- 合计 **659 组**(check 12 + capsule 446 + panel 159 + host 42),`npm test` 全绿。
440
+ 合计 **662 组**(check 12 + capsule 449 + 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` 的一手文档,功能路径有 659 项离线断言;隐藏线程的 `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 导出。
484
+ - **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 662 项离线断言;隐藏线程的 `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
@@ -5587,7 +5587,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5587
5587
  * that rename, so the tab names the quote instead of the child id. Nothing
5588
5588
  * is sent.
5589
5589
  */
5590
- function nativeSideChatRequest(sessionId, text, code, path, lang, mode, quoteKey) {
5590
+ function nativeSideChatRequest(sessionId, text, code, path, lang, mode, quoteKey, meta) {
5591
5591
  if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text.trim() === "") {
5592
5592
  return Promise.resolve();
5593
5593
  }
@@ -5637,7 +5637,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5637
5637
  rememberSideThread(sessionId, threadId);
5638
5638
  if (typeof quoteKey === "string" && quoteKey !== "") rememberQuoteThread(sessionId, quoteKey, threadId);
5639
5639
  const boundary = targetOfThread(threadId).kind === "hidden" ? translateFor(sessionId, "sideChatBoundary") : undefined;
5640
- if (!(await prefillDraft(threadId, sideQuestion(text, code === true, path, lang), boundary))) {
5640
+ // A fork is an ordinary session: its composer takes the chip, so the
5641
+ // quote reads as a reference there too. The boundary line only rides
5642
+ // with the plain-text form.
5643
+ if (!(await prefillQuoteDraft(threadId, text, code === true, path, lang, meta ?? (boundary === undefined ? undefined : { boundary })))) {
5641
5644
  console.warn("[ui-quote] native sidechat draft not written:", threadId);
5642
5645
  toast(translateFor(sessionId, "sideChatDraftFailed"));
5643
5646
  }
@@ -5791,7 +5794,7 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5791
5794
  * was handled (open or failed after creating); `false` means nothing was
5792
5795
  * created and the caller may fall back to a fork.
5793
5796
  */
5794
- function hiddenSideChatRequest(sessionId, text, code, path, lang, mode, quoteKey) {
5797
+ function hiddenSideChatRequest(sessionId, text, code, path, lang, mode, quoteKey, meta) {
5795
5798
  if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text.trim() === "") {
5796
5799
  return Promise.resolve(false);
5797
5800
  }
@@ -5833,10 +5836,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5833
5836
  toast(translateFor(sessionId, "sideChatFailed"));
5834
5837
  return true;
5835
5838
  }
5836
- // No visible boundary line: the thread's own first message carries
5837
- // the "background only" instruction, so the composer holds nothing
5838
- // but the quote the reader is about to ask about.
5839
- if (!(await prefillDraft(threadId, sideQuestion(text, code === true, path, lang)))) {
5839
+ // The quote goes in as a reference chip, not as the reader's own
5840
+ // words; the thread's first message already carries the background
5841
+ // instruction, so no visible boundary line is written.
5842
+ if (!(await prefillQuoteDraft(threadId, text, code === true, path, lang, meta))) {
5840
5843
  console.warn("[ui-quote] hidden sidechat draft not written:", threadId);
5841
5844
  toast(translateFor(sessionId, "sideChatDraftFailed"));
5842
5845
  }
@@ -6399,6 +6402,38 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
6399
6402
  * of the first quote in a seeded thread.
6400
6403
  * @returns {Promise<boolean>} whether the draft was written.
6401
6404
  */
6405
+ /**
6406
+ * Put the quote into a side thread's composer the way the main composer
6407
+ * gets it: as an inline reference chip, not as the reader's own words. A
6408
+ * plain blockquote draft reads like something *they* typed and would be
6409
+ * sent as their message; a chip visibly belongs to the plugin's reference
6410
+ * source, and the host's send path turns it back into the wire form.
6411
+ * Falls back to the blockquote only when the shell has no chip face.
6412
+ */
6413
+ async function prefillQuoteDraft(threadId, text, code, path, lang, meta) {
6414
+ const boundary = meta !== null && meta !== undefined && typeof meta.boundary === "string" ? meta.boundary : "";
6415
+ for (let attempt = 0; attempt < 25; attempt += 1) {
6416
+ const shell = inputShell(threadId);
6417
+ if (shell !== null && shell !== undefined) {
6418
+ const entry = insertionFace(threadId, shell);
6419
+ if (entry !== null && insertChip(entry, threadId, text, "", code === true, meta)) return true;
6420
+ // The text fallback appends: a second quote on the same thread must
6421
+ // not wipe the draft the first one left behind.
6422
+ const current = shellDraft(shell);
6423
+ const kept = current === null ? "" : current.replace(/\s+$/u, "");
6424
+ const prefix = boundary !== "" && kept.includes(boundary) === false ? boundary + "\n\n" : "";
6425
+ const plain = sideQuestion(text, code === true, path, lang);
6426
+ if (writeDraft(shell, kept === "" ? prefix + plain : kept + "\n\n" + prefix + plain)) return true;
6427
+ console.warn("[ui-quote] sidechat shell has no draft face");
6428
+ noteFailure("sidechat", "the side thread's shell has no draft face");
6429
+ return false;
6430
+ }
6431
+ await sleep(200);
6432
+ }
6433
+ noteFailure("sidechat", "the side thread's composer did not mount in 5s");
6434
+ return false;
6435
+ }
6436
+
6402
6437
  async function prefillDraft(sessionId, text, boundary) {
6403
6438
  for (let attempt = 0; attempt < 25; attempt += 1) {
6404
6439
  const shell = inputShell(sessionId);
@@ -6510,6 +6545,15 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
6510
6545
  const code = state.code;
6511
6546
  const path = state.path;
6512
6547
  const lang = state.lang;
6548
+ // The chip's own metadata: the file a preview/diff quote came from and
6549
+ // the reader's origin. Without it the side thread's chip would render
6550
+ // as a bare quote with no provenance.
6551
+ const meta = {
6552
+ path,
6553
+ lang,
6554
+ origin: state.origin,
6555
+ ...(state.ref === undefined || state.ref === null ? {} : { ref: state.ref })
6556
+ };
6513
6557
  const next = mode === "new" ? "new" : "reuse";
6514
6558
  const quoteKey = sourceKeyOf(text);
6515
6559
  close();
@@ -6524,11 +6568,11 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
6524
6568
  }
6525
6569
  }
6526
6570
  if (builtinSideChatState !== "unavailable") {
6527
- const handled = await hiddenSideChatRequest(sessionId, text, code, path, lang, next, quoteKey);
6571
+ const handled = await hiddenSideChatRequest(sessionId, text, code, path, lang, next, quoteKey, meta);
6528
6572
  if (handled) return;
6529
6573
  }
6530
6574
  if (nativeSideChatAvailable()) {
6531
- await nativeSideChatRequest(sessionId, text, code, path, lang, next, quoteKey);
6575
+ await nativeSideChatRequest(sessionId, text, code, path, lang, next, quoteKey, meta);
6532
6576
  return;
6533
6577
  }
6534
6578
  const better = sideChatService();
@@ -8540,6 +8584,7 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
8540
8584
  openThreadTab,
8541
8585
  displayedThreadId,
8542
8586
  prefillDraft,
8587
+ prefillQuoteDraft,
8543
8588
  sourceKeyOf,
8544
8589
  saveSource,
8545
8590
  sourceFor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.12.3-lab.35",
3
+ "version": "0.12.3-lab.36",
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
@@ -745,10 +745,18 @@ const bindHostThread = (sessionId, threadId) => {
745
745
  };
746
746
  /** Install one session's input shell (`draftFace: false` = a version mismatch). */
747
747
  const installShell = (sessionId, options = {}) => {
748
- const state = { draft: options.draft ?? "" };
748
+ const state = { draft: options.draft ?? "", chips: [] };
749
749
  const shell = { state: { getSnapshot: () => ({ draft: state.draft }) } };
750
750
  if (options.draftFace === "actions") shell.actions = { setDraft: (text) => { state.draft = text; } };
751
751
  else if (options.draftFace !== false) shell.setDraft = (text) => { state.draft = text; };
752
+ // A host composer that can hold an atomic reference node (the chip face).
753
+ if (options.chipFace === true) {
754
+ shell.actions = { ...(shell.actions ?? {}), captureInsertion: () => ({ start: 0, end: 0, draftRev: 1 }) };
755
+ shell.insertReference = (attachment) => {
756
+ state.chips.push(attachment);
757
+ return true;
758
+ };
759
+ }
752
760
  shells.set(sessionId, shell);
753
761
  return state;
754
762
  };
@@ -978,6 +986,29 @@ try {
978
986
  hiddenState.draft.startsWith("> 隐藏引用") && hiddenState.draft.includes("Side conversation boundary.") === false,
979
987
  JSON.stringify(hiddenState.draft)
980
988
  );
989
+
990
+ // A blockquote in the composer reads like something the reader typed — it
991
+ // would be sent as their own words. The thread's composer takes the chip
992
+ // face when the host has one, so the quote stays a reference.
993
+ const chipThread = installShell("session-chip-side", { chipFace: true });
994
+ const chipWritten = await testing.prefillQuoteDraft("session-chip-side", "被引用的原话", false, null, "", { path: null, lang: "", origin: "chat" });
995
+ check(
996
+ "the side thread's quote lands as a reference chip",
997
+ chipWritten === true && chipThread.chips.length === 1 && chipThread.draft === "",
998
+ JSON.stringify({ chips: chipThread.chips.length, draft: chipThread.draft })
999
+ );
1000
+ check(
1001
+ "the chip carries the wire form and a readable label",
1002
+ String(chipThread.chips[0]?.ref).includes("❝ 被引用的原话") && chipThread.chips[0]?.label === "被引用的原话",
1003
+ JSON.stringify(chipThread.chips[0])
1004
+ );
1005
+ const plainThread = installShell("session-plain-side");
1006
+ const plainWritten = await testing.prefillQuoteDraft("session-plain-side", "被引用的原话", false, null, "", {});
1007
+ check(
1008
+ "a shell without the chip face still gets the blockquote draft",
1009
+ plainWritten === true && plainThread.chips.length === 0 && plainThread.draft === "> 被引用的原话",
1010
+ JSON.stringify({ ok: plainWritten, draft: plainThread.draft })
1011
+ );
981
1012
  check(
982
1013
  "the hidden thread is remembered with its parent",
983
1014
  testing.storedSideThread("session-hidden-main") === "session-hidden-1" &&