dsh-quote-followup 0.2.1 → 0.2.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
@@ -7,9 +7,10 @@ A Web-only [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) p
7
7
  ## Features
8
8
 
9
9
  - Select text inside the Web conversation transcript to reveal a floating **Quote** button.
10
- - Append the selection as a Markdown quote without replacing the existing draft.
11
- - Repeat the action to collect multiple excerpts before sending.
12
- - Keep the Lexical editor model and rendered composer in sync.
10
+ - Append a native DSH conversation-reference chip without replacing the existing draft.
11
+ - Reuse the same atomic `ReferenceChipNode`, conversation icon, and business color as `@file` / `@session`.
12
+ - Quote multiple excerpts; on send, the plugin codec expands each chip into a model-readable Markdown blockquote.
13
+ - Fall back to a plain-text quote when the host lacks native chip support.
13
14
 
14
15
  TUI is intentionally unsupported.
15
16
 
@@ -19,14 +20,14 @@ TUI is intentionally unsupported.
19
20
  dsh plugin --profile web add dsh-quote-followup
20
21
  ```
21
22
 
22
- Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restart `dsh web` so the server rebuilds its boot-time client bundle.
23
+ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restart `dsh web` so the server rebuilds its boot-time client bundle. Reload or reopen browser tabs that stayed open across the restart; their already-loaded JavaScript cannot update itself from a new server process.
23
24
 
24
25
  ## Use
25
26
 
26
27
  1. Select text in a conversation message.
27
28
  2. Click **❐ Quote**.
28
29
  3. Select and quote more excerpts if needed.
29
- 4. Type the follow-up below the inserted quotes and send.
30
+ 4. Type the follow-up after the inserted chips and send.
30
31
 
31
32
  ## Development
32
33
 
@@ -34,7 +35,7 @@ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restar
34
35
  npm test
35
36
  ```
36
37
 
37
- The regression harness models the Lexical boundary that caused the historical “first quote works, second quote disappears” bug: once the editor has a block, the caret must be inside its last block rather than after the block at the editor root.
38
+ The regression harness covers native quote chips, 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.
38
39
 
39
40
  ## License
40
41
 
package/README.zh.md CHANGED
@@ -7,9 +7,10 @@
7
7
  ## 功能
8
8
 
9
9
  - 在 Web 对话区选中文本后显示浮动的 **❐ 引用** 按钮。
10
- - 以 Markdown 引用块追加到现有草稿,不覆盖已输入内容。
11
- - 可连续引用多段内容,再统一编辑并发送。
12
- - 通过 Lexical paste 路径更新编辑器模型,避免只改 DOM。
10
+ - 以 DSH 原生对话引用 chip 追加到现有草稿,不覆盖已输入内容。
11
+ - chip 使用与 `@文件` / `@对话` 相同的 `ReferenceChipNode`、对话图标和业务色,可整体删除。
12
+ - 可连续引用多段内容;发送时由插件 codec 将各 chip 展开为模型可读的 Markdown 引用块。
13
+ - 旧版 DSH 缺少原生 chip 能力时,自动降级为纯文本引用。
13
14
 
14
15
  本插件不再适配 TUI。
15
16
 
@@ -19,14 +20,14 @@
19
20
  dsh plugin --profile web add dsh-quote-followup
20
21
  ```
21
22
 
22
- 将 `dsh-quote-followup` 加入 Web profile 的 `dsh.profile.bundles`,然后重启 `dsh web`,让服务端重新生成启动时缓存的 client bundle。仅刷新页面不会替换旧服务进程中的 bundle。
23
+ 将 `dsh-quote-followup` 加入 Web profile 的 `dsh.profile.bundles`,然后重启 `dsh web`,让服务端重新生成启动时缓存的 client bundle。重启后还需刷新或重新打开此前一直未关闭的浏览器页;旧页面已经加载的 JavaScript 不会从新服务进程自动更新。
23
24
 
24
25
  ## 使用
25
26
 
26
27
  1. 在对话消息中划选任意片段。
27
28
  2. 点击 **❐ 引用**。
28
29
  3. 如需补充,可继续选中并引用其他片段。
29
- 4. 在引用块下输入追问并发送。
30
+ 4. 在引用 chip 后输入追问并发送。
30
31
 
31
32
  ## 开发验证
32
33
 
@@ -34,7 +35,7 @@ dsh plugin --profile web add dsh-quote-followup
34
35
  npm test
35
36
  ```
36
37
 
37
- 回归测试覆盖历史问题:“第一次引用成功,第二次消失”。根因是编辑器已有段落后,caret 不能放在 Lexical root block 之后,必须放进最后一个 block 内。
38
+ 回归测试覆盖原生引用 chip、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
38
39
 
