dsh-select-quote 0.3.6 → 0.3.7

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.
Files changed (3) hide show
  1. package/README.md +6 -4
  2. package/lib/client.js +454 -226
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,9 +38,9 @@ dsh web --patch "$PWD/dev.patch.yml"
38
38
 
39
39
  ## 用法
40
40
 
41
- 1. 在对话区选中一段文本,点「添加到任务」
42
- 2. 在弹出的评论框里写一句想说的话(可留空,只划不评),`⌘/Ctrl + Enter` 确认
43
- 3. 输入框上方出现批注卡片:悬停展开列表,可逐条编辑、删除或一键清空,点条目跳回正文对应文字
41
+ 1. 在对话区选中一段文本,点「添加到任务」——到这一步批注就已经保存,输入框上方同步出现摘要卡片
42
+ 2. 想补充的话在弹出的评论框里写一句,点「添加评论」或 `⌘/Ctrl + Enter`;不写就直接点空白处或「暂不评论」,批注同样保留
43
+ 3. 悬停摘要卡片展开列表,可逐条编辑、删除或一键清空,点条目跳回正文对应文字
44
44
  4. 正常打字发送,批注随消息一起交给模型,卡片随之消失
45
45
  5. 模型回答里引用某条批注的位置显示为可点击 chip
46
46
 
@@ -69,7 +69,9 @@ dsh web --patch "$PWD/dev.patch.yml"
69
69
 
70
70
  **升级 dsh 之后功能不对?** 先 `pnpm run check`;产物没问题就对照 [`docs/internals.md`](docs/internals.md) 的「依赖的产品内部约定」逐条复核,那里列了所有产品内部名。
71
71
 
72
- **划词没有工具条?** 确认选区在对话区而不是编辑器里;刷新页面(样式与观察者是幂等注入的);宿主 boot 日志里应出现 `dsh-select-quote`。
72
+ **划词没有工具条?** 确认选区在对话区而不是编辑器里;刷新页面(样式与观察者是幂等注入的);宿主 boot 日志里应出现 `dsh-select-quote`。工具条挂在对话滚动区里,随正文一起滚动,滚到顶部会像正文一样滑进 header 下面;选中行完全滚出视口后收起,滚回去重新出现。
73
+
74
+ **点空白处批注会丢吗?** 不会。点「添加到任务」时批注已经保存,空白点击只关闭评论框;已经写了一半的评论会一并保存到那条批注上。
73
75
 
74
76
  **输入框里出现了协议原文?** 发送前的注入没生效,一般是 composer 结构变了——检查 `[data-composer-card]` 是否还在。
75
77
 
package/lib/client.js CHANGED
@@ -5,8 +5,8 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
- let react_jsx_runtime = require("react/jsx-runtime");
9
8
  let react_dom = require("react-dom");
9
+ let react_jsx_runtime = require("react/jsx-runtime");
10
10
  //#region src/client/dom/composer-host.ts
11
11
  /**
12
12
  * Host DOM contract for the resident composer.
@@ -138,6 +138,28 @@ ${atomRules}
138
138
  }
139
139
  }
140
140
 
141
+ /* Same entrance for the below-anchor variant (only the content top uses it). */
142
+ @keyframes dsq-toolbar-in-below {
143
+ from {
144
+ transform: translate(-50%, 0) translateY(-4px) scale(0.96);
145
+ }
146
+ to {
147
+ transform: translate(-50%, 0) translateY(0) scale(1);
148
+ }
149
+ }
150
+
151
+ /* Comment phase: the hint sits above the popover's top-left corner (explicit
152
+ top-left placement comes from placeCommentPop), so the entrance no longer
153
+ owns the centering shift. */
154
+ @keyframes dsq-toolbar-in-popover-top {
155
+ from {
156
+ transform: translateY(4px) scale(0.96);
157
+ }
158
+ to {
159
+ transform: translateY(0) scale(1);
160
+ }
161
+ }
162
+
141
163
  /* 内容层的 rise:悬停面板的列表在用;工具条改用容器 scale 后这条在这里待命。 */
