dsh-client-ui-quote 0.11.2 → 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.10.0 起上架;每个 `v*` 标签由下面的 workflow 自动发布,本仓库当前 0.11.2)。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 293 项断言会先在 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. **工程化**:293 项离线断言(含真跑 `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 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章,197 项断言)
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
- 修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 197 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
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
- 合计 **293 项**(check.mjs 的契约断言 + capsule 197 + 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
  };
@@ -2409,10 +2470,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2409
2470
  const revising = state.revising;
2410
2471
  const code = state.code;
2411
2472
  const meta = { path: state.path, reply: state.reply === true };
2412
- 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);
2413
2477
  const trimmed = typeof comment === "string" ? comment.trim() : "";
2414
2478
  close();
2415
- 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
+ }
2416
2486
 
2417
2487
  // Remember the row this came from before anything can fail: the jump
2418
2488
  // row is keyed by the quote's own wording, so it survives the send.
@@ -2809,6 +2879,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2809
2879
  if (!state.pointerOnTranscript) return;
2810
2880
  state.pointerOnTranscript = false;
2811
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;
2812
2886
  refresh();
2813
2887
  }
2814
2888
 
@@ -2838,12 +2912,16 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2838
2912
  else close();
2839
2913
  }
2840
2914
 
2841
- function attach() {
2842
- if (state.listeners > 0) {
2843
- state.listeners += 1;
2844
- return;
2845
- }
2846
- 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;
2847
2925
  document.addEventListener("selectionchange", onSelectionChange);
2848
2926
  document.addEventListener("keyup", onKeyUp, true);
2849
2927
  document.addEventListener("pointerdown", onPointerDown, true);
@@ -2852,13 +2930,14 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2852
2930
  document.addEventListener("mousedown", onMouseDown, true);
2853
2931
  document.addEventListener("keydown", onKeyDown, true);
2854
2932
  document.addEventListener("scroll", onScrollOrResize, true);
2855
- window.addEventListener("resize", onScrollOrResize);
2933
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
2934
+ window.addEventListener("resize", onScrollOrResize);
2935
+ }
2856
2936
  }
2857
2937
 
2858
- function detach() {
2859
- if (state.listeners === 0) return;
2860
- state.listeners -= 1;
2861
- if (state.listeners > 0) return;
2938
+ function detachListeners() {
2939
+ if (!listenersAttached) return;
2940
+ listenersAttached = false;
2862
2941
  document.removeEventListener("selectionchange", onSelectionChange);
2863
2942
  document.removeEventListener("keyup", onKeyUp, true);
2864
2943
  document.removeEventListener("pointerdown", onPointerDown, true);
@@ -2867,8 +2946,12 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2867
2946
  document.removeEventListener("mousedown", onMouseDown, true);
2868
2947
  document.removeEventListener("keydown", onKeyDown, true);
2869
2948
  document.removeEventListener("scroll", onScrollOrResize, true);
2870
- window.removeEventListener("resize", onScrollOrResize);
2949
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
2950
+ window.removeEventListener("resize", onScrollOrResize);
2951
+ }
2871
2952
  close();
2953
+ state.pointerOnTranscript = false;
2954
+ state.suppress = false;
2872
2955
  if (state.bar !== null) {
2873
2956
  state.bar.remove();
2874
2957
  state.bar = null;
@@ -2906,10 +2989,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
2906
2989
  }
2907
2990
  };
2908
2991
  sessions.set(sessionId, entry);
2909
- attach();
2910
2992
  return () => {
2911
2993
  if (sessions.get(sessionId) === entry) sessions.delete(sessionId);
2912
- if (sessions.size === 0) detach();
2913
2994
  };
2914
2995
  }, [props.sessionId]);
2915
2996
 
@@ -3673,6 +3754,12 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
3673
3754
  // The sent-message capsule lives outside the composer, so it is
3674
3755
  // installed with the plugin and not with a session's slot entry.
3675
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");
3676
3763
  // The three rows are also three keys. Without the service the menu
3677
3764
  // stays the only way in — nothing here is required to work.
3678
3765
  ctx.effect(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.11.2",
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
@@ -1365,6 +1365,145 @@ try {
1365
1365
  );
1366
1366
  }
1367
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
+
1368
1507
  console.log(
1369
1508
  failures.length === 0
1370
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
  }