dsh-client-ui-quote 0.11.1 → 0.11.3

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
@@ -55,7 +55,7 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
55
55
 
56
56
  ### 方式三:npm 包(已上架)
57
57
 
58
- 已发布到 npm:`dsh-client-ui-quote@0.11.1`(0.10.0 起上架)。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 290 项断言会先在 runner 上跑完,任何一项失败都不会发出去。`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
58
+ 已发布到 npm:`dsh-client-ui-quote`(0.10.0 起上架;每个 `v*` 标签由下面的 workflow 自动发布,本仓库当前 0.11.3)。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 306 项断言会先在 runner 上跑完,任何一项失败都不会发出去。`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
59
59
 
60
60
  装:
61
61
 
@@ -227,7 +227,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
227
227
  1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
228
228
  2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标不随引用文本发给模型(不污染 prompt)。
229
229
  3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在 4471 条里没有对应物。
230
- 4. **工程化**:290 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
230
+ 4. **工程化**:306 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
231
231
 
232
232
  反过来也要说清楚:**本插件不做的**——不做翻译/解释类选区动作(81 条同类在做)、不做多选合并引用(7 条有)、不做选区折叠/行内 diff 这类渲染花样。它只把「引用」这一件事做穿。
233
233
 
@@ -260,7 +260,7 @@ locale/zh.json 插件页卡片文案(中文)
260
260
  locale/en.json 插件页卡片文案(英文)
261
261
  test/check.mjs 离线自检(清单 + 组件契约)
262
262
  test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
263
- test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章,194 项断言)
263
+ test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章 + 浮层不随座位失效,210 项断言)
264
264
  test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,96 项断言)
265
265
  test/capsule.html 真浏览器里跑同一套胶囊断言(可选)
266
266
  ```
@@ -308,7 +308,10 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
308
308
  卡片给出 `跳回原文` 与 `改这条引用` 两个动作、点后者把栏切到评论模式并**预填旧评论与旧正文**、
309
309
  按确认后 `insertReference` 恰好一次(新芯片的 `ref` 带改后的评论)、旧引用被记为「已修订」并落进 `localStorage`、
310
310
  旧胶囊挂上 `已修订` 徽标、它的卡片显示改后的评论(连正文改过的连改后原文一起显示)、空修订不算修订、
311
- 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 194 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用)。
311
+ 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出;
312
+ **浮层不跟着座位生死**(`conversation.input.overlay` 没挂载也能选中出栏、挂上两个会话座位既不叠加也不丢监听、
313
+ 座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份);
314
+ (共 210 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
312
315
  `test/capsule.html` 是同一套断言的真浏览器版,用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。
313
316
 
314
317
  `test/panel.mjs` 单独跑引用清单页与侧边线程回流(96 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
@@ -327,7 +330,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
327
330
  `bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