142
164
  @keyframes dsq-rise-in {
143
165
  from {
@@ -184,10 +206,35 @@ ${atomRules}
184
206
  --dsq-shadow: var(--dsw-elevation-prominent, 0 0 0 .5px rgba(0, 0, 0, 0.04), 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
185
207
  }
186
208
 
209
+ /* 浮层挂载与层带(2026-10-20 定,改前先读):
210
+ 工具条/评论泡/状态不再 portal 到 body,而是由 dom/floating-layer.ts 挂进对话
211
+ 真正的滚动容器(桌面抽屉布局是 ST7X_W_scrollBody,普通布局是 .icaHSq_scroll)
212
+ 里的一个 absolute host,坐标用**内容坐标**。
213
+
214
+ 为什么不是 body:header(对话标题那一排)是滚动容器之外的非定位元素,任何
215
+ body 级 positioned + z-index 的浮层都天然压过它(这就是 z-index 降到 0 也仍在
216
+ header 之上的原因);composer 座位是 sticky + z-index 7,只有低于它才不会被
217
+ 输入框穿透。
218
+
219
+ 现在把两件事交回宿主布局:
220
+ - 滚动容器负责裁剪:浮层随内容上移,到容器上边缘就被裁掉,自然滑进 header
221
+ 下面,和正文一致;
222
+ - host 的 z-index 固定 6(见 floating-layer.ts):低于 composer 的 7,高于
223
+ 内容里吸顶元素的 6(同 z 时 host 追加在滚动容器末尾,DOM 次序压过它们)。
224
+ host 内部次序:status 3 > toolbar 2 > commentPop 1。
225
+
226
+ 摘要行与批注卡片在内容普通流里,不参与这套层带;悬停面板仍是 body 级 fixed
227
+ (原因见 AnnotationCardView 里 contain:layout 的说明)。 */
228
+
187
229
  .dsq_toolbar {
188
- position: fixed;
189
- z-index: 10060;
190
- transform: translate(-50%, -100%);
230
+ position: absolute;
231
+ z-index: 2;
232
+ /* The layer host is 0×0, so an auto-width absolute child would shrink to
233
+ min-content (Chinese hint text then wraps one character per line).
234
+ max-content keeps the bubble sized by its row regardless of the host. */
235
+ width: max-content;
236
+ max-width: calc(100vw - 24px);
237
+ transform: translate(-50%, var(--dsq-anchor-y, -100%));
191
238
  gap: 2px;
192
239
  padding: 4px;
193
240
  /* 无边框:--dsq-shadow(宿主的 elevation-prominent)自带 0 0 0 .5px 的描边环,
@@ -202,6 +249,18 @@ ${atomRules}
202
249
  animation: dsq-toolbar-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
203
250
  }
204
251
 
252
+ /* Hangs below the selection: no room above at the very top of the transcript. */
253
+ .dsq_toolbar[data-anchor='below'] {
254
+ --dsq-anchor-y: 0%;
255
+ animation-name: dsq-toolbar-in-below;
256
+ }
257
+
258
+ /* Comment phase: placed at an explicit top-left, 6px above the comment popover. */
259
+ .dsq_toolbar[data-anchor='popover-top'] {
260
+ transform: none;
261
+ animation-name: dsq-toolbar-in-popover-top;
262
+ }
263
+
205
264
  /* 按钮行:只负责布局;入场动画在容器上(见 .dsq_toolbar 的 A/B 说明)。 */
206
265
  .dsq_toolbarInner {
207
266
  display: inline-flex;
@@ -248,8 +307,8 @@ ${atomRules}
248
307
  }
249
308
 
250
309
  .dsq_status {
251
- position: fixed;
252
- z-index: 10061;
310
+ position: absolute;
311
+ z-index: 3;
253
312
  transform: translate(-50%, 8px);
254
313
  padding: 4px 10px;
255
314
  border-radius: 999px;
@@ -266,7 +325,7 @@ ${atomRules}
266
325
  position: absolute;
267
326
  top: 8px;
268
327
  left: 8px;
269
- z-index: 40;
328
+ z-index: 12;
270
329
  width: 20%;
271
330
  min-width: 148px;
272
331
  max-width: 220px;
@@ -406,7 +465,7 @@ ${atomRules}
406
465
  /* Qoder-style annotation summary (“N 条批注”) inside the composer. */
407
466
  .dsq_summary {
408
467
  position: relative;
409
- z-index: 10040;
468
+ z-index: 13;
410
469
  gap: 10px;
411
470
  width: 100%;
412
471
  max-width: 100%;
@@ -450,7 +509,7 @@ ${atomRules}
450
509
  flex atoms — the open class below turns display back on. */
451
510
  display: none;
452
511
  position: fixed;
453
- z-index: 10050;
512
+ z-index: 14;
454
513
  width: min(320px, calc(100vw - 16px));
455
514
  flex-direction: column;
456
515
  padding: 12px;
@@ -671,15 +730,19 @@ ${atomRules}
671
730
 
672
731
  /* Inline comment composer under the floating selection toolbar. */
673
732
  .dsq_commentPop {
674
- position: fixed;
675
- z-index: 10050;
676
- transform: translate(-50%, 4px);
733
+ position: absolute;
734
+ z-index: 1;
735
+ /* Vertical offset comes from placeCommentPop (below the selection); only
736
+ the horizontal centering belongs to the transform. */
737
+ transform: translateX(-50%);
677
738
  width: min(320px, calc(100vw - 24px));
678
739
  gap: 8px;
679
740
  padding: 8px;
680
741
  border-radius: 16px;
681
742
  /* 底色与模糊都在材质图层里 */
682
743
  box-shadow: var(--dsq-shadow);
744
+ /* The layer host is pointer-events:none; interactive surfaces opt back in. */
745
+ pointer-events: auto;
683
746
  }
684
747
 
685
748
  .dsq_commentInput {
@@ -849,6 +912,7 @@ ${atomRules}
849
912
  .dsq_annMark {
850
913
  --dsq-mark-color: var(--dsq-accent);
851
914
  --dsq-mark-tint: var(--dsq-accent-tint);
915
+ position: relative;
852
916
  background: var(--dsq-mark-tint);
853
917
  text-decoration: underline;
854
918
  text-decoration-style: dashed;
@@ -862,10 +926,17 @@ ${atomRules}
862
926
 
863
927
  /* Ordinal as a superscript corner badge at the END of the marked text, so it
864
928
  never sits between the reader and the sentence. Only the last fragment of a
865
- multi-fragment mark carries the index, so the number appears exactly once. */
929
+ multi-fragment mark carries the index, so the number appears exactly once.
930
+ Absolute so the badge adds no inline advance width: annotating never reflows
931
+ the paragraph or moves following words. Bottom (not top) anchors to the last
932
+ fragment: for a mark spanning several lines the containing block is the union
933
+ of the first and last fragments, so its bottom edge is the last line. */
866
934
  .dsq_annMark[data-dsq-ann-index]::after {
867
935
  content: attr(data-dsq-ann-index);
868
- display: inline-grid;
936
+ position: absolute;
937
+ bottom: 8px;
938
+ left: 100%;
939
+ display: grid;
869
940
  place-items: center;
870
941
  min-width: 15px;
871
942
  height: 15px;
@@ -877,7 +948,7 @@ ${atomRules}
877
948
  font-size: 10px;
878
949
  font-weight: 600;
879
950
  line-height: 1;
880
- vertical-align: super;
951
+ pointer-events: none;
881
952
  }
882
953
 
883
954
  .dsq_annMark:hover {
@@ -1163,6 +1234,12 @@ function createMark(job, withIndex) {
1163
1234
  * selections that cross inline elements or paragraph boundaries never reorder
1164
1235
  * the DOM; fragments of one annotation share the mark id, and the ordinal
1165
1236
  * badge rides on the final fragment (end of the annotation).
1237
+ *
1238
+ * `surroundContents` is load-bearing: `extractContents()` leaves the text
1239
+ * before **and after** the range in the same node, so inserting the mark at
1240
+ * `node.nextSibling` (the old implementation) appended every partial selection
1241
+ * to the end of its parent — a selection starting at offset 0, such as the
1242
+ * sentence after a colon, visibly jumped to the paragraph's end.
1166
1243
  */
1167
1244
  function wrapRange(item, job, from, to) {
1168
1245
  const located = cleanTextRange(item, from, to);
@@ -1172,15 +1249,12 @@ function wrapRange(item, job, from, to) {
1172
1249
  let wrapped = false;
1173
1250
  pieces.forEach((piece, i) => {
1174
1251
  const node = piece.entry.node;
1175
- const parent = node.parentElement;
1176
- if (!parent) return;
1252
+ if (!node.parentElement) return;
1177
1253
  const range = document.createRange();
1178
1254
  range.setStart(node, piece.start - piece.entry.start);
1179
1255
  range.setEnd(node, piece.end - piece.entry.start);
1180
1256
  const mark = createMark(job, i === pieces.length - 1);
1181
- mark.append(range.extractContents());
1182
- parent.insertBefore(mark, node.nextSibling);
1183
- if (node.nodeValue === "") node.remove();
1257
+ range.surroundContents(mark);
1184
1258
  wrapped = true;
1185
1259
  });
1186
1260
  return wrapped;
@@ -1715,20 +1789,24 @@ const GAP = 8;
1715
1789
  const MARGIN = 8;
1716
1790
  /**
1717
1791
  * Place `panel` near `anchor`, flipping vertical side when the preferred side
1718
- * lacks room. Keeps the panel inside the viewport on the horizontal axis.
1792
+ * lacks room. Keeps the panel inside `bounds` (the viewport by default).
1719
1793
  */
1720
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP) {
1721
- const vw = window.innerWidth;
1722
- const vh = window.innerHeight;
1723
- const spaceBelow = vh - anchor.bottom;
1724
- const spaceAbove = anchor.top;
1794
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP, bounds) {
1795
+ const box = bounds ?? {
1796
+ top: 0,
1797
+ left: 0,
1798
+ right: window.innerWidth,
1799
+ bottom: window.innerHeight
1800
+ };
1801
+ const spaceBelow = box.bottom - anchor.bottom;
1802
+ const spaceAbove = anchor.top - box.top;
1725
1803
  let side = preferred;
1726
1804
  const need = panel.height + gap;
1727
1805
  if (preferred === "bottom" && spaceBelow < need && spaceAbove > spaceBelow) side = "top";
1728
1806
  else if (preferred === "top" && spaceAbove < need && spaceBelow > spaceAbove) side = "bottom";
1729
1807
  return {
1730
- top: side === "bottom" ? Math.min(anchor.bottom + gap, vh - panel.height - MARGIN) : Math.max(anchor.top - panel.height - gap, MARGIN),
1731
- left: Math.min(Math.max(anchor.left, MARGIN), Math.max(MARGIN, vw - panel.width - MARGIN)),
1808
+ top: side === "bottom" ? Math.min(anchor.bottom + gap, box.bottom - panel.height - MARGIN) : Math.max(anchor.top - panel.height - gap, box.top + MARGIN),
1809
+ left: Math.min(Math.max(anchor.left, box.left + MARGIN), Math.max(box.left + MARGIN, box.right - panel.width - MARGIN)),
1732
1810
  side
1733
1811
  };
1734
1812
  }
@@ -1746,78 +1824,32 @@ const SVG_ATTRS = {
1746
1824
  focusable: false
1747
1825
  };
1748
1826
  const SHAPES = {
1749
- copy: (0, react_jsx_runtime.jsxs)("g", { children: [(0, react_jsx_runtime.jsx)("rect", {
1750
- key: "r",
1827
+ copy: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
1751
1828
  width: 14,
1752
1829
  height: 14,
1753
1830
  x: 8,
1754
1831
  y: 8,
1755
1832
  rx: 2,
1756
1833
  ry: 2
1757
- }), (0, react_jsx_runtime.jsx)("path", {
1758
- key: "p",
1759
- d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
1760
- })] }),
1761
- pencil: (0, react_jsx_runtime.jsxs)("g", { children: [(0, react_jsx_runtime.jsx)("path", {
1762
- key: "p1",
1763
- d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
1764
- }), (0, react_jsx_runtime.jsx)("path", {
1765
- key: "p2",
1766
- d: "m15 5 4 4"
1767
- })] }),
1768
- "trash-2": (0, react_jsx_runtime.jsxs)("g", { children: [
1769
- (0, react_jsx_runtime.jsx)("path", {
1770
- key: "p1",
1771
- d: "M10 11v6"
1772
- }),
1773
- (0, react_jsx_runtime.jsx)("path", {
1774
- key: "p2",
1775
- d: "M14 11v6"
1776
- }),
1777
- (0, react_jsx_runtime.jsx)("path", {
1778
- key: "p3",
1779
- d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
1780
- }),
1781
- (0, react_jsx_runtime.jsx)("path", {
1782
- key: "p4",
1783
- d: "M3 6h18"
1784
- }),
1785
- (0, react_jsx_runtime.jsx)("path", {
1786
- key: "p5",
1787
- d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
1788
- })
1834
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" })] }),
1835
+ pencil: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m15 5 4 4" })] }),
1836
+ "trash-2": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
1837
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 11v6" }),
1838
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 11v6" }),
1839
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }),
1840
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
1841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" })
1789
1842
  ] }),
1790
- x: (0, react_jsx_runtime.jsxs)("g", { children: [(0, react_jsx_runtime.jsx)("path", {
1791
- key: "p1",
1792
- d: "M18 6 6 18"
1793
- }), (0, react_jsx_runtime.jsx)("path", {
1794
- key: "p2",
1795
- d: "m6 6 12 12"
1796
- })] }),
1797
- plus: (0, react_jsx_runtime.jsxs)("g", { children: [(0, react_jsx_runtime.jsx)("path", {
1798
- key: "p1",
1799
- d: "M5 12h14"
1800
- }), (0, react_jsx_runtime.jsx)("path", {
1801
- key: "p2",
1802
- d: "M12 5v14"
1803
- })] }),
1804
- "external-link": (0, react_jsx_runtime.jsxs)("g", { children: [
1805
- (0, react_jsx_runtime.jsx)("path", {
1806
- key: "p1",
1807
- d: "M15 3h6v6"
1808
- }),
1809
- (0, react_jsx_runtime.jsx)("path", {
1810
- key: "p2",
1811
- d: "M10 14 21 3"
1812
- }),
1813
- (0, react_jsx_runtime.jsx)("path", {
1814
- key: "p3",
1815
- d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
1816
- })
1843
+ x: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })] }),
1844
+ plus: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })] }),
1845
+ "external-link": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
1846
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15 3h6v6" }),
1847
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 14 21 3" }),
1848
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" })
1817
1849
  ] })