39
40
  ## 许可证
40
41
 
package/lib/client.js CHANGED
@@ -32,6 +32,10 @@ window.__ModuleLoader__.load({
32
32
  '[data-input-scroll] textarea'
33
33
  ];
34
34
  const BUTTON_ID = "dsh-quote-followup-btn";
35
+ const BUTTON_VERSION_ATTR = "data-dsh-quote-followup-version";
36
+ const CLIENT_VERSION = "0.2.3";
37
+ /** Codec owner for native DSH reference chips. */
38
+ const QUOTE_SOURCE = "quote-followup";
35
39
  /** Per-quote character cap for a bounded composer insertion. */
36
40
  const QUOTE_MAX_CHARS = 1600;
37
41
  //#endregion
@@ -71,17 +75,54 @@ window.__ModuleLoader__.load({
71
75
  }
72
76
  return null;
73
77
  };
74
- /** Markdown blockquote frame for one quoted fragment. */
75
- const quoteFrame = (text, role) => {
78
+ const roleLabel = (role) => role === "user" ? "user" : role === "assistant" ? "assistant" : "对话";
79
+ /** Bound one quote before it becomes editor state. */
80
+ const quotePayload = (text, role) => {
76
81
  let body = String(text ?? "");
77
82
  let truncated = false;
78
83
  if (body.length > QUOTE_MAX_CHARS) {
79
84
  body = body.slice(0, QUOTE_MAX_CHARS);
80
85
  truncated = true;
81
86
  }
82
- const who = role === "user" ? "user" : role === "assistant" ? "assistant" : "对话";
83
- const lines = body.split("\n").map((line) => `> ${line}`.trimEnd());
84
- return `> [引用 · ${who}${truncated ? ",已截断" : ""}]\n${lines.join("\n")}\n\n`;
87
+ return { text: body, role, truncated };
88
+ };
89
+ /** Model / clipboard projection. The composer shows a chip, not this frame. */
90
+ const quoteFrame = ({ text, role, truncated }) => {
91
+ const lines = text.split("\n").map((line) => `> ${line}`.trimEnd());
92
+ return `> [引用 · ${roleLabel(role)}${truncated ? ",已截断" : ""}]\n${lines.join("\n")}\n\n`;
93
+ };
94
+ const decodeQuote = (ref) => {
95
+ const value = JSON.parse(ref);
96
+ if (value === null || typeof value !== "object" || typeof value.text !== "string")
97
+ throw new Error("invalid quote-followup reference");
98
+ return {
99
+ text: value.text,
100
+ role: value.role === "user" || value.role === "assistant" ? value.role : null,
101
+ truncated: value.truncated === true
102
+ };
103
+ };
104
+ const quoteInsert = (payload) => {
105
+ const clipboardText = quoteFrame(payload);
106
+ const preview = payload.text.replace(/\s+/g, " ").trim();
107
+ return {
108
+ source: QUOTE_SOURCE,
109
+ ref: JSON.stringify(payload),
110
+ label: `引用 · ${roleLabel(payload.role)}: ${preview}${payload.truncated ? "…" : ""}`,
111
+ appearance: "session",
112
+ clipboardText
113
+ };
114
+ };
115
+ /** Codec-only source: it owns quote chips but intentionally adds no @ candidates. */
116
+ const QUOTE_TRIGGER_SOURCE = {
117
+ trigger: "@",
118
+ name: QUOTE_SOURCE,
119
+ order: 1000,
120
+ candidates: () => Promise.resolve([]),
121
+ onPick: () => void 0,
122
+ codec: {
123
+ clipboardText: (ref) => quoteFrame(decodeQuote(ref)),
124
+ serialize: (ref) => Promise.resolve(quoteFrame(decodeQuote(ref)))
125
+ }
85
126
  };
86
127
  const findComposer = () => {
87
128
  for (const selector of COMPOSER_SELECTORS) {
@@ -91,7 +132,87 @@ window.__ModuleLoader__.load({
91
132
  }
92
133
  return null;
93
134
  };
94
- /** Dispatch a real paste path so stateful editors (Lexical) own the update. */
135
+ /**
136
+ * Insert through DSH's registered ReferenceChipNode so the quote uses the
137
+ * exact same atomic editor entity and visual treatment as @file/@session.
138
+ * The node class is already registered on the live composer; no second
139
+ * Lexical or React runtime is loaded by this plugin.
140
+ */
141
+ const appendQuoteChip = (element, payload) => {
142
+ const editor = element.__lexicalEditor;
143
+ const registry = editor?._nodes;
144
+ const ChipNode = registry?.get?.("reference-chip")?.klass;
145
+ const TextNode = registry?.get?.("text")?.klass;
146
+ const ParagraphNode = registry?.get?.("paragraph")?.klass;
147
+ if (typeof editor?.update !== "function" || typeof ChipNode !== "function" || typeof TextNode !== "function" || typeof ParagraphNode !== "function")
148
+ return false;
149
+ try {
150
+ let inserted = false;
151
+ editor.update(() => {
152
+ const state = editor._pendingEditorState ?? editor._editorState;
153
+ const root = state?._nodeMap?.get?.("root");
154
+ if (root === void 0 || root === null || typeof root.append !== "function")
155
+ return;
156
+ let block = typeof root.getLastChild === "function" ? root.getLastChild() : null;
157
+ if (block === null || typeof block.append !== "function") {
158
+ block = new ParagraphNode();
159
+ root.append(block);
160
+ }
161
+ const nodes = [];
162
+ const tail = typeof block.getTextContent === "function" ? block.getTextContent().slice(-1) : "";
163
+ if (tail !== "" && !/\s/u.test(tail))
164
+ nodes.push(new TextNode(" "));
165
+ nodes.push(new ChipNode(quoteInsert(payload)));
166
+ const trailing = new TextNode(" ");
167
+ nodes.push(trailing);
168
+ block.append(...nodes);
169
+ if (typeof trailing.selectEnd === "function")
170
+ trailing.selectEnd();
171
+ inserted = true;
172
+ }, { discrete: true });
173
+ if (inserted)
174
+ element.focus();
175
+ return inserted;
176
+ } catch (error) {
177
+ console.warn("[dsh-quote-followup] native reference chip failed; using text fallback", error);
178
+ return false;
179
+ }
180
+ };
181
+ /**
182
+ * Use the live Lexical command table when DSH exposes its editor on the root.
183
+ * This bypasses browser security differences around synthetic clipboardData
184
+ * while still entering through DSH's own PASTE_COMMAND → keyboard.paste path.
185
+ */
186
+ const pasteViaLexical = (element, text) => {
187
+ const editor = element.__lexicalEditor;
188
+ if (editor === null || typeof editor !== "object" || typeof editor.dispatchCommand !== "function")
189
+ return false;
190
+ const commands = editor._commands;
191
+ if (commands === null || typeof commands !== "object" || typeof commands.keys !== "function")
192
+ return false;
193
+ const command = [...commands.keys()].find((candidate) => candidate?.type === "PASTE_COMMAND");
194
+ if (command === undefined)
195
+ return false;
196
+ const clipboardData = {
197
+ files: [],
198
+ items: [],
199
+ types: ["text/plain"],
200
+ getData: (type) => type === "text/plain" ? text : ""
201
+ };
202
+ const event = {
203
+ clipboardData,
204
+ defaultPrevented: false,
205
+ preventDefault() { this.defaultPrevented = true; },
206
+ stopPropagation() {}
207
+ };
208
+ try {
209
+ return editor.dispatchCommand(command, event) === true;
210
+ } catch (error) {
211
+ console.warn("[dsh-quote-followup] Lexical paste command failed; using DOM event fallback", error);
212
+ return false;
213
+ }
214
+ };
215
+ /** Dispatch a DOM paste fallback for non-DSH stateful editors. */
95
216
  const pasteIntoEditor = (element, text) => {
96
217
  let clipboardData = null;
97
218
  try {
@@ -149,6 +270,8 @@ window.__ModuleLoader__.load({
149
270
  range.collapse(false);
150
271
  selection?.removeAllRanges();
151
272
  selection?.addRange(range);
273
+ if (pasteViaLexical(element, insertText))
274
+ return;
152
275
  if (pasteIntoEditor(element, insertText))
153
276
  return;
154
277
  if (typeof document.execCommand === "function" && document.execCommand("insertText", false, insertText))
@@ -160,12 +283,15 @@ window.__ModuleLoader__.load({
160
283
  //#endregion
161
284
  //#region floating button
162
285
  let pendingQuote = null;
286
+ let quoteSourceReady = false;
163
287
  const ensureButton = () => {
164
288
  const existing = document.getElementById(BUTTON_ID);
165
- if (existing !== null)
289
+ if (existing?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
166
290
  return existing;
291
+ existing?.remove();
167
292
  const button = document.createElement("button");
168
293
  button.id = BUTTON_ID;
294
+ button.setAttribute(BUTTON_VERSION_ATTR, CLIENT_VERSION);
169
295
  button.type = "button";
170
296
  button.textContent = "❐ 引用";
171
297
  button.title = "引用选中内容到输入框(可多次引用,发送前可编辑)";
@@ -247,15 +373,29 @@ window.__ModuleLoader__.load({
247
373
  // Clear the transcript range BEFORE focusing the composer. Clearing after
248
374
  // insertion destroys Lexical's caret and makes the next quote unreliable.
249
375
  document.getSelection()?.removeAllRanges();
250
- appendToComposer(composer, quoteFrame(quote.text, quote.role));
376
+ const payload = quotePayload(quote.text, quote.role);
377
+ if (!(quoteSourceReady && appendQuoteChip(composer, payload)))
378
+ appendToComposer(composer, quoteFrame(payload));
251
379
  };
252
380
  //#endregion
253
381
  //#region apply
254
- const MOUNTED = Symbol.for("dsh-quote-followup.mounted");
382
+ const STATE = Symbol.for("dsh-quote-followup.state");
255
383
  function apply(ctx) {
256
- if (globalThis[MOUNTED] === true)
384
+ const previous = globalThis[STATE];
385
+ if (previous?.version === CLIENT_VERSION)
257
386
  return;
258
- globalThis[MOUNTED] = true;
387
+ if (typeof previous?.dispose === "function")
388
+ previous.dispose();
389
+ let unregisterSource = null;
390
+ try {
391
+ const inputTriggers = typeof ctx?.get === "function" ? ctx.get("inputTriggers") : null;
392
+ if (typeof inputTriggers?.registerSource === "function") {
393
+ unregisterSource = inputTriggers.registerSource(QUOTE_TRIGGER_SOURCE);
394
+ quoteSourceReady = true;
395
+ }
396
+ } catch (error) {
397
+ console.warn("[dsh-quote-followup] native reference codec unavailable; using text fallback", error);
398
+ }
259
399
  const onScroll = () => hideButton();
260
400
  const onKeyDown = (event) => {
261
401
  if (event.key === "Escape")
@@ -265,24 +405,39 @@ window.__ModuleLoader__.load({
265
405
  if (event.target instanceof Element && event.target.id !== BUTTON_ID)
266
406
  hideButton();
267
407
  };
268
- document.addEventListener("selectionchange", onSelectionChange);
269
- window.addEventListener("scroll", onScroll, true);
270
- document.addEventListener("keydown", onKeyDown, true);
271
- document.addEventListener("mousedown", onMouseDown, true);
408
+ let disposed = false;
409
+ let state;
272
410
  const cleanup = () => {
273
- globalThis[MOUNTED] = false;
411
+ if (disposed)
412
+ return;
413
+ disposed = true;
414
+ quoteSourceReady = false;
415
+ if (typeof unregisterSource === "function")
416
+ unregisterSource();
274
417
  document.removeEventListener("selectionchange", onSelectionChange);
275
418
  window.removeEventListener("scroll", onScroll, true);
276
419
  document.removeEventListener("keydown", onKeyDown, true);
277
420
  document.removeEventListener("mousedown", onMouseDown, true);
278
- hideButton();
279
- document.getElementById(BUTTON_ID)?.remove();
421
+ const button = document.getElementById(BUTTON_ID);
422
+ if (button?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
423
+ button.remove();
424
+ if (globalThis[STATE] === state)
425
+ delete globalThis[STATE];
280
426
  };
427
+ state = { version: CLIENT_VERSION, dispose: cleanup };
428
+ globalThis[STATE] = state;
429
+ document.addEventListener("selectionchange", onSelectionChange);
430
+ window.addEventListener("scroll", onScroll, true);
431
+ document.addEventListener("keydown", onKeyDown, true);
432
+ document.addEventListener("mousedown", onMouseDown, true);
433
+ // Claim the shared id immediately so an older listener in a long-lived tab
434
+ // cannot keep its obsolete click handler after a client hot-swap.
435
+ ensureButton();
281
436
  if (ctx !== undefined && typeof ctx.effect === "function")
282
437
  ctx.effect(() => cleanup, "dsh-quote-followup: selection quote ui");
283
438
  }
284
439
  //#endregion
285
- exports.inject = [];
440
+ exports.inject = ["inputTriggers"];
286
441
  exports.apply = apply;
287
442
  return module.exports;
288
443
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-followup",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Quote selected Web conversation text into the DeepSeek Harness composer for targeted follow-up turns.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -44,13 +44,15 @@
44
44
  },
45
45
  "dsh": {
46
46
  "engines": {
47
- "dsh": ">=0.1.1-rc.1"
47
+ "dsh": ">=0.1.2-rc.1"
48
48
  },
49
49
  "bundle": {
50
50
  "patch": "./cordis.patch.yml"
51
51
  },
52
52
  "client": {
53
- "inject": [],
53
+ "inject": [
54
+ "@deepseek-ai/dsh-client-ui-input-trigger"
55
+ ],
54
56
  "platform": "web"
55
57
  }
56
58
  }