328
331
  `exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
329
332
 
330
- 合计 **290 项**(check.mjs 的契约断言 + capsule 194 + panel 96),`npm test` 全绿。
333
+ 合计 **306 项**(check.mjs 的契约断言 + capsule 210 + panel 96),`npm test` 全绿。
331
334
 
332
335
  ## 已知限制
333
336
 
package/lib/client.js CHANGED
@@ -591,7 +591,15 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
591
591
  let sessionId = null;
592
592
  if (chat !== null) {
593
593
  sessionId = chat.getAttribute("data-conversation-session");
594
- if (typeof sessionId !== "string" || !sessions.has(sessionId)) return null;
594
+ if (typeof sessionId !== "string" || sessionId === "") {
595
+ // A pane the host has not labelled yet: fall back to whichever
596
+ // conversation view is mounted, the way the preview path does.
597
+ // The bar used to demand a mounted slot entry here, which is why
598
+ // a selection could go dead after a session switch.
599
+ const active = document.querySelector(CONTENT_SELECTOR + "[data-conversation-session]");
600
+ sessionId = active === null ? null : active.getAttribute("data-conversation-session");
601
+ }
602
+ if (typeof sessionId !== "string" || sessionId === "") return null;
595
603
  } else {
596
604
  const active = document.querySelector(CONTENT_SELECTOR + "[data-conversation-session]");
597
605
  sessionId = active === null ? null : active.getAttribute("data-conversation-session");
@@ -859,18 +867,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
859
867
  sourceDisposer = typeof disposer === "function" ? disposer : () => {};
860
868
  return true;
861
869
  } catch (error) {
862
- console.warn("[ui-quote] reference source registration failed:", error);
863
- return false;
870
+ // "#quote is already registered" is the common case (a second copy of
871
+ // this plugin, or a re-apply): the source object is ours and the codec
872
+ // is stateless, so the resident registration is good enough. Treating
873
+ // it as a failure is what silently downgraded quotes to raw Markdown.
874
+ console.warn("[ui-quote] reference source already registered; reusing it:", error);
875
+ sourceDisposer = () => {};
876
+ return true;
864
877
  }
865
878
  }
866
879
 
867
- /**
868
- * Mint a chip through the conversation input service face: the resident
869
- * shell owns the editor that can place an atomic node. Every step is
870
- * optional — a missing service just falls back to the Markdown form.
871
- */
872
- function insertChip(entry, sessionId, text, comment, code, meta) {
873
- if (!ensureSource() || pluginCtx === null) return false;
880
+ /** The host's resident composer shell for a session, or null when absent. */
881
+ function inputShellFor(sessionId) {
882
+ if (pluginCtx === null || typeof sessionId !== "string" || sessionId === "") return null;
874
883
  let hub = null;
875
884
  try {
876
885
  const conversation = pluginCtx.get("conversation");
@@ -878,14 +887,38 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
878
887
  } catch (error) {
879
888
  hub = null;
880
889
  }
881
- if (hub === null || hub === undefined || typeof hub.shell !== "function") return false;
882
- let shell = null;
890
+ if (hub === null || hub === undefined || typeof hub.shell !== "function") return null;
883
891
  try {
884
- shell = hub.shell(sessionId);
892
+ const shell = hub.shell(sessionId);
893
+ return shell === null || shell === undefined ? null : shell;
885
894
  } catch (error) {
886
- shell = null;
895
+ return null;
887
896
  }
888
- if (shell === null || shell === undefined || typeof shell.insertReference !== "function") return false;
897
+ }
898
+
899
+ /**
900
+ * The face that can put text into a session's draft: the mounted slot
901
+ * entry when there is one, else the host shell's own actions. The bar is
902
+ * document-level, so an action must never depend on a slot entry still
903
+ * being there — the composer outlives the entry across a session switch.
904
+ */
905
+ function insertionFace(sessionId, shell) {
906
+ const entry = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
907
+ if (entry !== undefined && entry !== null) return entry;
908
+ const actions = shell === null || shell === undefined ? null : shell.actions;
909
+ if (actions === null || actions === undefined || typeof actions.captureInsertion !== "function") return null;
910
+ return { actions: () => actions };
911
+ }
912
+
913
+ /**
914
+ * Mint a chip through the conversation input service face: the resident
915
+ * shell owns the editor that can place an atomic node. Every step is
916
+ * optional — a missing service just falls back to the Markdown form.
917
+ */
918
+ function insertChip(entry, sessionId, text, comment, code, meta) {
919
+ if (!ensureSource() || pluginCtx === null) return false;
920
+ const shell = inputShellFor(sessionId);
921
+ if (shell === null || typeof shell.insertReference !== "function") return false;
889
922
  const model = chipRef(text, comment, code, meta);
890
923
  const attachment = {
891
924
  source: SOURCE_NAME,
@@ -1711,22 +1744,28 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1711
1744
  }
1712
1745
 
1713
1746
  function onCapsuleMutations(records) {
1714
- for (const record of records) {
1715
- if (record.type === "characterData") {
1716
- collectRuns(record.target, capsuleDirty);
1717
- collectRefRuns(record.target, refDirty);
1718
- continue;
1719
- }
1720
- for (const node of record.addedNodes) {
1721
- collectRuns(node, capsuleDirty);
1722
- collectRefRuns(node, refDirty);
1723
- }
1724
- for (const node of record.removedNodes) {
1725
- if (node.nodeType === 1 && capsules.has(node)) capsuleDirty.add(node);
1747
+ try {
1748
+ for (const record of records) {
1749
+ if (record.type === "characterData") {
1750
+ collectRuns(record.target, capsuleDirty);
1751
+ collectRefRuns(record.target, refDirty);
1752
+ continue;
1753
+ }
1754
+ for (const node of record.addedNodes) {
1755
+ collectRuns(node, capsuleDirty);
1756
+ collectRefRuns(node, refDirty);
1757
+ }
1758
+ for (const node of record.removedNodes) {
1759
+ if (node.nodeType === 1 && capsules.has(node)) capsuleDirty.add(node);
1760
+ }
1761
+ const target = elementOf(record.target);
1762
+ if (target !== null && capsules.has(target)) capsuleDirty.add(target);
1726
1763
  }
1727
- const target = elementOf(record.target);
1728
- if (target !== null && capsules.has(target)) capsuleDirty.add(target);
1764
+ } catch (error) {
1765
+ console.warn("[ui-quote] capsule scan failed:", error);
1729
1766
  }
1767
+ // Always reschedule: a throw inside the scan used to leave the pass
1768
+ // unscheduled and the transcript raw until the next reload.
1730
1769
  scheduleCapsules();
1731
1770
  }
1732
1771
 
@@ -1737,19 +1776,34 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
1737
1776
 
1738
1777
  function flushCapsules() {
1739
1778
  capsuleTimer = null;
1740
- dropDetachedPopover();
1741
- for (const run of Array.from(capsules.keys())) {
1742
- if (!run.isConnected) dropCapsule(run);
1743
- }
1744
1779
  const runs = Array.from(capsuleDirty);
1745
1780
  capsuleDirty.clear();
1781
+ // A throw anywhere in this pass used to abort it silently: the rest of
1782
+ // the transcript stayed raw (`> ❝ …` text, no pills, no hover cards)
1783
+ // with no error the reader could see. Each stage stands on its own now.
1784
+ try {
1785
+ dropDetachedPopover();
1786
+ for (const run of Array.from(capsules.keys())) {
1787
+ if (!run.isConnected) dropCapsule(run);
1788
+ }
1789
+ } catch (error) {
1790
+ console.warn("[ui-quote] capsule cleanup failed:", error);
1791
+ }
1746
1792
  for (const run of runs) {
1747
- if (run.isConnected) enhance(run);
1748
- else if (capsules.has(run)) dropCapsule(run);
1793
+ try {
1794
+ if (run.isConnected) enhance(run);
1795
+ else if (capsules.has(run)) dropCapsule(run);
1796
+ } catch (error) {
1797
+ console.warn("[ui-quote] capsule enhance failed:", error);
1798
+ }
1799
+ }
1800
+ try {
1801
+ // Badges come last: the quotes they link to must be in the ledger first.
1802
+ flushRefBadges();
1803
+ wireChips();
1804
+ } catch (error) {
1805
+ console.warn("[ui-quote] capsule tail pass failed:", error);
1749
1806
  }
1750
- // Badges come last: the quotes they link to must be in the ledger first.
1751
- flushRefBadges();
1752
- wireChips();
1753
1807
  }
1754
1808
 
1755
1809
  function ensureCapsuleStyle(doc) {
@@ -2018,6 +2072,14 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2018
2072
  * ------------------------------------------------------------------ */
2019
2073
 
2020
2074
  const sessions = new Map();
2075
+ /**
2076
+ * The bar's listeners are document singletons and live exactly as long as
2077
+ * the plugin. While they were reference-counted by the overlay slot they
2078
+ * could be removed for good by an overlapping mount pair, and then the bar
2079
+ * never appeared again until a page reload — the "quote plugin stopped
2080
+ * working" report.
2081
+ */
2082
+ let listenersAttached = false;
2021
2083
  const state = {
2022
2084
  bar: null,
2023
2085
  style: null,
@@ -2040,7 +2102,6 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2040
2102
  /** The quote key being corrected, when the box was opened by 改这条引用. */
2041
2103
  revising: null,
2042
2104
  settle: null,
2043
- listeners: 0,
2044
2105
  pointerOnTranscript: false,
2045
2106
  suppress: false
2046
2107
  };
@@ -2160,6 +2221,29 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2160
2221
  * its wording, not just its comment. Both halves map onto the wire form's
2161
2222
  * two marks, top to bottom.
2162
2223
  */
2224
+ /**
2225
+ * Grow one field to its content. A textarea that is not laid out yet
2226
+ * reports a scrollHeight of 0 — writing that back as `0px` collapsed the
2227
+ * quote half of the box and clipped its first line, so an unmeasurable
2228
+ * field is left on `auto` until the bar is on screen.
2229
+ */
2230
+ function growField(node) {
2231
+ node.style.height = "auto";
2232
+ const next = node.scrollHeight;
2233
+ if (!Number.isFinite(next) || next <= 0) return;
2234
+ node.style.height = Math.min(next, COMMENT_MAX_HEIGHT) + "px";
2235
+ }
2236
+
2237
+ /** Every field the bar is currently showing, re-measured. */
2238
+ function growInputs() {
2239
+ const bar = state.bar;
2240
+ if (bar === null || bar === undefined || typeof bar.querySelectorAll !== "function") return;
2241
+ for (const field of Array.from(bar.querySelectorAll(".dshq-input"))) {
2242
+ if (String(field.tagName).toLowerCase() !== "textarea") continue;
2243
+ growField(field);
2244
+ }
2245
+ }
2246
+
2163
2247
  function renderComment(bar) {
2164
2248
  const doc = bar.ownerDocument;
2165
2249
  bar.setAttribute("role", "dialog");
@@ -2186,10 +2270,6 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2186
2270
  "data-action": "confirm"
2187
2271
  }, [el(doc, "span", { text: translate("addToChat") }), el(doc, "span", { class: "dshq-enter", text: "⏎" })]);
2188
2272
 
2189
- const grow = (node) => {
2190
- node.style.height = "auto";
2191
- node.style.height = Math.min(node.scrollHeight, COMMENT_MAX_HEIGHT) + "px";
2192
- };
2193
2273
  // The quote half is mandatory now (an empty quote is nothing to send);
2194
2274
  // the comment keeps Kimi's rule of being required in this box.
2195
2275
  const syncConfirm = () => {
@@ -2197,12 +2277,12 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2197
2277
  };
2198
2278
  quoteInput.addEventListener("input", () => {
2199
2279
  state.quote = quoteInput.value;
2200
- grow(quoteInput);
2280
+ growField(quoteInput);
2201
2281
  syncConfirm();
2202
2282
  });
2203
2283
  input.addEventListener("input", () => {
2204
2284
  state.comment = input.value;
2205
- grow(input);
2285
+ growField(input);
2206
2286
  syncConfirm();
2207
2287
  });
2208
2288
  const onKey = (event) => {
@@ -2234,7 +2314,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2234
2314
  el(doc, "span", { text: translate("replyToggle") })
2235
2315
  ]);
2236
2316
 
2237
- grow(quoteInput);
2317
+ // No grow here: the fields are measured in `position()`, once the bar
2318
+ // takes part in layout. Reading them while detached returned 0.
2238
2319
  syncConfirm();
2239
2320
 
2240
2321
  bar.replaceChildren(
@@ -2258,6 +2339,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2258
2339
 
2259
2340
  bar.style.visibility = "hidden";
2260
2341
  bar.dataset.visible = "true";
2342
+ // The bar is in layout now (merely invisible), which is the first
2343
+ // moment its fields can be measured — and it has to happen before the
2344
+ // height below is read, or the box would be placed at the wrong place.
2345
+ growInputs();
2261
2346
 
2262
2347
  const viewport = window.visualViewport;
2263
2348
  const width = viewport === undefined || viewport === null ? window.innerWidth : viewport.width;
@@ -2385,10 +2470,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2385
2470
  const revising = state.revising;
2386
2471
  const code = state.code;
2387
2472
  const meta = { path: state.path, reply: state.reply === true };
2388
- const entry = sessionId === null ? null : sessions.get(sessionId);
2473
+ // Resolve the insertion face before the bar is torn down: the slot entry
2474
+ // may already be gone (a session switch) while the composer is still up.
2475
+ const shell = inputShellFor(sessionId);
2476
+ const entry = insertionFace(sessionId, shell);
2389
2477
  const trimmed = typeof comment === "string" ? comment.trim() : "";
2390
2478
  close();
2391
- if (typeof text !== "string" || text.trim() === "" || entry === null || entry === undefined) return;
2479
+ if (typeof text !== "string" || text.trim() === "") return;
2480
+ if (entry === null) {
2481
+ // Returning silently here is what made "clicked and nothing happened"
2482
+ // reports: the user gets told instead.
2483
+ toast(translate("failed"));
2484
+ return;
2485
+ }
2392
2486
 
2393
2487
  // Remember the row this came from before anything can fail: the jump
2394
2488
  // row is keyed by the quote's own wording, so it survives the send.
@@ -2785,6 +2879,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2785
2879
  if (!state.pointerOnTranscript) return;
2786
2880
  state.pointerOnTranscript = false;
2787
2881
  if (insideBar(event.target)) return;
2882
+ // Self-healing: `close()` suppresses the settle-timer refresh so the
2883
+ // gesture that dismissed the bar cannot reopen it. A fresh pointer
2884
+ // gesture is always a fresh intention.
2885
+ state.suppress = false;
2788
2886
  refresh();
2789
2887
  }
2790
2888
 
@@ -2814,12 +2912,16 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2814
2912
  else close();
2815
2913
  }
2816
2914
 
2817
- function attach() {
2818
- if (state.listeners > 0) {
2819
- state.listeners += 1;
2820
- return;
2821
- }
2822
- state.listeners = 1;
2915
+ /**
2916
+ * Install the document listeners. Called once for the plugin's lifetime
2917
+ * from `apply()`, never per slot: a counter kept them alive only while
2918
+ * some overlay happened to be mounted, and one bad count removed them
2919
+ * forever (the bar then stayed dead until a reload).
2920
+ */
2921
+ function attachListeners() {
2922
+ if (listenersAttached) return;
2923
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return;
2924
+ listenersAttached = true;
2823
2925
  document.addEventListener("selectionchange", onSelectionChange);
2824
2926
  document.addEventListener("keyup", onKeyUp, true);
2825
2927
  document.addEventListener("pointerdown", onPointerDown, true);
@@ -2828,13 +2930,14 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2828
2930
  document.addEventListener("mousedown", onMouseDown, true);
2829
2931
  document.addEventListener("keydown", onKeyDown, true);
2830
2932
  document.addEventListener("scroll", onScrollOrResize, true);
2831
- window.addEventListener("resize", onScrollOrResize);
2933
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
2934
+ window.addEventListener("resize", onScrollOrResize);
2935
+ }
2832
2936
  }
2833
2937
 
2834
- function detach() {
2835
- if (state.listeners === 0) return;
2836
- state.listeners -= 1;
2837
- if (state.listeners > 0) return;
2938
+ function detachListeners() {
2939
+ if (!listenersAttached) return;
2940
+ listenersAttached = false;
2838
2941
  document.removeEventListener("selectionchange", onSelectionChange);
2839
2942
  document.removeEventListener("keyup", onKeyUp, true);
2840
2943
  document.removeEventListener("pointerdown", onPointerDown, true);
@@ -2843,8 +2946,12 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2843
2946
  document.removeEventListener("mousedown", onMouseDown, true);
2844
2947
  document.removeEventListener("keydown", onKeyDown, true);
2845
2948
  document.removeEventListener("scroll", onScrollOrResize, true);
2846
- window.removeEventListener("resize", onScrollOrResize);
2949
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
2950
+ window.removeEventListener("resize", onScrollOrResize);
2951
+ }
2847
2952
  close();
2953
+ state.pointerOnTranscript = false;
2954
+ state.suppress = false;
2848
2955
  if (state.bar !== null) {
2849
2956
  state.bar.remove();
2850
2957
  state.bar = null;
@@ -2882,10 +2989,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2882
2989
  }
2883
2990
  };
2884
2991
  sessions.set(sessionId, entry);
2885
- attach();
2886
2992
  return () => {
2887
2993
  if (sessions.get(sessionId) === entry) sessions.delete(sessionId);
2888
- if (sessions.size === 0) detach();
2889
2994
  };
2890
2995
  }, [props.sessionId]);
2891
2996
 
@@ -3649,6 +3754,12 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
3649
3754
  // The sent-message capsule lives outside the composer, so it is
3650
3755
  // installed with the plugin and not with a session's slot entry.
3651
3756
  ctx.effect(installCapsules, "ui-quote: sent-message capsules");
3757
+ // So does the bar: its listeners are document singletons and must not
3758
+ // depend on any composer staying mounted.
3759
+ ctx.effect(() => {
3760
+ attachListeners();
3761
+ return () => detachListeners();
3762
+ }, "ui-quote: selection listeners");
3652
3763
  // The three rows are also three keys. Without the service the menu
3653
3764
  // stays the only way in — nothing here is required to work.
3654
3765
  ctx.effect(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
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 line range, 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",
package/test/capsule.mjs CHANGED
@@ -914,6 +914,21 @@ try {
914
914
  // this block is about what a mounted enhancer does with a revision.
915
915
  capsuleEffect.fn();
916
916
 
917
+ // A textarea measures itself from its own text — but only while it is in the
918
+ // document, the way the real one reads 0 before the bar is laid out. That is
919
+ // what made the auto-grow collapse the quote field.
920
+ const makeTextarea = dom.document.createElement.bind(dom.document);
921
+ dom.document.createElement = (tag) => {
922
+ const node = makeTextarea(tag);
923
+ if (String(tag).toLowerCase() === "textarea") {
924
+ Object.defineProperty(node, "scrollHeight", {
925
+ configurable: true,
926
+ get: () => (node.isConnected ? 12 + 12 * String(node.value ?? "").split("\n").length : 0)
927
+ });
928
+ }
929
+ return node;
930
+ };
931
+
917
932
  // A transcript of its own, with a capsule that has a comment.
918
933
  const host2 = dom.document.createElement("div");
919
934
  host2.setAttribute("data-conversation-content", "");
@@ -948,11 +963,16 @@ try {
948
963
  const input = bar?.querySelector(".dshq-comment-input");
949
964
  check("the box is prefilled with the old quote for editing", quoteBox?.value === original, JSON.stringify(quoteBox?.value));
950
965
  check("the box is prefilled with the old comment", input?.value === oldComment, JSON.stringify(input?.value));
966
+ // Regression: the quote half used to be measured before it was attached, so
967
+ // it was written `height: 0px` and its first line showed up clipped.
968
+ check("the quote field is grown to its text, not collapsed", quoteBox?.style?.height === "24px", JSON.stringify(quoteBox?.style?.height));
969
+ check("the comment field is grown too", input?.style?.height === "24px", JSON.stringify(input?.style?.height));
951
970
 
952
971
  // Correcting it writes a NEW quote (nothing is edited in place) and the old
953
972
  // capsule learns it was superseded.
954
973
  input.value = newComment;
955
974
  input.dispatch("input", {});
975
+ check("typing in a field re-measures it", input?.style?.height === "24px", JSON.stringify(input?.style?.height));
956
976
  // The bar handles clicks by delegation, so the click is dispatched on the bar
957
977
  // with the button as its target — the shim has no bubbling of its own.
958
978
  const confirm = bar.querySelector('[data-action="confirm"]');
@@ -1345,6 +1365,145 @@ try {
1345
1365
  );
1346
1366
  }
1347
1367
 
1368
+ /* ------------------------------------------------------------------ *
1369
+ * 浮层不跟着座位生死 — the bar is document-level, so neither its
1370
+ * listeners nor the action behind one of its rows may depend on an
1371
+ * overlay slot entry still being mounted.
1372
+ *
1373
+ * The listeners used to be reference-counted by the slot component: an
1374
+ * overlapping mount pair (a side thread's composer over a running
1375
+ * parent, a re-registered slot) could drive the count to zero, the
1376
+ * document listeners were then removed for good, and the bar never came
1377
+ * back until a page reload — the "引用插件经常失效" report. A commit
1378
+ * whose slot entry had gone also returned silently.
1379
+ * ------------------------------------------------------------------ */
1380
+
1381
+ {
1382
+ const listenersEffect = effects.find((entry) => String(entry.label).includes("selection listeners"));
1383
+ check("the bar's listeners are installed with the plugin, not with a slot", listenersEffect !== undefined);
1384
+ check(
1385
+ "and they are one document-level set, never a slot-scoped count",
1386
+ (dom.document.listeners.get("pointerup") ?? []).length === 1,
1387
+ JSON.stringify((dom.document.listeners.get("pointerup") ?? []).length)
1388
+ );
1389
+
1390
+ const pane = (sessionId, text) => {
1391
+ const node = dom.document.createElement("div");
1392
+ node.setAttribute("data-conversation-content", "");
1393
+ node.setAttribute("data-conversation-session", sessionId);
1394
+ const bubble = dom.document.createElement("div");
1395
+ bubble.className = "bubble";
1396
+ const run = dom.document.createElement("span");
1397
+ run.className = "plainRun";
1398
+ run.textContent = text;
1399
+ bubble.append(run);
1400
+ node.append(bubble);
1401
+ dom.document.body.append(node);
1402
+ return { node, run };
1403
+ };
1404
+ const selectIn = (run, text) => {
1405
+ dom.document.selection = {
1406
+ isCollapsed: false,
1407
+ rangeCount: 1,
1408
+ toString: () => text,
1409
+ removeAllRanges() {
1410
+ dom.document.selection = null;
1411
+ },
1412
+ getRangeAt: () => ({
1413
+ startContainer: run.firstChild,
1414
+ endContainer: run.firstChild,
1415
+ intersectsNode: () => false,
1416
+ getBoundingClientRect: () => ({ left: 100, top: 300, right: 220, bottom: 318, width: 120, height: 18 })
1417
+ })
1418
+ };
1419
+ dom.document.dispatch("pointerdown", { target: run.firstChild });
1420
+ dom.document.dispatch("pointerup", { target: run.firstChild });
1421
+ };
1422
+ const barOf = () => dom.document.getElementById("dsh-quote-bar");
1423
+
1424
+ // 1. The composer slot never mounted for this session at all.
1425
+ const firstPane = pane("session-bar", "浮层要跟着手走的原文");
1426
+ selectIn(firstPane.run, "浮层要跟着手走的原文");
1427
+ check(
1428
+ "a selection opens the bar even with no composer slot mounted for that session",
1429
+ barOf()?.dataset.visible === "true",
1430
+ JSON.stringify(barOf()?.dataset.visible)
1431
+ );
1432
+ check(
1433
+ "the bar offers the actions",
1434
+ barOf()?.querySelector('[data-action="add"]') !== null && barOf()?.querySelector('[data-action="comment"]') !== null
1435
+ );
1436
+
1437
+ // 2. A slot comes and goes — the listeners are not tied to it.
1438
+ const overlay = registered.find((entry) => entry.spec.name === "conversation.input.overlay");
1439
+ check("the overlay slot is registered for mounting", overlay !== undefined);
1440
+ dom.document.dispatch("keydown", { key: "Escape", isComposing: false, target: firstPane.run });
1441
+ check("Escape closes the bar", barOf()?.dataset.visible === "false", JSON.stringify(barOf()?.dataset.visible));
1442
+ if (overlay !== undefined) {
1443
+ overlay.Component({ sessionId: "session-bar", inputActions: { captureInsertion: () => ({ draftRev: 1 }) } });
1444
+ overlay.Component({ sessionId: "session-bar-2", inputActions: { captureInsertion: () => ({ draftRev: 2 }) } });
1445
+ }
1446
+ check(
1447
+ "mounting two composers does not stack or drop the listeners",
1448
+ (dom.document.listeners.get("pointerup") ?? []).length === 1,
1449
+ JSON.stringify((dom.document.listeners.get("pointerup") ?? []).length)
1450
+ );
1451
+ selectIn(firstPane.run, "浮层要跟着手走的原文");
1452
+ check("the bar still opens after a slot mounted", barOf()?.dataset.visible === "true", JSON.stringify(barOf()?.dataset.visible));
1453
+
1454
+ // 3. A session with no slot entry still lands its chip: the composer's own
1455
+ // actions stand in for the entry that is not there.
1456
+ const landed = [];
1457
+ shells.set("session-noslot", {
1458
+ actions: { captureInsertion: () => ({ draftRev: 7 }) },
1459
+ insertReference(attachment) {
1460
+ landed.push(attachment);
1461
+ return true;
1462
+ }
1463
+ });
1464
+ const secondPane = pane("session-noslot", "没有座位的会话也要能引用");
1465
+ selectIn(secondPane.run, "没有座位的会话也要能引用");
1466
+ barOf().dispatch("click", { target: barOf().querySelector('[data-action="add"]') });
1467
+ check(
1468
+ "the chip lands through the composer's own actions",
1469
+ landed.length === 1 && String(landed[0]?.ref ?? "").includes("没有座位的会话也要能引用"),
1470
+ JSON.stringify([landed.length, String(landed[0]?.ref ?? "").slice(0, 24)])
1471
+ );
1472
+
1473
+ // 4. With nowhere to insert, the reader is told instead of left wondering.
1474
+ const thirdPane = pane("session-noshell", "没有输入框的会话");
1475
+ selectIn(thirdPane.run, "没有输入框的会话");
1476
+ const staleToast = dom.document.getElementById("dsh-quote-toast");
1477
+ if (staleToast !== null) staleToast.textContent = "";
1478
+ barOf().dispatch("click", { target: barOf().querySelector('[data-action="add"]') });
1479
+ const toastNode = dom.document.getElementById("dsh-quote-toast");
1480
+ check(
1481
+ "a dead end says so instead of doing nothing",
1482
+ String(toastNode?.textContent) === "插入失败,请重试",
1483
+ JSON.stringify(toastNode?.textContent)
1484
+ );
1485
+
1486
+ // 5. Disposal is honest: the effect takes its listeners with it, and
1487
+ // reinstalling puts exactly one set back.
1488
+ listenersEffect.dispose();
1489
+ check(
1490
+ "disposing the effect removes the listeners",
1491
+ (dom.document.listeners.get("pointerup") ?? []).length === 0,
1492
+ JSON.stringify((dom.document.listeners.get("pointerup") ?? []).length)
1493
+ );
1494
+ listenersEffect.fn();
1495
+ check(
1496
+ "reinstalling brings back exactly one set",
1497
+ (dom.document.listeners.get("pointerup") ?? []).length === 1,
1498
+ JSON.stringify((dom.document.listeners.get("pointerup") ?? []).length)
1499
+ );
1500
+ selectIn(firstPane.run, "浮层要跟着手走的原文");
1501
+ check("the bar works again after a reinstall", barOf()?.dataset.visible === "true", JSON.stringify(barOf()?.dataset.visible));
1502
+
1503
+ dom.document.selection = null;
1504
+ dom.document.dispatch("keydown", { key: "Escape", isComposing: false, target: firstPane.run });
1505
+ }
1506
+
1348
1507
  console.log(
1349
1508
  failures.length === 0
1350
1509
  ? "\ndsh-client-ui-quote capsule: " + passed + " checks passed"
package/test/dom.mjs CHANGED
@@ -31,6 +31,10 @@ class ShimNode {
31
31
  return node.nodeType === 9 || node === this.ownerDocument?.documentElement;
32
32
  }
33
33
 
34
+ get firstChild() {
35
+ return this.childNodes.length === 0 ? null : this.childNodes[0];
36
+ }
37
+
34
38
  get lastChild() {
35
39
  return this.childNodes.length === 0 ? null : this.childNodes[this.childNodes.length - 1];
36
40
  }
@@ -306,6 +310,10 @@ class ShimDocument extends ShimNode {
306
310
  this.ownerDocument = this;
307
311
  this.mutations = [];
308
312
  this.observers = [];
313
+ /** Document-level listeners (the bar's own are installed here). */
314
+ this.listeners = new Map();
315
+ /** Set by a test to hand `document.getSelection()` a live selection. */
316
+ this.selection = null;
309
317
  /** Maintained by `ShimElement.focus()` / `blur()`. */
310
318
  this.activeElement = null;
311
319
  this.documentElement = new ShimElement(this, 'html');
@@ -357,12 +365,28 @@ class ShimDocument extends ShimNode {
357
365
  return found;
358
366
  }
359
367
 
360
- addEventListener() {}
368
+ addEventListener(type, handler) {
369
+ // The bar's listeners are document singletons; the harness has to keep
370
+ // them so a test can drive a selection the way a browser would.
371
+ const list = this.listeners.get(type) ?? [];
372
+ list.push(handler);
373
+ this.listeners.set(type, list);
374
+ }
375
+
376
+ removeEventListener(type, handler) {
377
+ const list = this.listeners.get(type) ?? [];
378
+ const index = list.indexOf(handler);
379
+ if (index !== -1) list.splice(index, 1);
380
+ }
361
381
 
362
- removeEventListener() {}
382
+ dispatch(type, event) {
383
+ for (const handler of [...(this.listeners.get(type) ?? [])]) handler(event);
384
+ }
363
385
 
364
386
  getSelection() {
365
- // A collapsed selection that can be cleared, which is all the bar asks of it.
387
+ // A collapsed selection that can be cleared, which is all the bar asks of
388
+ // it — unless a test installs one with `document.selection = …`.
389
+ if (this.selection !== null && this.selection !== undefined) return this.selection;
366
390
  return { isCollapsed: true, rangeCount: 0, removeAllRanges() {} };
367
391
  }
368
392
  }