1818
1850
  };
1819
1851
  function LucideIcon({ name, size = 15 }) {
1820
- return (0, react_jsx_runtime.jsx)("svg", {
1852
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1821
1853
  ...SVG_ATTRS,
1822
1854
  width: size,
1823
1855
  height: size,
@@ -2029,23 +2061,23 @@ function AnnotationCardView({ title, text, comment, variant, index, onRemove, on
2029
2061
  useLocale();
2030
2062
  const composer = variant === "composer";
2031
2063
  const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
2032
- return (0, react_jsx_runtime.jsxs)("div", {
2064
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2033
2065
  className: cx(A.box, A.row, styles.card, !composer && styles.tCard),
2034
2066
  role: "group",
2035
2067
  "aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
2036
2068
  children: [
2037
- (0, react_jsx_runtime.jsx)("div", {
2069
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2038
2070
  className: cx(A.keep, A.gridMid, styles.cardIcon, !composer && styles.tCardIcon),
2039
- "aria-hidden": true,
2071
+ "aria-hidden": "true",
2040
2072
  children: index ? String(index) : "AI"
2041
2073
  }),
2042
- (0, react_jsx_runtime.jsxs)("div", {
2074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2043
2075
  className: cx(A.grow, A.col, styles.cardBody),
2044
- children: [(0, react_jsx_runtime.jsx)("div", {
2076
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2045
2077
  className: cx(A.ell, styles.cardTitle),
2046
2078
  title: text,
2047
2079
  children: title
2048
- }), (0, react_jsx_runtime.jsx)("div", {
2080
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2049
2081
  className: cx(A.ell, styles.cardSubtitle),
2050
2082
  ...comment ? {
2051
2083
  title: comment,
@@ -2054,14 +2086,14 @@ function AnnotationCardView({ title, text, comment, variant, index, onRemove, on
2054
2086
  children: subtitle
2055
2087
  })]
2056
2088
  }),
2057
- composer && onEditComment ? (0, react_jsx_runtime.jsx)("button", {
2089
+ composer && onEditComment ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2058
2090
  type: "button",
2059
2091
  className: cx(A.plain, A.gridMid, styles.cardAction),
2060
2092
  "aria-label": t("card.editComment"),
2061
2093
  onClick: onEditComment,
2062
2094
  children: "✎"
2063
2095
  }) : null,
2064
- composer && onRemove ? (0, react_jsx_runtime.jsx)("button", {
2096
+ composer && onRemove ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2065
2097
  type: "button",
2066
2098
  className: cx(A.plain, A.gridMid, styles.cardClose),
2067
2099
  "aria-label": t("card.remove"),
@@ -2239,62 +2271,62 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2239
2271
  editDraft,
2240
2272
  onItemEdit
2241
2273
  ]);
2242
- const panel = open ? (0, react_jsx_runtime.jsx)("div", {
2274
+ const panel = open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2243
2275
  ref: popRef,
2244
2276
  className: `${styles.summaryHover} ${styles.summaryHoverOpen}`,
2245
2277
  role: "dialog",
2246
2278
  "aria-label": t("summary.dialogAria", { count }),
2247
2279
  onPointerEnter: openPop,
2248
2280
  onPointerLeave,
2249
- children: (0, react_jsx_runtime.jsx)("div", {
2281
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2250
2282
  className: cx(A.col, styles.summaryHoverInner),
2251
- children: (0, react_jsx_runtime.jsx)("div", {
2283
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2252
2284
  className: cx(A.col, styles.summaryList),
2253
2285
  children: items.map((item, i) => {
2254
2286
  const isEditing = editing === i;
2255
- return (0, react_jsx_runtime.jsxs)("div", {
2287
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2256
2288
  className: styles.summaryItem,
2257
- children: [(0, react_jsx_runtime.jsxs)("div", {
2289
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2258
2290
  className: cx(A.row, styles.summaryItemHead),
2259
2291
  children: [
2260
- (0, react_jsx_runtime.jsx)("div", {
2292
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2261
2293
  className: cx(A.keep, A.gridMid, styles.summaryItemBadge),
2262
- "aria-hidden": true,
2294
+ "aria-hidden": "true",
2263
2295
  children: String(i + 1)
2264
2296
  }),
2265
- (0, react_jsx_runtime.jsx)("div", {
2297
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2266
2298
  className: styles.summaryItemLabel,
2267
2299
  children: t("common.selectedText")
2268
2300
  }),
2269
- !isEditing && (onItemRemove || onItemEdit) ? (0, react_jsx_runtime.jsxs)("div", {
2301
+ !isEditing && (onItemRemove || onItemEdit) ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2270
2302
  className: cx(A.row, styles.summaryItemActions),
2271
- children: [onItemEdit ? (0, react_jsx_runtime.jsx)("button", {
2303
+ children: [onItemEdit ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2272
2304
  type: "button",
2273
2305
  className: cx(A.plain, A.gridMid, styles.summaryItemAction),
2274
2306
  "aria-label": t("summary.edit", { index: i + 1 }),
2275
2307
  onClick: () => startEdit(i),
2276
- children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
2277
- }) : null, onItemRemove ? (0, react_jsx_runtime.jsx)("button", {
2308
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
2309
+ }) : null, onItemRemove ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2278
2310
  type: "button",
2279
2311
  className: cx(A.plain, A.gridMid, styles.summaryItemAction),
2280
2312
  "aria-label": t("summary.remove", { index: i + 1 }),
2281
2313
  onClick: () => onItemRemove(i, item),
2282
- children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
2314
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
2283
2315
  }) : null]
2284
2316
  }) : null
2285
2317
  ]
2286
- }), isEditing ? (0, react_jsx_runtime.jsxs)("div", {
2318
+ }), isEditing ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2287
2319
  className: styles.summaryItemMain,
2288
2320
  children: [
2289
- (0, react_jsx_runtime.jsx)("div", {
2321
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2290
2322
  className: styles.annEditText,
2291
2323
  children: item.text
2292
2324
  }),
2293
- (0, react_jsx_runtime.jsx)("div", {
2325
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2294
2326
  className: styles.summaryItemLabel,
2295
2327
  children: t("summary.userComment")
2296
2328
  }),
2297
- (0, react_jsx_runtime.jsx)("textarea", {
2329
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
2298
2330
  ref: editInputRef,
2299
2331
  className: cx(A.box, styles.annEditInput),
2300
2332
  placeholder: t("common.commentPlaceholder"),
@@ -2312,14 +2344,14 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2312
2344
  }
2313
2345
  }
2314
2346
  }),
2315
- (0, react_jsx_runtime.jsxs)("div", {
2347
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2316
2348
  className: cx(A.row, styles.annEditActions),
2317
- children: [(0, react_jsx_runtime.jsx)("button", {
2349
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2318
2350
  type: "button",
2319
2351
  className: cx(A.plain, styles.annEditCancel),
2320
2352
  onClick: () => setEditing(null),
2321
2353
  children: t("common.cancel")
2322
- }), (0, react_jsx_runtime.jsx)("button", {
2354
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2323
2355
  type: "button",
2324
2356
  className: styles.annEditSave,
2325
2357
  onClick: commitEdit,
@@ -2327,9 +2359,9 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2327
2359
  })]
2328
2360
  })
2329
2361
  ]
2330
- }) : (0, react_jsx_runtime.jsx)("div", {
2362
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2331
2363
  className: styles.summaryItemMain,
2332
- children: (0, react_jsx_runtime.jsxs)("button", {
2364
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2333
2365
  type: "button",
2334
2366
  className: cx(A.plain, styles.summaryItemButton),
2335
2367
  onClick: () => {
@@ -2337,15 +2369,15 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2337
2369
  closePop();
2338
2370
  },
2339
2371
  children: [
2340
- (0, react_jsx_runtime.jsx)("div", {
2372
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2341
2373
  className: styles.summaryItemText,
2342
2374
  children: item.title
2343
2375
  }),
2344
- (0, react_jsx_runtime.jsx)("div", {
2376
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2345
2377
  className: styles.summaryItemLabel,
2346
2378
  children: t("summary.userComment")
2347
2379
  }),
2348
- (0, react_jsx_runtime.jsx)("div", {
2380
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2349
2381
  className: item.comment ? styles.summaryItemText : `${styles.summaryItemText} ${styles.summaryItemTextEmpty}`,
2350
2382
  children: item.comment || t("summary.noComment")
2351
2383
  })
@@ -2357,7 +2389,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2357
2389
  })
2358
2390
  })
2359
2391
  }) : null;
2360
- return (0, react_jsx_runtime.jsxs)("div", {
2392
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2361
2393
  ref: rootRef,
2362
2394
  className: cx(A.row, styles.summary),
2363
2395
  tabIndex: 0,
@@ -2370,27 +2402,27 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2370
2402
  onBlur,
2371
2403
  onKeyDown,
2372
2404
  children: [
2373
- (0, react_jsx_runtime.jsx)("div", {
2405
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2374
2406
  className: cx(A.keep, A.gridMid, styles.summaryIcon),
2375
- "aria-hidden": true,
2407
+ "aria-hidden": "true",
2376
2408
  children: String(count)
2377
2409
  }),
2378
- (0, react_jsx_runtime.jsxs)("div", {
2410
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2379
2411
  className: cx(A.grow, styles.summaryBody),
2380
- children: [(0, react_jsx_runtime.jsx)("div", {
2412
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2381
2413
  className: styles.summaryTitle,
2382
2414
  children: t("summary.title", { count })
2383
- }), (0, react_jsx_runtime.jsx)("div", {
2415
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2384
2416
  className: styles.summaryHint,
2385
2417
  children: t("summary.hint")
2386
2418
  })]
2387
2419
  }),
2388
- onRemoveAll ? (0, react_jsx_runtime.jsx)("button", {
2420
+ onRemoveAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2389
2421
  type: "button",
2390
2422
  className: cx(A.plain, A.gridMid, styles.cardClose),
2391
2423
  "aria-label": t("summary.removeAll"),
2392
2424
  onClick: onRemoveAll,
2393
- children: (0, react_jsx_runtime.jsx)(LucideIcon, {
2425
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
2394
2426
  name: "x",
2395
2427
  size: 13
2396
2428
  })
@@ -2605,10 +2637,10 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2605
2637
  sync();
2606
2638
  }, [sync]);
2607
2639
  if (!sessionId || items.length === 0) return null;
2608
- return (0, react_jsx_runtime.jsx)("div", {
2640
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2609
2641
  ref: stackRef,
2610
2642
  className: cx(A.col, styles.cardStack),
2611
- children: (0, react_jsx_runtime.jsx)(AnnotationSummary, {
2643
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationSummary, {
2612
2644
  count: items.length,
2613
2645
  items: items.map((item) => ({
2614
2646
  id: item.id,
@@ -2626,9 +2658,116 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2626
2658
  /** Alias kept for overlay registration. */
2627
2659
  const QuoteCard = AnnotationPanel;
2628
2660
  //#endregion
2661
+ //#region src/client/dom/floating-layer.ts
2662
+ /**
2663
+ * Mount point for the selection UI: a zero-size host inside the transcript's
2664
+ * real scroll container.
2665
+ *
2666
+ * The host is `position: absolute` in **content coordinates**, so floating UI
2667
+ * scrolls with the selected text and the scroller clips it at its own edges.
2668
+ * That is what keeps the toolbar out of the app header and the composer:
2669
+ * clipping happens in the scroller's stacking context instead of competing
2670
+ * with the header (which is not positioned and therefore loses to any
2671
+ * body-level positioned portal, whatever its z-index).
2672
+ *
2673
+ * Two host layouts exist in the product:
2674
+ * - desktop / overlay composer: `ST7X_W_scrollBody` scrolls; the chat frame is
2675
+ * content-sized and the chat's own `.icaHSq_scroll` is `overflow: visible`;
2676
+ * - regular layout: `.icaHSq_scroll` is the scroller.
2677
+ * Walking up from the selected flow item finds whichever one is live.
2678
+ */
2679
+ const HOST_ATTR = "data-dsq-layer";
2680
+ /** Below the composer seat (sticky, z-index 7); above in-content stickies (6). */
2681
+ const HOST_Z = 6;
2682
+ const layers = /* @__PURE__ */ new WeakMap();
2683
+ /** flow item → layer, so scrolling does not re-walk the ancestor chain. */
2684
+ const itemLayers = /* @__PURE__ */ new WeakMap();
2685
+ let fallback = null;
2686
+ function contentCoords(scroller, left, top) {
2687
+ const rect = scroller.getBoundingClientRect();
2688
+ return {
2689
+ left: left - rect.left - scroller.clientLeft + scroller.scrollLeft,
2690
+ top: top - rect.top - scroller.clientTop + scroller.scrollTop
2691
+ };
2692
+ }
2693
+ function layerFor(scroller) {
2694
+ const cached = layers.get(scroller);
2695
+ if (cached?.host.isConnected) return cached;
2696
+ if (getComputedStyle(scroller).position === "static") scroller.style.position = "relative";
2697
+ let host = scroller.querySelector(`:scope > [${HOST_ATTR}]`);
2698
+ if (!host) {
2699
+ host = document.createElement("div");
2700
+ host.setAttribute(HOST_ATTR, "");
2701
+ host.style.cssText = `position:absolute;left:0;top:0;width:0;height:0;z-index:${HOST_Z};pointer-events:none;`;
2702
+ scroller.appendChild(host);
2703
+ }
2704
+ const layer = {
2705
+ host,
2706
+ scroller,
2707
+ bounds: () => {
2708
+ const rect = scroller.getBoundingClientRect();
2709
+ return new DOMRect(rect.left + scroller.clientLeft, rect.top + scroller.clientTop, scroller.clientWidth, scroller.clientHeight);
2710
+ },
2711
+ toContent: (left, top) => contentCoords(scroller, left, top),
2712
+ fromContent: (left, top) => {
2713
+ const rect = scroller.getBoundingClientRect();
2714
+ return {
2715
+ left: left + rect.left + scroller.clientLeft - scroller.scrollLeft,
2716
+ top: top + rect.top + scroller.clientTop - scroller.scrollTop
2717
+ };
2718
+ }
2719
+ };
2720
+ layers.set(scroller, layer);
2721
+ return layer;
2722
+ }
2723
+ /** Fallback for hosts whose transcript has no scrollable ancestor. */
2724
+ function fallbackLayer() {
2725
+ if (fallback?.host.isConnected) return fallback;
2726
+ const host = document.createElement("div");
2727
+ host.setAttribute(HOST_ATTR, "");
2728
+ host.style.cssText = `position:absolute;left:0;top:0;width:0;height:0;z-index:${HOST_Z};pointer-events:none;`;
2729
+ document.body.appendChild(host);
2730
+ fallback = {
2731
+ host,
2732
+ scroller: document.body,
2733
+ bounds: () => new DOMRect(0, 0, window.innerWidth, window.innerHeight),
2734
+ toContent: (left, top) => ({
2735
+ left,
2736
+ top
2737
+ }),
2738
+ fromContent: (left, top) => ({
2739
+ left,
2740
+ top
2741
+ })
2742
+ };
2743
+ return fallback;
2744
+ }
2745
+ /** Layer for a node inside the transcript (falls back to a body host). */
2746
+ function floatingLayerFor(node) {
2747
+ const item = flowItemOf(node);
2748
+ const cached = item ? itemLayers.get(item) : void 0;
2749
+ if (cached?.host.isConnected) return cached;
2750
+ let el = item;
2751
+ while (el && el !== document.body) {
2752
+ const { overflowY } = getComputedStyle(el);
2753
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
2754
+ const layer = layerFor(el);
2755
+ if (item) itemLayers.set(item, layer);
2756
+ return layer;
2757
+ }
2758
+ el = el.parentElement;
2759
+ }
2760
+ const layer = fallbackLayer();
2761
+ if (item) itemLayers.set(item, layer);
2762
+ return layer;
2763
+ }
2764
+ //#endregion
2629
2765
  //#region src/client/dom/selection.ts
2630
2766
  /** Selection snapshot used by the floating toolbar. */
2631
2767
  const TOOLBAR_OFFSET = 12;
2768
+ const BELOW_GAP = 8;
2769
+ /** Estimated toolbar height; only used to decide above vs below at content top. */
2770
+ const TOOLBAR_HEIGHT = 40;
2632
2771
  const VIEWPORT_MARGIN = 12;
2633
2772
  const LINE_TOP_TOLERANCE = 3;
2634
2773
  /**
@@ -2669,12 +2808,24 @@ function getRangeFirstLineRect(range) {
2669
2808
  const firstTop = Math.min(...rects.map((rect) => rect.top));
2670
2809
  return mergeRects(rects.filter((rect) => Math.abs(rect.top - firstTop) <= LINE_TOP_TOLERANCE));
2671
2810
  }
2672
- function toolbarPosition(rect) {
2811
+ /** Keep the centered toolbar inside the visible horizontal band. */
2812
+ function anchorX(rect, bounds) {
2673
2813
  const center = rect.left + rect.width / 2;
2674
- return {
2675
- left: Math.min(Math.max(center, VIEWPORT_MARGIN), window.innerWidth - VIEWPORT_MARGIN),
2676
- top: Math.max(rect.top - TOOLBAR_OFFSET, VIEWPORT_MARGIN)
2677
- };
2814
+ const min = bounds.left + VIEWPORT_MARGIN;
2815
+ const max = Math.max(min, bounds.right - VIEWPORT_MARGIN);
2816
+ return Math.min(Math.max(center, min), max);
2817
+ }
2818
+ /**
2819
+ * True when the selection's first line still touches the viewport.
2820
+ *
2821
+ * The toolbar anchors to that line, so once it has scrolled fully out of
2822
+ * view there is nothing to anchor to and the snapshot reads as "gone" — the
2823
+ * toolbar hides and comes back when the user scrolls to the selection again
2824
+ * (scroll re-reads the selection). A partially visible line still counts:
2825
+ * `toolbarPosition` parks the toolbar at the viewport edge in that case.
2826
+ */
2827
+ function isFirstLineOnScreen(rect) {
2828
+ return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
2678
2829
  }
2679
2830
  /**
2680
2831
  * Read the live selection when it is a non-empty range inside a conversation
@@ -2692,13 +2843,24 @@ function readSelectionSnapshot(toolbarRoot) {
2692
2843
  if (!text) return null;
2693
2844
  const rect = getRangeFirstLineRect(selection.getRangeAt(0));
2694
2845
  if (!rect) return null;
2695
- const { left, top } = toolbarPosition(rect);
2696
- const source = rangeOffsetsInFlowItem(selection.getRangeAt(0));
2846
+ if (!isFirstLineOnScreen(rect)) return null;
2847
+ const layer = floatingLayerFor(anchorNode);
2848
+ const x = anchorX(rect, layer.bounds());
2849
+ const above = layer.toContent(x, rect.top - TOOLBAR_OFFSET);
2850
+ const below = above.top < TOOLBAR_HEIGHT;
2851
+ const anchor = below ? layer.toContent(x, rect.bottom + BELOW_GAP) : above;
2852
+ const range = selection.getRangeAt(0);
2853
+ const union = range.getBoundingClientRect();
2854
+ const selectionBottom = layer.toContent(0, union.height > 0 ? union.bottom : rect.bottom).top;
2855
+ const source = rangeOffsetsInFlowItem(range);
2697
2856
  const anchorHref = (anchorNode instanceof Element ? anchorNode : anchorNode.parentElement)?.closest("a[href]")?.getAttribute("href") ?? void 0;
2698
2857
  return {
2699
2858
  text,
2700
- left,
2701
- top,
2859
+ left: anchor.left,
2860
+ top: anchor.top,
2861
+ below,
2862
+ selectionBottom,
2863
+ layer,
2702
2864
  ...source ? { source } : {},
2703
2865
  ...anchorHref ? { anchorHref } : {}
2704
2866
  };
@@ -2775,35 +2937,72 @@ function SelectionToolbar({ sessionId }) {
2775
2937
  const [status, setStatus] = (0, react.useState)(null);
2776
2938
  const activeRef = (0, react.useRef)(null);
2777
2939
  activeRef.current = active;
2940
+ /** Annotation saved when the comment step opened; the box only edits it. */
2941
+ const pendingIdRef = (0, react.useRef)(null);
2778
2942
  (0, react.useEffect)(() => {
2779
2943
  ensureToolbarStyles();
2780
2944
  if (sessionId) bindAnnotationSession(sessionId);
2781
2945
  }, [sessionId]);
2782
2946
  const hide = (0, react.useCallback)(() => {
2947
+ pendingIdRef.current = null;
2783
2948
  setActive(null);
2784
2949
  setPhase("idle");
2785
2950
  setComment("");
2786
2951
  setStatus(null);
2787
2952
  }, []);
2953
+ /**
2954
+ * Keep what was typed when the comment popover goes away without an explicit
2955
+ * cancel. The annotation itself was saved when the step opened, so dropping
2956
+ * the text would also lose the only optional part of the interaction.
2957
+ */
2958
+ const flushComment = (0, react.useCallback)(() => {
2959
+ const id = pendingIdRef.current;
2960
+ pendingIdRef.current = null;
2961
+ if (!id) return;
2962
+ const typed = commentRef.current?.value ?? "";
2963
+ if (typed.trim()) updateAnnotationComment(void 0, id, typed);
2964
+ }, []);
2965
+ /** Save the current selection without a comment; returns the store id. */
2966
+ const saveSelection = (0, react.useCallback)(() => {
2967
+ const snapshot = activeRef.current;
2968
+ if (!snapshot) return null;
2969
+ return saveAnnotation(void 0, {
2970
+ text: snapshot.text,
2971
+ sources: snapshot.source ? [{
2972
+ text: snapshot.text,
2973
+ messageId: snapshot.source.messageId,
2974
+ messageKind: snapshot.source.messageKind,
2975
+ startOffset: snapshot.source.startOffset,
2976
+ endOffset: snapshot.source.endOffset
2977
+ }] : void 0
2978
+ }).id;
2979
+ }, []);
2788
2980
  const refresh = (0, react.useCallback)(() => {
2789
2981
  const next = readSelectionSnapshot(toolbarRef.current);
2790
2982
  if (!next) {
2791
2983
  if (phaseRef.current === "idle") setActive(null);
2792
2984
  return;
2793
2985
  }
2794
- setActive((prev) => {
2795
- if (phaseRef.current === "comment" && prev && prev.text !== next.text) {
2796
- setPhase("idle");
2797
- setComment("");
2798
- }
2799
- if (prev && prev.text === next.text && prev.left === next.left && prev.top === next.top) return prev;
2800
- return next;
2801
- });
2802
- }, []);
2986
+ const current = activeRef.current;
2987
+ if (phaseRef.current === "comment" && current && current.text !== next.text) {
2988
+ flushComment();
2989
+ setPhase("idle");
2990
+ setComment("");
2991
+ }
2992
+ setActive((prev) => prev && prev.text === next.text && prev.left === next.left && prev.top === next.top && prev.below === next.below && prev.layer === next.layer ? prev : next);
2993
+ }, [flushComment]);
2803
2994
  const phaseRef = (0, react.useRef)(phase);
2804
2995
  phaseRef.current = phase;
2805
2996
  (0, react.useEffect)(() => {
2806
- const schedule = () => window.setTimeout(refresh, 0);
2997
+ let scheduled = false;
2998
+ const schedule = () => {
2999
+ if (scheduled) return;
3000
+ scheduled = true;
3001
+ window.setTimeout(() => {
3002
+ scheduled = false;
3003
+ refresh();
3004
+ }, 0);
3005
+ };
2807
3006
  let dragging = false;
2808
3007
  const onPointerDown = (event) => {
2809
3008
  const target = event.target;
@@ -2813,6 +3012,7 @@ function SelectionToolbar({ sessionId }) {
2813
3012
  if (inToolbar || inComment) return;
2814
3013
  dragging = true;
2815
3014
  if (phaseRef.current === "comment") {
3015
+ flushComment();
2816
3016
  hide();
2817
3017
  return;
2818
3018
  }
@@ -2834,7 +3034,7 @@ function SelectionToolbar({ sessionId }) {
2834
3034
  const onScroll = (event) => {
2835
3035
  const target = event.target;
2836
3036
  if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
2837
- hide();
3037
+ schedule();
2838
3038
  };
2839
3039
  document.addEventListener("pointerdown", onPointerDown, true);
2840
3040
  document.addEventListener("pointerup", onPointerUp, true);
@@ -2850,12 +3050,27 @@ function SelectionToolbar({ sessionId }) {
2850
3050
  window.removeEventListener("resize", schedule);
2851
3051
  document.removeEventListener("scroll", onScroll, true);
2852
3052
  };
2853
- }, [hide, refresh]);
3053
+ }, [
3054
+ flushComment,
3055
+ hide,
3056
+ refresh
3057
+ ]);
2854
3058
  const placeCommentPop = (0, react.useCallback)(() => {
3059
+ const snapshot = activeRef.current;
3060
+ const layer = snapshot?.layer;
2855
3061
  const toolbar = toolbarRef.current;
2856
3062
  const pop = commentWrapRef.current;
2857
- if (!toolbar || !pop) return;
2858
- const anchor = toolbar.getBoundingClientRect();
3063
+ if (!snapshot || !layer || !toolbar || !pop) return;
3064
+ const bounds = layer.bounds();
3065
+ const toolbarW = toolbar.offsetWidth;
3066
+ const toolbarH = toolbar.offsetHeight;
3067
+ const home = layer.fromContent(snapshot.left, snapshot.top);
3068
+ const anchor = {
3069
+ left: home.left - toolbarW / 2,
3070
+ top: home.top - (snapshot.below ? 0 : toolbarH),
3071
+ width: toolbarW,
3072
+ height: toolbarH
3073
+ };
2859
3074
  pop.style.display = "flex";
2860
3075
  pop.style.visibility = "hidden";
2861
3076
  const panel = {
@@ -2863,24 +3078,45 @@ function SelectionToolbar({ sessionId }) {
2863
3078
  height: pop.offsetHeight || 180
2864
3079
  };
2865
3080
  pop.style.visibility = "";
2866
- const { top } = placePopover(anchor, panel, "bottom");
3081
+ const gap = 8;
3082
+ const margin = 8;
3083
+ const belowTop = layer.fromContent(0, snapshot.selectionBottom).top + gap;
3084
+ const aboveTop = anchor.top - panel.height - gap;
3085
+ const top = belowTop <= bounds.bottom - margin - panel.height ? belowTop : aboveTop >= bounds.top + margin ? aboveTop : Math.max(bounds.top + margin, Math.min(belowTop, bounds.bottom - margin - panel.height));
2867
3086
  const half = panel.width / 2 + 8;
2868
- const center = Math.min(Math.max(anchor.left + anchor.width / 2, half), Math.max(half, window.innerWidth - half));
2869
- pop.style.top = `${top}px`;
2870
- pop.style.left = `${center}px`;
3087
+ const min = bounds.left + half;
3088
+ const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
3089
+ const at = layer.toContent(center, top);
3090
+ pop.style.top = `${at.top}px`;
3091
+ pop.style.left = `${at.left}px`;
3092
+ const sideGap = 6;
3093
+ const popLeftView = layer.fromContent(at.left, at.top).left - panel.width / 2;
3094
+ const popTopView = top;
3095
+ let leftView = popLeftView;
3096
+ let topView = popTopView - sideGap - toolbarH;
3097
+ if (topView < bounds.top + margin) topView = popTopView + panel.height + sideGap;
3098
+ leftView = Math.max(bounds.left + margin, Math.min(leftView, bounds.right - margin - toolbarW));
3099
+ topView = Math.max(bounds.top + margin, Math.min(topView, bounds.bottom - margin - toolbarH));
3100
+ const toolbarAt = layer.toContent(leftView, topView);
3101
+ toolbar.style.left = `${toolbarAt.left}px`;
3102
+ toolbar.style.top = `${toolbarAt.top}px`;
2871
3103
  }, []);
3104
+ (0, react.useLayoutEffect)(() => {
3105
+ if (phase === "comment") placeCommentPop();
3106
+ }, [
3107
+ active,
3108
+ phase,
3109
+ placeCommentPop
3110
+ ]);
2872
3111
  (0, react.useEffect)(() => {
2873
- if (phase === "comment") {
2874
- placeCommentPop();
2875
- commentRef.current?.focus();
2876
- }
2877
- }, [phase, placeCommentPop]);
3112
+ if (phase === "comment") commentRef.current?.focus();
3113
+ }, [phase]);
2878
3114
  (0, react.useEffect)(() => {
2879
3115
  const onResize = () => placeCommentPop();
2880
3116
  window.addEventListener("resize", onResize);
2881
3117
  return () => window.removeEventListener("resize", onResize);
2882
3118
  }, [placeCommentPop]);
2883
- (0, react.useEffect)(() => {
3119
+ (0, react.useLayoutEffect)(() => {
2884
3120
  if (phase !== "comment") return;
2885
3121
  const area = commentRef.current;
2886
3122
  if (!area) return;
@@ -2907,12 +3143,18 @@ function SelectionToolbar({ sessionId }) {
2907
3143
  setStatus(t("toolbar.copyFailed"));
2908
3144
  }
2909
3145
  }, [hide]);
3146
+ /**
3147
+ * "Add to task" is the commit: the quote is saved immediately, with or
3148
+ * without a comment. The popover only edits that saved entry, so dismissing
3149
+ * it (outside click, new selection) can never undo the annotation.
3150
+ */
2910
3151
  const openComment = (0, react.useCallback)(() => {
2911
3152
  if (!activeRef.current) return;
3153
+ pendingIdRef.current = saveSelection();
2912
3154
  setPhase("comment");
2913
3155
  setComment("");
2914
3156
  setStatus(null);
2915
- }, []);
3157
+ }, [saveSelection]);
2916
3158
  /**
2917
3159
  * Hand the selected link to the system browser.
2918
3160
  *
@@ -2930,89 +3172,75 @@ function SelectionToolbar({ sessionId }) {
2930
3172
  clearNativeSelection();
2931
3173
  hide();
2932
3174
  }, [hide]);
2933
- /** Quick-add without opening the comment step (double-tap / “Skip comment”). */
2934
- const commitAnnotation = (0, react.useCallback)((body) => {
2935
- const snapshot = activeRef.current;
2936
- if (!snapshot) {
2937
- hide();
2938
- return;
2939
- }
2940
- saveAnnotation(void 0, {
2941
- text: snapshot.text,
2942
- ...body && body.trim() ? { comment: body.trim() } : {},
2943
- sources: snapshot.source ? [{
2944
- text: snapshot.text,
2945
- messageId: snapshot.source.messageId,
2946
- messageKind: snapshot.source.messageKind,
2947
- startOffset: snapshot.source.startOffset,
2948
- endOffset: snapshot.source.endOffset
2949
- }] : void 0
2950
- });
2951
- getAnnotations().length;
3175
+ /** Close the optional comment step; the annotation stays saved either way. */
3176
+ const finishComment = (0, react.useCallback)((keepComment) => {
3177
+ if (keepComment) flushComment();
3178
+ else pendingIdRef.current = null;
2952
3179
  clearNativeSelection();
2953
3180
  hide();
2954
- setStatus(null);
2955
3181
  window.setTimeout(focusComposer, 16);
2956
- }, [hide]);
3182
+ }, [flushComment, hide]);
2957
3183
  if (!active) return null;
2958
3184
  const toolbarStyle = {
2959
3185
  left: active.left,
2960
3186
  top: active.top
2961
3187
  };
2962
3188
  const link = linkTarget(active.text, active.anchorHref);
2963
- return (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
2964
- (0, react_jsx_runtime.jsx)("div", {
3189
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3190
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2965
3191
  ref: toolbarRef,
2966
3192
  className: cx(A.row, styles.toolbar),
2967
- style: toolbarStyle,
3193
+ style: phase === "comment" ? void 0 : toolbarStyle,
2968
3194
  role: "toolbar",
2969
3195
  "aria-label": t("toolbar.aria"),
2970
3196
  "data-dsq-toolbar": "true",
3197
+ "data-anchor": phase === "comment" ? "popover-top" : active.below ? "below" : "above",
2971
3198
  onPointerDown: handleToolbarPointerDown,
2972
- children: phase === "comment" ? (0, react_jsx_runtime.jsx)("div", {
3199
+ children: phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2973
3200
  className: cx(A.inlineRow, styles.toolbarInner),
2974
- children: (0, react_jsx_runtime.jsx)("span", {
3201
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2975
3202
  style: {
2976
3203
  padding: "0 8px",
2977
3204
  fontSize: 13,
2978
- opacity: .8
3205
+ opacity: .8,
3206
+ whiteSpace: "nowrap"
2979
3207
  },
2980
3208
  children: t("toolbar.commentHint")
2981
3209
  })
2982
- }) : (0, react_jsx_runtime.jsxs)("div", {
3210
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2983
3211
  className: cx(A.inlineRow, styles.toolbarInner),
2984
3212
  children: [
2985
- link ? (0, react_jsx_runtime.jsx)("button", {
3213
+ link ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2986
3214
  type: "button",
2987
3215
  className: cx(A.plain, A.inlineRow, styles.button),
2988
3216
  onClick: handleOpenLink,
2989
- children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
3217
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
2990
3218
  name: "external-link",
2991
3219
  size: 14
2992
3220
  }), t("toolbar.openLink")]
2993
3221
  }) : null,
2994
- link ? (0, react_jsx_runtime.jsx)("span", {
3222
+ link ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2995
3223
  className: styles.divider,
2996
- "aria-hidden": true
3224
+ "aria-hidden": "true"
2997
3225
  }) : null,
2998
- (0, react_jsx_runtime.jsx)("button", {
3226
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2999
3227
  type: "button",
3000
3228
  className: cx(A.plain, A.inlineRow, styles.button),
3001
3229
  onClick: () => void handleCopy(),
3002
- children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
3230
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3003
3231
  name: "copy",
3004
3232
  size: 14
3005
3233
  }), t("toolbar.copy")]
3006
3234
  }),
3007
- (0, react_jsx_runtime.jsx)("span", {
3235
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3008
3236
  className: styles.divider,
3009
- "aria-hidden": true
3237
+ "aria-hidden": "true"
3010
3238
  }),
3011
- (0, react_jsx_runtime.jsx)("button", {
3239
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3012
3240
  type: "button",
3013
3241
  className: cx(A.plain, A.inlineRow, styles.button),
3014
3242
  onClick: openComment,
3015
- children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
3243
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3016
3244
  name: "plus",
3017
3245
  size: 14
3018
3246
  }), t("toolbar.addToTask")]
@@ -3020,14 +3248,13 @@ function SelectionToolbar({ sessionId }) {
3020
3248
  ]
3021
3249
  })
3022
3250
  }),
3023
- phase === "comment" ? (0, react_jsx_runtime.jsxs)("div", {
3251
+ phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3024
3252
  ref: commentWrapRef,
3025
3253
  className: cx(A.col, styles.commentPop),
3026
- style: void 0,
3027
3254
  role: "dialog",
3028
3255
  "aria-label": t("toolbar.commentDialogAria"),
3029
3256
  onPointerDown: handleToolbarPointerDown,
3030
- children: [(0, react_jsx_runtime.jsx)("textarea", {
3257
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3031
3258
  ref: commentRef,
3032
3259
  className: cx(A.box, styles.commentInput),
3033
3260
  placeholder: t("common.commentPlaceholder"),
@@ -3036,37 +3263,38 @@ function SelectionToolbar({ sessionId }) {
3036
3263
  onKeyDown: (e) => {
3037
3264
  if (e.key === "Escape") {
3038
3265
  e.preventDefault();
3266
+ pendingIdRef.current = null;
3039
3267
  hide();
3040
3268
  return;
3041
3269
  }
3042
3270
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3043
3271
  e.preventDefault();
3044
- commitAnnotation(comment);
3272
+ finishComment(true);
3045
3273
  }
3046
3274
  }
3047
- }), (0, react_jsx_runtime.jsxs)("div", {
3275
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3048
3276
  className: styles.commentActions,
3049
- children: [(0, react_jsx_runtime.jsx)("button", {
3277
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3050
3278
  type: "button",
3051
3279
  className: cx(A.plain, A.inlineRow, styles.button),
3052
- onClick: () => commitAnnotation(void 0),
3280
+ onClick: () => finishComment(false),
3053
3281
  children: t("toolbar.skipComment")
3054
- }), (0, react_jsx_runtime.jsx)("button", {
3282
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3055
3283
  type: "button",
3056
3284
  className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
3057
3285
  disabled: !comment.trim(),
3058
- onClick: () => commitAnnotation(comment),
3286
+ onClick: () => finishComment(true),
3059
3287
  children: t("toolbar.addComment")
3060
3288
  })]
3061
3289
  })]
3062
3290
  }) : null,
3063
- status ? (0, react_jsx_runtime.jsx)("div", {
3291
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3064
3292
  className: styles.status,
3065
3293
  style: toolbarStyle,
3066
3294
  role: "status",
3067
3295
  children: status
3068
3296
  }) : null
3069
- ] }), document.body);
3297
+ ] }), active.layer.host);
3070
3298
  }
3071
3299
  //#endregion
3072
3300
  //#region src/client/dom/decorateAnnotationDirectives.ts
@@ -3196,11 +3424,11 @@ function TranscriptQuoteCard({ node }) {
3196
3424
  scrollToAnnotation(index + 1, text, markIdOf(index));
3197
3425
  }, [markIdOf]);
3198
3426
  if (quotes.length === 0) return null;
3199
- return (0, react_jsx_runtime.jsx)("div", {
3427
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3200
3428
  ref: stackRef,
3201
3429
  className: styles.tCardStack,
3202
3430
  "data-dsq-deco": "true",
3203
- children: quotes.map((quote, index) => (0, react_jsx_runtime.jsx)("div", {
3431
+ children: quotes.map((quote, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3204
3432
  className: cx(A.plain, styles.tCardButton),
3205
3433
  role: "button",
3206
3434
  tabIndex: 0,
@@ -3211,7 +3439,7 @@ function TranscriptQuoteCard({ node }) {
3211
3439
  event.preventDefault();
3212
3440
  onSelect(index, quote.text);
3213
3441
  },
3214
- children: (0, react_jsx_runtime.jsx)(AnnotationCardView, {
3442
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationCardView, {
3215
3443
  variant: "transcript",
3216
3444
  title: quote.title,
3217
3445
  text: quote.text,
@@ -3258,12 +3486,12 @@ function UserMessageDisplay({ node, renderMessageImages }) {
3258
3486
  const quotes = scanned.annotations;
3259
3487
  const images = contentImages(node.data?.content);
3260
3488
  if (!text && images.length === 0 && quotes.length === 0) return null;
3261
- return (0, react_jsx_runtime.jsx)("div", {
3489
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3262
3490
  className: cx(A.colEnd, styles.userRow),
3263
- children: (0, react_jsx_runtime.jsxs)("div", {
3491
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3264
3492
  className: cx(A.colEnd, styles.userStack),
3265
3493
  children: [
3266
- images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
3494
+ images.length > 0 && renderMessageImages !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3267
3495
  className: cx(A.colEnd, styles.userImages),
3268
3496
  "data-message-attachments": true,
3269
3497
  children: renderMessageImages({
@@ -3271,12 +3499,12 @@ function UserMessageDisplay({ node, renderMessageImages }) {
3271
3499
  align: "end",
3272
3500
  compact: images.length > 1
3273
3501
  })
3274
- }, "images") : null,
3275
- quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null,
3276
- text ? (0, react_jsx_runtime.jsx)("div", {
3502
+ }) : null,
3503
+ quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null,
3504
+ text ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3277
3505
  className: styles.userBubble,
3278
3506
  children: text
3279
- }, "bubble") : null
3507
+ }) : null
3280
3508
  ]
3281
3509
  })
3282
3510
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-select-quote",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "DeepSeek Harness web plugin: annotate text selected in the conversation — the selection rides along with your next message, is marked in the source text, and the model points back at it.",
5
5
  "keywords": [
6
6
  "deepseek",