dsh-quote-followup 0.2.6 → 0.2.8
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 +7 -3
- package/README.zh.md +7 -3
- package/lib/client.js +232 -78
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@ English | [中文](README.zh.md)
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/dsh-quote-followup)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](https://dsh.market/)
|
|
7
8
|
|
|
8
9
|
A Web-only [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin for quoting selected conversation text into the composer and asking a focused follow-up.
|
|
9
10
|
|
|
@@ -16,13 +17,14 @@ A Web-only [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) p
|
|
|
16
17
|
## Features
|
|
17
18
|
|
|
18
19
|
- Select text inside the Web conversation transcript to reveal a floating **Quote** button.
|
|
19
|
-
- Append a native DSH conversation-reference chip without replacing the existing draft.
|
|
20
|
+
- Append a native DSH conversation-reference chip without replacing the existing draft, using the session-scoped composer input facade rather than Lexical's private node registry.
|
|
20
21
|
- Reuse the same atomic `ReferenceChipNode`, conversation icon, and business color as `@file` / `@session`.
|
|
21
22
|
- Keep each chip compact by showing only the excerpt; the bubble icon already conveys that it is a quote.
|
|
22
23
|
- Follow the active DSH locale for the floating action and serialized quote frame.
|
|
23
|
-
- Attach the conversation turn number to the serialized frame when the selection
|
|
24
|
+
- Attach the conversation turn number to the serialized frame when both ends of the selection belong to the same DSH chat row. Selections spanning messages keep the excerpt but omit ambiguous role/turn provenance.
|
|
24
25
|
- Quote multiple excerpts; on send, the plugin codec expands each chip into a model-readable Markdown blockquote.
|
|
25
26
|
- Fall back to a plain-text quote when the host lacks native chip support.
|
|
27
|
+
- Show a localized notice if the composer is missing or unavailable, restore the selection, and let you retry quoting the original text.
|
|
26
28
|
|
|
27
29
|
TUI is intentionally unsupported.
|
|
28
30
|
|
|
@@ -56,7 +58,9 @@ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restar
|
|
|
56
58
|
npm test
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
The regression harness covers compact native quote chips, locale switching,
|
|
61
|
+
The regression harness covers compact native quote chips, locale switching, single-row versus cross-message provenance, missing/locked composer feedback and retry, repeated quoting, spacing after an existing draft, codec serialization, the Firefox text fallback, and current-client takeover of stale singleton/button state after a hot swap.
|
|
62
|
+
|
|
63
|
+
An opt-in [assembled DSH Web regression](https://github.com/tr1v3r/dsh-quote-followup/blob/master/test/integration/README.md) runs the plugin through a pinned, built DSH checkout with Chromium. It covers existing drafts, repeated quoting, undo/redo, session isolation, prompt serialization, and Host-driven unavailable input.
|
|
60
64
|
|
|
61
65
|
## License
|
|
62
66
|
|
package/README.zh.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/dsh-quote-followup)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](https://dsh.market/)
|
|
7
8
|
|
|
8
9
|
一个仅支持 Web 的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:选中对话文本,将其追加到输入框,再进行针对性追问。
|
|
9
10
|
|
|
@@ -16,13 +17,14 @@
|
|
|
16
17
|
## 功能
|
|
17
18
|
|
|
18
19
|
- 在 Web 对话区选中文本后显示浮动的 **❐ 引用** 按钮。
|
|
19
|
-
-
|
|
20
|
+
- 通过会话级输入接口将 DSH 原生对话引用 chip 追加到现有草稿,不覆盖已输入内容,也不直接读取 Lexical 私有节点注册表。
|
|
20
21
|
- chip 使用与 `@文件` / `@对话` 相同的 `ReferenceChipNode`、对话图标和业务色,可整体删除。
|
|
21
22
|
- chip 只展示摘录正文,引用语义由气泡图标表达,减少重复标签。
|
|
22
23
|
- 浮动按钮和发送时的引用框架跟随 DSH 当前语言。
|
|
23
|
-
-
|
|
24
|
+
- 选区两端属于同一 DSH 聊天行时,序列化引用附带对话轮次编号;跨消息选区保留摘录,但不标注归属不明的角色与轮次。
|
|
24
25
|
- 可连续引用多段内容;发送时由插件 codec 将各 chip 展开为模型可读的 Markdown 引用块。
|
|
25
26
|
- 旧版 DSH 缺少原生 chip 能力时,自动降级为纯文本引用。
|
|
27
|
+
- 输入框缺失或暂不可编辑时显示本地化提示,恢复原选区,便于重试引用。
|
|
26
28
|
|
|
27
29
|
本插件不再适配 TUI。
|
|
28
30
|
|
|
@@ -56,7 +58,9 @@ dsh plugin --profile web add dsh-quote-followup
|
|
|
56
58
|
npm test
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
回归测试覆盖精简的原生引用 chip、中英文 locale
|
|
61
|
+
回归测试覆盖精简的原生引用 chip、中英文 locale 切换、同消息与跨消息的来源判定、输入框缺失/不可编辑时的提示与重试、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
|
|
62
|
+
|
|
63
|
+
可选的[真实 DSH Web 集成回归](https://github.com/tr1v3r/dsh-quote-followup/blob/master/test/integration/README.md)通过固定版本、已构建的 DSH 和 Chromium 加载插件,验证已有草稿、连续引用、撤销重做、会话隔离、发送序列化及 Host 驱动的不可写输入状态。
|
|
60
64
|
|
|
61
65
|
## 许可证
|
|
62
66
|
|
package/lib/client.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* can edit before sending — a visible, targeted follow-up with zero hidden
|
|
8
8
|
* state and zero host-side privileges.
|
|
9
9
|
*
|
|
10
|
-
* Pure DOM (no React). The
|
|
11
|
-
* `inputTriggers` codec registry
|
|
12
|
-
*
|
|
10
|
+
* Pure DOM (no React). The injected host services are DSH's
|
|
11
|
+
* `inputTriggers` codec registry, `locale`, and `sessions`. Without the
|
|
12
|
+
* optional codec, quotes degrade to plain text; without locale, copy is English.
|
|
13
|
+
* Session scoping is needed for native reference insertion. Mounted by the
|
|
13
14
|
* web client runtime through the package's `dsh.client` declaration
|
|
14
15
|
* (`platform: "web"`).
|
|
15
16
|
*
|
|
@@ -26,6 +27,7 @@ window.__ModuleLoader__.load({
|
|
|
26
27
|
const TRANSCRIPT_SELECTOR = '[data-slot="conversation.session"]';
|
|
27
28
|
/** Composer input candidates, most specific first (official slot → legacy hints). */
|
|
28
29
|
const COMPOSER_SELECTORS = [
|
|
30
|
+
'[data-composer-input]',
|
|
29
31
|
'[data-slot="conversation.composer"] textarea',
|
|
30
32
|
'[data-slot="conversation.composer"] [contenteditable="true"]',
|
|
31
33
|
'textarea[data-phase]',
|
|
@@ -35,7 +37,13 @@ window.__ModuleLoader__.load({
|
|
|
35
37
|
];
|
|
36
38
|
const BUTTON_ID = "dsh-quote-followup-btn";
|
|
37
39
|
const BUTTON_VERSION_ATTR = "data-dsh-quote-followup-version";
|
|
38
|
-
const CLIENT_VERSION = "0.2.
|
|
40
|
+
const CLIENT_VERSION = "0.2.8";
|
|
41
|
+
/** Shared <style> element; versioned like the button so hot-swaps replace it. */
|
|
42
|
+
const STYLE_ID = "dsh-quote-followup-style";
|
|
43
|
+
const STYLE_VERSION_ATTR = "data-dsh-quote-followup-style-version";
|
|
44
|
+
const BUTTON_CLASS = "dsh-qf-btn";
|
|
45
|
+
const NOTICE_ID = "dsh-quote-followup-notice";
|
|
46
|
+
const NOTICE_CLASS = "dsh-qf-notice";
|
|
39
47
|
/** Codec owner for native DSH reference chips. */
|
|
40
48
|
const QUOTE_SOURCE = "quote-followup";
|
|
41
49
|
const LOCALE_NS = "quote-followup";
|
|
@@ -49,7 +57,10 @@ window.__ModuleLoader__.load({
|
|
|
49
57
|
"conversation.role.user": "用户",
|
|
50
58
|
"conversation.role.assistant": "助手",
|
|
51
59
|
"quote.turn": "第 {n} 轮",
|
|
52
|
-
"quote.truncated": "已截断"
|
|
60
|
+
"quote.truncated": "已截断",
|
|
61
|
+
"notice.composer-missing": "未找到输入框;摘录已保留,请选中原文重试。",
|
|
62
|
+
"notice.composer-locked": "输入框暂不可编辑;摘录已保留,请选中原文重试。",
|
|
63
|
+
"notice.insert-failed": "引用未能插入;摘录已保留,请选中原文重试。"
|
|
53
64
|
},
|
|
54
65
|
en: {
|
|
55
66
|
"button.label": "❐ Quote",
|
|
@@ -59,7 +70,10 @@ window.__ModuleLoader__.load({
|
|
|
59
70
|
"conversation.role.user": "user",
|
|
60
71
|
"conversation.role.assistant": "assistant",
|
|
61
72
|
"quote.turn": "turn {n}",
|
|
62
|
-
"quote.truncated": "truncated"
|
|
73
|
+
"quote.truncated": "truncated",
|
|
74
|
+
"notice.composer-missing": "Composer not found; excerpt saved. Select the original text to retry.",
|
|
75
|
+
"notice.composer-locked": "Composer is unavailable; excerpt saved. Select the original text to retry.",
|
|
76
|
+
"notice.insert-failed": "Quote could not be inserted; excerpt saved. Select the original text to retry."
|
|
63
77
|
}
|
|
64
78
|
};
|
|
65
79
|
const fallbackT = (key) => LOCALE_DICT.en[key] ?? key;
|
|
@@ -95,6 +109,9 @@ window.__ModuleLoader__.load({
|
|
|
95
109
|
return null;
|
|
96
110
|
return anchor;
|
|
97
111
|
};
|
|
112
|
+
/** Only assign provenance when both ends belong to the same marked chat row. */
|
|
113
|
+
const messageRowOf = (node, transcript) =>
|
|
114
|
+
walkAncestors(node, transcript, (element) => element.hasAttribute?.("data-chat-turn") ? element : undefined);
|
|
98
115
|
/**
|
|
99
116
|
* Best-effort role for the quoted fragment: walk up from the range start
|
|
100
117
|
* looking for role markers (data attributes, then class-name hints).
|
|
@@ -206,49 +223,52 @@ window.__ModuleLoader__.load({
|
|
|
206
223
|
return null;
|
|
207
224
|
};
|
|
208
225
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* Lexical or React runtime is loaded by this plugin.
|
|
226
|
+
* The per-session input facade owns chip creation, editor transactions and
|
|
227
|
+
* phase/revision guards. Append at its published draft end (detect-text
|
|
228
|
+
* offsets differ from clipboard-text lengths when a draft contains chips).
|
|
213
229
|
*/
|
|
214
|
-
const appendQuoteChip = (element, payload, t) => {
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
if (typeof editor?.update !== "function" || typeof ChipNode !== "function" || typeof TextNode !== "function" || typeof ParagraphNode !== "function")
|
|
230
|
+
const appendQuoteChip = (ctx, element, payload, t) => {
|
|
231
|
+
const sessions = ctx?.get?.("sessions");
|
|
232
|
+
const sessionId = sessions?.list?.getSnapshot?.()?.current;
|
|
233
|
+
const scope = sessionId === undefined ? null : sessions?.scope?.(sessionId);
|
|
234
|
+
const conversation = scope?.get?.("conversation");
|
|
235
|
+
if (scope === null || scope === undefined || typeof conversation?.input?.for !== "function")
|
|
221
236
|
return false;
|
|
237
|
+
let input;
|
|
222
238
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
239
|
+
input = conversation.input.for(scope);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.warn("[dsh-quote-followup] scoped composer unavailable", error);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
const state = input?.state?.getSnapshot?.();
|
|
245
|
+
if (state === undefined || typeof state.draftRev !== "number" || typeof input.insertReference !== "function")
|
|
246
|
+
return false;
|
|
247
|
+
// This is a rejected edit, not a missing feature: never bypass the
|
|
248
|
+
// facade's admission guard with an unguarded text fallback.
|
|
249
|
+
if (state.phase !== "plain" && state.phase !== "claimed")
|
|
250
|
+
return null;
|
|
251
|
+
const end = state.draft.length - (state.occurrences ?? []).reduce((extra, occurrence) =>
|
|
252
|
+
extra + Math.max(0, occurrence.length - 1), 0);
|
|
253
|
+
if (!Number.isSafeInteger(end) || end < 0)
|
|
254
|
+
return null;
|
|
255
|
+
const lead = end > 0 && !/\s/u.test(state.draft.slice(-1)) ? " " : "";
|
|
256
|
+
try {
|
|
257
|
+
if (lead !== "" && input.insertText(lead, { start: end, end, draftRev: state.draftRev }) !== true)
|
|
258
|
+
return null;
|
|
259
|
+
const current = input.state.getSnapshot();
|
|
260
|
+
if (current.draftRev !== state.draftRev + lead.length)
|
|
261
|
+
return null;
|
|
262
|
+
const at = end + lead.length;
|
|
263
|
+
const inserted = input.insertReference(quoteInsert(payload, t), {
|
|
264
|
+
start: at, end: at, draftRev: current.draftRev
|
|
265
|
+
});
|
|
246
266
|
if (inserted)
|
|
247
267
|
element.focus();
|
|
248
|
-
return inserted;
|
|
268
|
+
return inserted ? true : null;
|
|
249
269
|
} catch (error) {
|
|
250
|
-
console.warn("[dsh-quote-followup]
|
|
251
|
-
return
|
|
270
|
+
console.warn("[dsh-quote-followup] scoped reference insertion failed", error);
|
|
271
|
+
return null;
|
|
252
272
|
}
|
|
253
273
|
};
|
|
254
274
|
/**
|
|
@@ -358,7 +378,110 @@ window.__ModuleLoader__.load({
|
|
|
358
378
|
let pendingQuote = null;
|
|
359
379
|
let quoteSourceReady = false;
|
|
360
380
|
let currentT = fallbackT;
|
|
381
|
+
let clientContext = null;
|
|
382
|
+
let noticeTimer = null;
|
|
383
|
+
const clearNotice = () => {
|
|
384
|
+
if (noticeTimer !== null)
|
|
385
|
+
clearTimeout(noticeTimer);
|
|
386
|
+
noticeTimer = null;
|
|
387
|
+
document.getElementById(NOTICE_ID)?.remove();
|
|
388
|
+
};
|
|
389
|
+
const showNotice = (key) => {
|
|
390
|
+
clearNotice();
|
|
391
|
+
const notice = document.createElement("div");
|
|
392
|
+
notice.id = NOTICE_ID;
|
|
393
|
+
notice.className = NOTICE_CLASS;
|
|
394
|
+
notice.setAttribute("role", "status");
|
|
395
|
+
notice.setAttribute("data-message-key", key);
|
|
396
|
+
notice.textContent = currentT(key);
|
|
397
|
+
document.body.appendChild(notice);
|
|
398
|
+
noticeTimer = setTimeout(clearNotice, 4000);
|
|
399
|
+
};
|
|
400
|
+
const restoreSelection = (range) => {
|
|
401
|
+
if (range === null)
|
|
402
|
+
return;
|
|
403
|
+
try {
|
|
404
|
+
const selection = document.getSelection();
|
|
405
|
+
selection?.removeAllRanges();
|
|
406
|
+
selection?.addRange(range);
|
|
407
|
+
} catch {}
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* Inject the button stylesheet once per client version. Visuals follow
|
|
411
|
+
* the dsh-annotation convention: a single idempotent <style> element
|
|
412
|
+
* built on DSH Web design tokens (--dsw-alias-* / --dsw-specific-*),
|
|
413
|
+
* so the pill tracks the active theme instead of hard-coded colors.
|
|
414
|
+
* The trailing fallbacks only engage when a token is renamed upstream.
|
|
415
|
+
* Without a real document.head (test fakes) the button still works,
|
|
416
|
+
* just unstyled.
|
|
417
|
+
*/
|
|
418
|
+
const ensureStyles = () => {
|
|
419
|
+
const head = document.head;
|
|
420
|
+
if (head === null || typeof head.appendChild !== "function")
|
|
421
|
+
return;
|
|
422
|
+
const existing = document.getElementById(STYLE_ID);
|
|
423
|
+
if (existing !== null) {
|
|
424
|
+
if (existing.getAttribute(STYLE_VERSION_ATTR) === CLIENT_VERSION)
|
|
425
|
+
return;
|
|
426
|
+
existing.remove();
|
|
427
|
+
}
|
|
428
|
+
const style = document.createElement("style");
|
|
429
|
+
style.id = STYLE_ID;
|
|
430
|
+
style.setAttribute(STYLE_VERSION_ATTR, CLIENT_VERSION);
|
|
431
|
+
style.textContent = [
|
|
432
|
+
`.${BUTTON_CLASS} {`,
|
|
433
|
+
" position: fixed;",
|
|
434
|
+
" z-index: 2147483600;",
|
|
435
|
+
" display: inline-flex;",
|
|
436
|
+
" align-items: center;",
|
|
437
|
+
" gap: 6px;",
|
|
438
|
+
" height: 28px;",
|
|
439
|
+
" padding: 0 12px;",
|
|
440
|
+
" border: none;",
|
|
441
|
+
" border-radius: 14px;",
|
|
442
|
+
" background: var(--dsw-alias-button-primary-fill, rgba(56, 56, 62, .94));",
|
|
443
|
+
" color: var(--dsw-alias-label-primary-foreground, #f5f5f7);",
|
|
444
|
+
" font-family: var(--dsw-font-family, system-ui);",
|
|
445
|
+
" font-size: 12px;",
|
|
446
|
+
" line-height: 18px;",
|
|
447
|
+
" white-space: nowrap;",
|
|
448
|
+
" cursor: pointer;",
|
|
449
|
+
" box-shadow: var(--dsw-shadow-lv3, 0 2px 10px rgba(0, 0, 0, .28));",
|
|
450
|
+
" user-select: none;",
|
|
451
|
+
" animation: dsh-qf-pop .12s var(--ds-ease-in-out, ease);",
|
|
452
|
+
"}",
|
|
453
|
+
`.${BUTTON_CLASS}:hover:not(:disabled) {`,
|
|
454
|
+
" background: var(--dsw-alias-button-primary-hover, rgba(72, 72, 80, .96));",
|
|
455
|
+
"}",
|
|
456
|
+
`.${BUTTON_CLASS}:active { filter: brightness(.92); }`,
|
|
457
|
+
`.${BUTTON_CLASS}:focus-visible {`,
|
|
458
|
+
" outline: 2px solid var(--dsw-alias-text-accent, #4c9aff);",
|
|
459
|
+
" outline-offset: 1px;",
|
|
460
|
+
"}",
|
|
461
|
+
`.${NOTICE_CLASS} {`,
|
|
462
|
+
" position: fixed;",
|
|
463
|
+
" z-index: 2147483600;",
|
|
464
|
+
" left: 50%;",
|
|
465
|
+
" bottom: 24px;",
|
|
466
|
+
" transform: translateX(-50%);",
|
|
467
|
+
" max-width: min(90vw, 480px);",
|
|
468
|
+
" padding: 8px 12px;",
|
|
469
|
+
" border-radius: 8px;",
|
|
470
|
+
" background: var(--dsw-alias-button-primary-fill, rgba(56, 56, 62, .94));",
|
|
471
|
+
" color: var(--dsw-alias-label-primary-foreground, #f5f5f7);",
|
|
472
|
+
" font-family: var(--dsw-font-family, system-ui);",
|
|
473
|
+
" font-size: 12px;",
|
|
474
|
+
" pointer-events: none;",
|
|
475
|
+
"}",
|
|
476
|
+
"@keyframes dsh-qf-pop {",
|
|
477
|
+
" from { opacity: 0; transform: translateY(3px); }",
|
|
478
|
+
" to { opacity: 1; transform: none; }",
|
|
479
|
+
"}"
|
|
480
|
+
].join("\n");
|
|
481
|
+
head.appendChild(style);
|
|
482
|
+
};
|
|
361
483
|
const ensureButton = () => {
|
|
484
|
+
ensureStyles();
|
|
362
485
|
const existing = document.getElementById(BUTTON_ID);
|
|
363
486
|
if (existing?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
|
|
364
487
|
return existing;
|
|
@@ -367,23 +490,10 @@ window.__ModuleLoader__.load({
|
|
|
367
490
|
button.id = BUTTON_ID;
|
|
368
491
|
button.setAttribute(BUTTON_VERSION_ATTR, CLIENT_VERSION);
|
|
369
492
|
button.type = "button";
|
|
493
|
+
button.className = BUTTON_CLASS;
|
|
370
494
|
button.textContent = currentT("button.label");
|
|
371
495
|
button.title = currentT("button.title");
|
|
372
|
-
|
|
373
|
-
position: "fixed",
|
|
374
|
-
zIndex: "2147483600",
|
|
375
|
-
padding: "3px 10px",
|
|
376
|
-
fontSize: "12px",
|
|
377
|
-
lineHeight: "20px",
|
|
378
|
-
borderRadius: "6px",
|
|
379
|
-
border: "1px solid rgba(140,140,150,.45)",
|
|
380
|
-
background: "rgba(56,56,62,.94)",
|
|
381
|
-
color: "#f5f5f7",
|
|
382
|
-
cursor: "pointer",
|
|
383
|
-
boxShadow: "0 2px 10px rgba(0,0,0,.28)",
|
|
384
|
-
userSelect: "none",
|
|
385
|
-
display: "none"
|
|
386
|
-
});
|
|
496
|
+
button.style.display = "none";
|
|
387
497
|
button.addEventListener("mousedown", (event) => {
|
|
388
498
|
event.preventDefault();
|
|
389
499
|
event.stopPropagation();
|
|
@@ -397,43 +507,57 @@ window.__ModuleLoader__.load({
|
|
|
397
507
|
return button;
|
|
398
508
|
};
|
|
399
509
|
const placeButton = (button, rect) => {
|
|
400
|
-
|
|
401
|
-
|
|
510
|
+
// Reveal before measuring: offsetWidth of a display:none element is 0,
|
|
511
|
+
// and the fixed-width guard below keeps fake DOMs (no layout) working.
|
|
512
|
+
button.style.display = "inline-flex";
|
|
513
|
+
const width = Number.isFinite(button.offsetWidth) && button.offsetWidth > 0
|
|
514
|
+
? button.offsetWidth
|
|
515
|
+
: 64;
|
|
516
|
+
// Center on the selection; measurement guards keep fake/hidden
|
|
517
|
+
// elements (offsetWidth 0 / undefined) on the old fixed-width path.
|
|
518
|
+
let x = rect.left + rect.width / 2 - width / 2;
|
|
519
|
+
let y = rect.top - 34; // pill height (28px) + 6px gap
|
|
402
520
|
if (y < 4)
|
|
403
521
|
y = rect.bottom + 6;
|
|
404
|
-
x = Math.max(4, Math.min(x, window.innerWidth -
|
|
522
|
+
x = Math.max(4, Math.min(x, window.innerWidth - width - 4));
|
|
405
523
|
button.style.left = `${Math.round(x)}px`;
|
|
406
524
|
button.style.top = `${Math.round(y)}px`;
|
|
407
|
-
button.style.display = "block";
|
|
408
525
|
};
|
|
409
526
|
const hideButton = () => {
|
|
410
527
|
const button = document.getElementById(BUTTON_ID);
|
|
411
528
|
if (button !== null)
|
|
412
529
|
button.style.display = "none";
|
|
413
530
|
};
|
|
531
|
+
const dismissQuote = () => {
|
|
532
|
+
pendingQuote = null;
|
|
533
|
+
hideButton();
|
|
534
|
+
};
|
|
414
535
|
const onSelectionChange = () => {
|
|
415
536
|
const selection = document.getSelection();
|
|
416
537
|
const transcript = selectionInTranscript(selection);
|
|
417
538
|
if (transcript === null) {
|
|
418
|
-
|
|
419
|
-
hideButton();
|
|
539
|
+
dismissQuote();
|
|
420
540
|
return;
|
|
421
541
|
}
|
|
422
542
|
const text = selection.toString();
|
|
423
543
|
if (text === null || text.trim() === "") {
|
|
424
|
-
|
|
425
|
-
hideButton();
|
|
544
|
+
dismissQuote();
|
|
426
545
|
return;
|
|
427
546
|
}
|
|
428
|
-
const
|
|
547
|
+
const range = selection.getRangeAt(0);
|
|
548
|
+
const rect = range.getBoundingClientRect();
|
|
429
549
|
if (rect.width === 0 && rect.height === 0) {
|
|
430
|
-
|
|
550
|
+
dismissQuote();
|
|
431
551
|
return;
|
|
432
552
|
}
|
|
553
|
+
const startRow = messageRowOf(range.startContainer, transcript);
|
|
554
|
+
const endRow = messageRowOf(range.endContainer, transcript);
|
|
555
|
+
const sameRow = startRow !== null && startRow === endRow;
|
|
433
556
|
pendingQuote = {
|
|
434
557
|
text: text.trim(),
|
|
435
|
-
role: detectRole(
|
|
436
|
-
turn: detectTurn(
|
|
558
|
+
role: sameRow ? detectRole(range, transcript) : null,
|
|
559
|
+
turn: sameRow ? detectTurn(range, transcript) : null,
|
|
560
|
+
range: range.cloneRange()
|
|
437
561
|
};
|
|
438
562
|
placeButton(ensureButton(), rect);
|
|
439
563
|
};
|
|
@@ -441,19 +565,39 @@ window.__ModuleLoader__.load({
|
|
|
441
565
|
if (pendingQuote === null)
|
|
442
566
|
return;
|
|
443
567
|
const quote = pendingQuote;
|
|
444
|
-
pendingQuote = null;
|
|
445
568
|
hideButton();
|
|
446
569
|
const composer = findComposer();
|
|
447
570
|
if (composer === null) {
|
|
448
|
-
|
|
571
|
+
restoreSelection(quote.range);
|
|
572
|
+
showNotice("notice.composer-missing");
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (composer.disabled || composer.readOnly || composer.getAttribute("contenteditable") === "false" ||
|
|
576
|
+
(composer.isContentEditable === false && composer.tagName !== "TEXTAREA" && composer.tagName !== "INPUT")) {
|
|
577
|
+
restoreSelection(quote.range);
|
|
578
|
+
showNotice("notice.composer-locked");
|
|
449
579
|
return;
|
|
450
580
|
}
|
|
451
581
|
// Clear the transcript range BEFORE focusing the composer. Clearing after
|
|
452
582
|
// insertion destroys Lexical's caret and makes the next quote unreliable.
|
|
453
583
|
document.getSelection()?.removeAllRanges();
|
|
454
584
|
const payload = quotePayload(quote.text, quote.role, quote.turn);
|
|
455
|
-
|
|
456
|
-
|
|
585
|
+
try {
|
|
586
|
+
const inserted = quoteSourceReady ? appendQuoteChip(clientContext, composer, payload, currentT) : false;
|
|
587
|
+
if (inserted === false)
|
|
588
|
+
appendToComposer(composer, quoteFrame(payload, currentT));
|
|
589
|
+
if (inserted === null) {
|
|
590
|
+
restoreSelection(quote.range);
|
|
591
|
+
showNotice("notice.insert-failed");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
pendingQuote = null;
|
|
595
|
+
clearNotice();
|
|
596
|
+
} catch (error) {
|
|
597
|
+
console.warn("[dsh-quote-followup] quote insertion failed", error);
|
|
598
|
+
restoreSelection(quote.range);
|
|
599
|
+
showNotice("notice.insert-failed");
|
|
600
|
+
}
|
|
457
601
|
};
|
|
458
602
|
//#endregion
|
|
459
603
|
//#region apply
|
|
@@ -464,6 +608,7 @@ window.__ModuleLoader__.load({
|
|
|
464
608
|
return;
|
|
465
609
|
if (typeof previous?.dispose === "function")
|
|
466
610
|
previous.dispose();
|
|
611
|
+
clientContext = ctx;
|
|
467
612
|
let unregisterSource = null;
|
|
468
613
|
let unregisterLocale = null;
|
|
469
614
|
let unsubscribeLocale = null;
|
|
@@ -473,6 +618,9 @@ window.__ModuleLoader__.load({
|
|
|
473
618
|
button.textContent = currentT("button.label");
|
|
474
619
|
button.title = currentT("button.title");
|
|
475
620
|
}
|
|
621
|
+
const notice = document.getElementById(NOTICE_ID);
|
|
622
|
+
if (notice !== null)
|
|
623
|
+
notice.textContent = currentT(notice.getAttribute("data-message-key"));
|
|
476
624
|
};
|
|
477
625
|
try {
|
|
478
626
|
const locale = typeof ctx?.get === "function" ? ctx.get("locale") : null;
|
|
@@ -498,14 +646,14 @@ window.__ModuleLoader__.load({
|
|
|
498
646
|
} catch (error) {
|
|
499
647
|
console.warn("[dsh-quote-followup] native reference codec unavailable; using text fallback", error);
|
|
500
648
|
}
|
|
501
|
-
const onScroll = () =>
|
|
649
|
+
const onScroll = () => dismissQuote();
|
|
502
650
|
const onKeyDown = (event) => {
|
|
503
651
|
if (event.key === "Escape")
|
|
504
|
-
|
|
652
|
+
dismissQuote();
|
|
505
653
|
};
|
|
506
654
|
const onMouseDown = (event) => {
|
|
507
655
|
if (event.target instanceof Element && event.target.id !== BUTTON_ID)
|
|
508
|
-
|
|
656
|
+
dismissQuote();
|
|
509
657
|
};
|
|
510
658
|
let disposed = false;
|
|
511
659
|
let state;
|
|
@@ -513,6 +661,9 @@ window.__ModuleLoader__.load({
|
|
|
513
661
|
if (disposed)
|
|
514
662
|
return;
|
|
515
663
|
disposed = true;
|
|
664
|
+
clientContext = null;
|
|
665
|
+
dismissQuote();
|
|
666
|
+
clearNotice();
|
|
516
667
|
quoteSourceReady = false;
|
|
517
668
|
if (typeof unsubscribeLocale === "function")
|
|
518
669
|
unsubscribeLocale();
|
|
@@ -528,6 +679,9 @@ window.__ModuleLoader__.load({
|
|
|
528
679
|
const button = document.getElementById(BUTTON_ID);
|
|
529
680
|
if (button?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
|
|
530
681
|
button.remove();
|
|
682
|
+
const style = document.getElementById(STYLE_ID);
|
|
683
|
+
if (style?.getAttribute(STYLE_VERSION_ATTR) === CLIENT_VERSION)
|
|
684
|
+
style.remove();
|
|
531
685
|
if (globalThis[STATE] === state)
|
|
532
686
|
delete globalThis[STATE];
|
|
533
687
|
};
|
|
@@ -544,7 +698,7 @@ window.__ModuleLoader__.load({
|
|
|
544
698
|
ctx.effect(() => cleanup, "dsh-quote-followup: selection quote ui");
|
|
545
699
|
}
|
|
546
700
|
//#endregion
|
|
547
|
-
exports.inject = ["inputTriggers", "locale"];
|
|
701
|
+
exports.inject = ["inputTriggers", "locale", "sessions"];
|
|
548
702
|
exports.apply = apply;
|
|
549
703
|
return module.exports;
|
|
550
704
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-quote-followup",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Quote selected Web conversation text into the DeepSeek Harness composer for targeted follow-up turns.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"follow-up"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node --check lib/index.js && node --check lib/client.js && node test/web-repeat-harness.mjs"
|
|
14
|
+
"test": "node --check lib/index.js && node --check lib/client.js && node test/web-repeat-harness.mjs",
|
|
15
|
+
"test:assembled-web": "node test/run-assembled-web.mjs"
|
|
15
16
|
},
|
|
16
17
|
"type": "module",
|
|
17
18
|
"main": "lib/index.js",
|