dsh-select-quote 0.3.2 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/lib/client.js +334 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -51,7 +51,7 @@ dsh web --patch "$PWD/dev.patch.yml"
51
51
 
52
52
  ## 已知限制
53
53
 
54
- - **待发批注只在内存里**:刷新页面或切换会话后会丢,卡片不持久化;已发送消息里的批注不受影响。
54
+ - **待发批注按会话持久化**:刷新页面、重启客户端后回到原会话,未发送的批注会原样回来(和草稿同一套机制);保留最近 20 个会话、7 天。发送即清除,切换会话跟着切,hero 里换工作区等于换会话,批注不跟着搬。
55
55
  - **占用了用户气泡的渲染**:本插件以最低优先级替换内置 `user` 节点。其它插件若替换同一个键(例如 `dsh-easyrewrite` 用 `-1`),会被本插件屏蔽。
56
56
  - **卡片宽度有限**:输入框内每条摘要最宽约为输入框的 1/4,超长标题走省略号。
57
57
  - **划词只在对话区生效**:不介入编辑器、终端等面板。
@@ -92,7 +92,7 @@ pnpm run sync:desktop -- --watch # 常驻:lib/ 一变就推
92
92
  pnpm dev:desktop # 增量重建 + 推送,一条命令
93
93
  ```
94
94
 
95
- 目标路径按 `$DSH_HOME`(默认 `~/.dsh`)与 profile 名推导,profile 用 `--profile <name>` 或 `$DSH_PROFILE` 指定,默认 `desktop`。字节没变就不写,避免空重载。重载会重置插件的内存状态,未发送的批注会丢。
95
+ 目标路径按 `$DSH_HOME`(默认 `~/.dsh`)与 profile 名推导,profile 用 `--profile <name>` 或 `$DSH_PROFILE` 指定,默认 `desktop`。字节没变就不写,避免空重载。模块级重载会重置内存状态,但未发送的批注已按会话持久化,重新挂载后会自己回来。
96
96
 
97
97
  发布:先把源码与产物 `lib/` 一起提交,再跑
98
98
 
package/lib/client.js CHANGED
@@ -975,9 +975,17 @@ function collectCleanText(root) {
975
975
  return entries;
976
976
  }
977
977
  function flowItemOf(node) {
978
- const item = (node instanceof Element ? node : node.parentElement)?.closest(FLOW_ITEM);
978
+ const item = (node instanceof Element ? node : node?.parentElement)?.closest(FLOW_ITEM);
979
979
  return item instanceof HTMLElement ? item : null;
980
980
  }
981
+ /**
982
+ * True when the node lives inside a conversation message. Selections in the
983
+ * sidebar, settings, model picker or any other app chrome are not quotes, so
984
+ * the toolbar must stay away from them.
985
+ */
986
+ function isInsideTranscript(node) {
987
+ return flowItemOf(node) !== null;
988
+ }
981
989
  function pointOffset(entries, container, offset) {
982
990
  for (const entry of entries) if (entry.node === container) return entry.start + Math.min(offset, entry.end - entry.start);
983
991
  if (!(container instanceof Element)) return null;
@@ -1317,22 +1325,6 @@ function findElementByText(text) {
1317
1325
  }
1318
1326
  return best;
1319
1327
  }
1320
- //#endregion
1321
- //#region src/client/runtime.ts
1322
- /**
1323
- * Shared runtime handles across slot scopes.
1324
- * The selection toolbar mounts at root (`shell.overlay`) while the annotation
1325
- * panel is session-scoped — pending annotations use one shared store key so
1326
- * both sides always read the same list.
1327
- */
1328
- const runtime = {};
1329
- function setRuntimeSession(sessionId) {
1330
- if (sessionId) runtime.sessionId = sessionId;
1331
- }
1332
- /** Store partition for pending annotations (single active composer). */
1333
- function annotationStoreKey() {
1334
- return "pending";
1335
- }
1336
1328
  const DRAFT_MARKER_RE = /\u200B/g;
1337
1329
  /** Plain text from Chat user-node content blocks (string or block array). */
1338
1330
  function contentBlocksToText(content) {
@@ -1525,11 +1517,27 @@ function scanAnnotatedMessage(text) {
1525
1517
  };
1526
1518
  }
1527
1519
  //#endregion
1520
+ //#region src/client/runtime.ts
1521
+ /**
1522
+ * Shared runtime handles across slot scopes.
1523
+ *
1524
+ * The selection toolbar mounts at root (`shell.overlay`) while the annotation
1525
+ * panel is session-scoped, so both sides read one partition keyed by the
1526
+ * session the host minted for the active composer.
1527
+ */
1528
+ const runtime = {};
1529
+ function setRuntimeSession(sessionId) {
1530
+ if (sessionId) runtime.sessionId = sessionId;
1531
+ }
1532
+ //#endregion
1528
1533
  //#region src/client/state/annotation-store.ts
1529
1534
  /** Per-session pending selection annotations (composer-side store). */
1530
1535
  const EMPTY$1 = [];
1536
+ /** Bucket for annotations added before a session id is known (never persisted). */
1537
+ const MEMORY_KEY = "pending";
1531
1538
  const bySession = /* @__PURE__ */ new Map();
1532
1539
  const listeners = /* @__PURE__ */ new Set();
1540
+ let persistence = null;
1533
1541
  function notify() {
1534
1542
  for (const listener of [...listeners]) listener();
1535
1543
  }
@@ -1539,12 +1547,25 @@ function subscribeAnnotations(listener) {
1539
1547
  listeners.delete(listener);
1540
1548
  };
1541
1549
  }
1542
- function key(sessionId) {
1543
- return sessionId ?? annotationStoreKey();
1550
+ /**
1551
+ * Store partition: the live session when one is bound, otherwise a memory-only
1552
+ * bucket. The host always mints a session before the composer accepts text, so
1553
+ * the bucket is the "cannot happen" path and never reaches storage.
1554
+ */
1555
+ function partition(sessionId) {
1556
+ return sessionId ?? runtime.sessionId ?? MEMORY_KEY;
1557
+ }
1558
+ /** Attach the persistence port (null detaches it). */
1559
+ function setAnnotationPersistence(port) {
1560
+ persistence = port;
1561
+ }
1562
+ function persist(partitionKey) {
1563
+ if (!persistence || partitionKey === MEMORY_KEY) return;
1564
+ persistence.save(partitionKey, bySession.get(partitionKey) ?? EMPTY$1);
1544
1565
  }
1545
1566
  /** Append one selection; an identical text+comment pair is reused. */
1546
1567
  function saveAnnotation(sessionId, input) {
1547
- const k = key(sessionId);
1568
+ const k = partition(sessionId);
1548
1569
  const comment = input.comment?.trim() ?? "";
1549
1570
  const current = bySession.get(k) ?? EMPTY$1;
1550
1571
  const duplicate = current.find((item) => item.text === input.text && (item.comment ?? "") === comment);
@@ -1556,14 +1577,15 @@ function saveAnnotation(sessionId, input) {
1556
1577
  sources: input.sources?.length ? input.sources : [{ text: input.text }]
1557
1578
  };
1558
1579
  bySession.set(k, [...current, annotation]);
1580
+ persist(k);
1559
1581
  notify();
1560
1582
  return annotation;
1561
1583
  }
1562
1584
  function getAnnotations(sessionId) {
1563
- return bySession.get(key(sessionId)) ?? EMPTY$1;
1585
+ return bySession.get(partition(sessionId)) ?? EMPTY$1;
1564
1586
  }
1565
1587
  function updateAnnotationComment(sessionId, id, comment) {
1566
- const k = key(sessionId);
1588
+ const k = partition(sessionId);
1567
1589
  const current = bySession.get(k);
1568
1590
  if (!current) return;
1569
1591
  const next = current.map((item) => {
@@ -1578,20 +1600,59 @@ function updateAnnotationComment(sessionId, id, comment) {
1578
1600
  };
1579
1601
  });
1580
1602
  bySession.set(k, next);
1603
+ persist(k);
1581
1604
  notify();
1582
1605
  }
1583
1606
  function removeAnnotation(sessionId, id) {
1584
- const k = key(sessionId);
1607
+ const k = partition(sessionId);
1585
1608
  const current = bySession.get(k);
1586
1609
  if (!current) return;
1587
1610
  const next = current.filter((item) => item.id !== id);
1588
1611
  if (next.length === current.length) return;
1589
1612
  if (next.length === 0) bySession.delete(k);
1590
1613
  else bySession.set(k, next);
1614
+ if (next.length === 0 && k !== MEMORY_KEY) persistence?.clear(k);
1615
+ else persist(k);
1591
1616
  notify();
1592
1617
  }
1593
1618
  function clearAnnotations(sessionId) {
1594
- if (bySession.delete(key(sessionId))) notify();
1619
+ const k = partition(sessionId);
1620
+ if (!bySession.delete(k)) return;
1621
+ if (k !== MEMORY_KEY) persistence?.clear(k);
1622
+ notify();
1623
+ }
1624
+ /**
1625
+ * Bind the active session and adopt whatever it left unsent.
1626
+ *
1627
+ * Called from render-phase bridges, so adoption is deferred to a microtask:
1628
+ * the resulting notify() must not land inside another component's render.
1629
+ * Re-binding the same session is a no-op.
1630
+ */
1631
+ function bindAnnotationSession(sessionId) {
1632
+ if (!sessionId || sessionId === runtime.sessionId) return;
1633
+ setRuntimeSession(sessionId);
1634
+ queueMicrotask(() => adoptPersistedAnnotations(sessionId));
1635
+ }
1636
+ /**
1637
+ * Adopt persisted annotations for a session.
1638
+ *
1639
+ * In-memory entries win on conflict — they can only be newer, because anything
1640
+ * added after the bind is persisted immediately. Annotations parked in the
1641
+ * memory-only bucket (added before the bind) move over as well.
1642
+ */
1643
+ function adoptPersistedAnnotations(sessionId) {
1644
+ if (!persistence) return;
1645
+ const stored = persistence.load(sessionId);
1646
+ const current = bySession.get(sessionId) ?? EMPTY$1;
1647
+ const orphaned = bySession.get(MEMORY_KEY) ?? EMPTY$1;
1648
+ const merged = /* @__PURE__ */ new Map();
1649
+ for (const item of stored) merged.set(item.id, item);
1650
+ for (const item of [...current, ...orphaned]) merged.set(item.id, item);
1651
+ const next = [...merged.values()];
1652
+ if (next.length === current.length && next.every((item, i) => item.id === current[i]?.id) && orphaned.length === 0) return;
1653
+ bySession.set(sessionId, next);
1654
+ if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
1655
+ notify();
1595
1656
  }
1596
1657
  function annotationTitle(annotation) {
1597
1658
  return previewTitle(annotation.text);
@@ -2347,6 +2408,31 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
2347
2408
  //#region src/client/composer/AnnotationPanel.tsx
2348
2409
  const EMPTY = [];
2349
2410
  /**
2411
+ * True while an IME composition is in flight.
2412
+ *
2413
+ * Composition events bubble, so a capture listener on the document sees them
2414
+ * wherever the composer lives. Anything that writes the draft must check this:
2415
+ * replacing editor content mid-composition drops the composing text.
2416
+ */
2417
+ function useComposing() {
2418
+ const composing = (0, react.useRef)(false);
2419
+ (0, react.useEffect)(() => {
2420
+ const onStart = () => {
2421
+ composing.current = true;
2422
+ };
2423
+ const onEnd = () => {
2424
+ composing.current = false;
2425
+ };
2426
+ document.addEventListener("compositionstart", onStart, true);
2427
+ document.addEventListener("compositionend", onEnd, true);
2428
+ return () => {
2429
+ document.removeEventListener("compositionstart", onStart, true);
2430
+ document.removeEventListener("compositionend", onEnd, true);
2431
+ };
2432
+ }, []);
2433
+ return composing;
2434
+ }
2435
+ /**
2350
2436
  * Composer annotation summary (`conversation.input.overlay`).
2351
2437
  *
2352
2438
  * Pending selections never enter the editor as text: the summary card is the
@@ -2360,6 +2446,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2360
2446
  const draftRef = (0, react.useRef)(draft);
2361
2447
  const actionsRef = (0, react.useRef)(inputActions);
2362
2448
  const sessionRef = (0, react.useRef)(sessionId);
2449
+ const composing = useComposing();
2363
2450
  draftRef.current = draft;
2364
2451
  actionsRef.current = inputActions;
2365
2452
  sessionRef.current = sessionId;
@@ -2370,7 +2457,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2370
2457
  setItems(getAnnotations());
2371
2458
  }, []);
2372
2459
  (0, react.useEffect)(() => {
2373
- if (sessionId) setRuntimeSession(sessionId);
2460
+ bindAnnotationSession(sessionId);
2374
2461
  }, [sessionId]);
2375
2462
  (0, react.useEffect)(() => {
2376
2463
  ensureToolbarStyles();
@@ -2406,6 +2493,19 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2406
2493
  setDraft,
2407
2494
  onFolded: () => setItems(EMPTY)
2408
2495
  });
2496
+ (0, react.useEffect)(() => {
2497
+ if (composing.current) return;
2498
+ if (items.length === 0) {
2499
+ if (draft.includes("​")) setDraft(stripDraftMarker(draft));
2500
+ return;
2501
+ }
2502
+ if (!draft.includes("​") && draft.trim() === "") setDraft(withDraftMarker(draft));
2503
+ }, [
2504
+ items,
2505
+ draft,
2506
+ setDraft,
2507
+ composing
2508
+ ]);
2409
2509
  const onRemoveAll = (0, react.useCallback)(() => {
2410
2510
  clearAnnotations();
2411
2511
  setItems(EMPTY);
@@ -2505,7 +2605,8 @@ function toolbarPosition(rect) {
2505
2605
  };
2506
2606
  }
2507
2607
  /**
2508
- * Read the live selection when it is a non-empty range outside the composer.
2608
+ * Read the live selection when it is a non-empty range inside a conversation
2609
+ * message and outside the composer / our own chrome.
2509
2610
  */
2510
2611
  function readSelectionSnapshot(toolbarRoot) {
2511
2612
  const selection = window.getSelection();
@@ -2514,6 +2615,7 @@ function readSelectionSnapshot(toolbarRoot) {
2514
2615
  if (!anchorNode || !focusNode) return null;
2515
2616
  if (isInsideComposerInput(anchorNode) || isInsideComposerInput(focusNode)) return null;
2516
2617
  if (toolbarRoot?.contains(anchorNode) || toolbarRoot?.contains(focusNode)) return null;
2618
+ if (!isInsideTranscript(anchorNode) || !isInsideTranscript(focusNode)) return null;
2517
2619
  const text = selection.toString().replace(/\r\n/g, "\n").trim();
2518
2620
  if (!text) return null;
2519
2621
  const rect = getRangeFirstLineRect(selection.getRangeAt(0));
@@ -2562,7 +2664,7 @@ function SelectionToolbar({ sessionId }) {
2562
2664
  activeRef.current = active;
2563
2665
  (0, react.useEffect)(() => {
2564
2666
  ensureToolbarStyles();
2565
- if (sessionId) setRuntimeSession(sessionId);
2667
+ if (sessionId) bindAnnotationSession(sessionId);
2566
2668
  }, [sessionId]);
2567
2669
  const hide = (0, react.useCallback)(() => {
2568
2670
  setActive(null);
@@ -3033,6 +3135,204 @@ function UserMessageDisplay({ node, renderMessageImages }) {
3033
3135
  });
3034
3136
  }
3035
3137
  //#endregion
3138
+ //#region src/client/state/annotation-persistence.ts
3139
+ /** localStorage prefix; a session id is appended as `.<sessionId>`. */
3140
+ const ANNOTATION_KEY_PREFIX = "dsh.select-quote.annotations";
3141
+ const PAYLOAD_VERSION = 1;
3142
+ function keyForSession(sessionId) {
3143
+ return `${ANNOTATION_KEY_PREFIX}.${sessionId}`;
3144
+ }
3145
+ function sessionIdOfKey(key) {
3146
+ const prefix = `${ANNOTATION_KEY_PREFIX}.`;
3147
+ return key.startsWith(prefix) ? key.slice(prefix.length) : null;
3148
+ }
3149
+ function isAnnotation(value) {
3150
+ if (!value || typeof value !== "object") return false;
3151
+ const item = value;
3152
+ return typeof item.id === "string" && typeof item.text === "string" && Array.isArray(item.sources);
3153
+ }
3154
+ /** Tolerant read: a corrupt or foreign payload degrades to "nothing stored". */
3155
+ function itemsOf(value) {
3156
+ if (!value || typeof value !== "object") return [];
3157
+ const payload = value;
3158
+ if (!Array.isArray(payload.items)) return [];
3159
+ return payload.items.filter(isAnnotation);
3160
+ }
3161
+ function encode(items, now) {
3162
+ return JSON.stringify({
3163
+ v: PAYLOAD_VERSION,
3164
+ updatedAt: now,
3165
+ items
3166
+ });
3167
+ }
3168
+ function decode(raw) {
3169
+ if (raw === null) return [];
3170
+ try {
3171
+ return itemsOf(JSON.parse(raw));
3172
+ } catch {
3173
+ return [];
3174
+ }
3175
+ }
3176
+ /** One warn per session, never a throw: a full quota must not break typing. */
3177
+ let quotaWarned = false;
3178
+ function warnQuota() {
3179
+ if (quotaWarned) return;
3180
+ quotaWarned = true;
3181
+ console.warn("dsh-select-quote: annotation persistence disabled (storage write failed)");
3182
+ }
3183
+ /**
3184
+ * Direct localStorage backend (the documented fallback). Writes are wrapped so
3185
+ * a quota failure degrades to memory-only instead of breaking the composer.
3186
+ */
3187
+ function createStoragePersistence(storage) {
3188
+ let degraded = false;
3189
+ return {
3190
+ load: (sessionId) => decode(storage.getItem(keyForSession(sessionId))),
3191
+ save: (sessionId, items) => {
3192
+ if (degraded) return;
3193
+ try {
3194
+ storage.setItem(keyForSession(sessionId), encode(items, Date.now()));
3195
+ } catch {
3196
+ degraded = true;
3197
+ warnQuota();
3198
+ }
3199
+ },
3200
+ clear: (sessionId) => {
3201
+ try {
3202
+ storage.removeItem(keyForSession(sessionId));
3203
+ } catch {}
3204
+ },
3205
+ prune: (now) => pruneSessions(storage, now)
3206
+ };
3207
+ }
3208
+ /**
3209
+ * Host store backend: the same `defineStore` the composer draft uses, so the
3210
+ * write path, per-session keying and failure semantics are the host's.
3211
+ */
3212
+ function createHostStorePersistence(defineStore, storage) {
3213
+ const handle = defineStore({
3214
+ init: () => ({
3215
+ v: PAYLOAD_VERSION,
3216
+ updatedAt: 0,
3217
+ items: []
3218
+ }),
3219
+ persist: ANNOTATION_KEY_PREFIX,
3220
+ actions: { replace(draft, items, now) {
3221
+ draft.items = [...items];
3222
+ draft.updatedAt = now;
3223
+ } }
3224
+ });
3225
+ const instances = /* @__PURE__ */ new Map();
3226
+ const instanceOf = (sessionId) => {
3227
+ const cached = instances.get(sessionId);
3228
+ if (cached) return cached;
3229
+ const instance = handle.create(sessionId);
3230
+ instances.set(sessionId, instance);
3231
+ return instance;
3232
+ };
3233
+ return {
3234
+ load: (sessionId) => itemsOf(instanceOf(sessionId).getSnapshot()),
3235
+ save: (sessionId, items) => instanceOf(sessionId).actions.replace(items, Date.now()),
3236
+ clear: (sessionId) => {
3237
+ instanceOf(sessionId).clearPersisted();
3238
+ instances.delete(sessionId);
3239
+ },
3240
+ prune: (now) => {
3241
+ const removed = pruneSessions(storage, now);
3242
+ for (const sessionId of removed) instances.delete(sessionId);
3243
+ return removed;
3244
+ }
3245
+ };
3246
+ }
3247
+ /** Retention: newest `maxSessions` within `maxAgeMs`, by payload timestamp. */
3248
+ function pruneSessions(storage, now = Date.now(), options = {}) {
3249
+ const maxSessions = options.maxSessions ?? 20;
3250
+ const maxAgeMs = options.maxAgeMs ?? 6048e5;
3251
+ const rows = [];
3252
+ for (const key of storage.keys()) {
3253
+ const sessionId = sessionIdOfKey(key);
3254
+ if (sessionId === null) continue;
3255
+ const raw = storage.getItem(key);
3256
+ let updatedAt = 0;
3257
+ try {
3258
+ const parsed = JSON.parse(raw ?? "null");
3259
+ if (parsed && typeof parsed.updatedAt === "number") updatedAt = parsed.updatedAt;
3260
+ } catch {
3261
+ updatedAt = 0;
3262
+ }
3263
+ rows.push({
3264
+ sessionId,
3265
+ updatedAt
3266
+ });
3267
+ }
3268
+ const removed = /* @__PURE__ */ new Set();
3269
+ for (const row of rows) if (now - row.updatedAt > maxAgeMs) removed.add(row.sessionId);
3270
+ rows.sort((a, b) => b.updatedAt - a.updatedAt);
3271
+ for (const row of rows.slice(maxSessions)) removed.add(row.sessionId);
3272
+ for (const sessionId of removed) storage.removeItem(keyForSession(sessionId));
3273
+ return [...removed];
3274
+ }
3275
+ function createNoopPersistence() {
3276
+ return {
3277
+ load: () => [],
3278
+ save: () => {},
3279
+ clear: () => {},
3280
+ prune: () => []
3281
+ };
3282
+ }
3283
+ //#endregion
3284
+ //#region src/client/state/annotation-storage.ts
3285
+ /**
3286
+ * Backend selection for annotation persistence.
3287
+ *
3288
+ * Kept apart from `annotation-persistence.ts` because reaching the host's
3289
+ * client-store needs a guarded `require`, and that is the one thing the pure
3290
+ * module cannot do — verify-bundle imports the pure module straight into Node,
3291
+ * where a `declare` statement is not valid JavaScript.
3292
+ *
3293
+ * Preference order: the host store (same engine and keying as the composer
3294
+ * draft), plain localStorage, and a no-op when the environment has no storage
3295
+ * at all.
3296
+ */
3297
+ /** The host package is a platform seed word; older hosts may not carry it. */
3298
+ const HOST_STORE_MODULE = "@deepseek-ai/dsh-client-store";
3299
+ function hostDefineStore() {
3300
+ try {
3301
+ const loaded = require(HOST_STORE_MODULE);
3302
+ return typeof loaded?.defineStore === "function" ? loaded.defineStore : null;
3303
+ } catch {
3304
+ return null;
3305
+ }
3306
+ }
3307
+ function browserStorage() {
3308
+ try {
3309
+ const storage = globalThis.localStorage;
3310
+ if (!storage) return null;
3311
+ return {
3312
+ getItem: (key) => storage.getItem(key),
3313
+ setItem: (key, value) => storage.setItem(key, value),
3314
+ removeItem: (key) => storage.removeItem(key),
3315
+ keys: () => {
3316
+ const out = [];
3317
+ for (let i = 0; i < storage.length; i += 1) {
3318
+ const key = storage.key(i);
3319
+ if (key !== null) out.push(key);
3320
+ }
3321
+ return out;
3322
+ }
3323
+ };
3324
+ } catch {
3325
+ return null;
3326
+ }
3327
+ }
3328
+ function createAnnotationPersistence() {
3329
+ const storage = browserStorage();
3330
+ const defineStore = hostDefineStore();
3331
+ if (storage && defineStore) return createHostStorePersistence(defineStore, storage);
3332
+ if (storage) return createStoragePersistence(storage);
3333
+ return createNoopPersistence();
3334
+ }
3335
+ //#endregion
3036
3336
  //#region src/client/index.tsx
3037
3337
  /**
3038
3338
  * Browser half of dsh-select-quote.
@@ -3077,6 +3377,12 @@ function apply(ctx) {
3077
3377
  syncPendingAnnotationJobs(getAnnotations());
3078
3378
  return subscribeAnnotations(() => syncPendingAnnotationJobs(getAnnotations()));
3079
3379
  }, "dsh-select-quote: pending annotation marks");
3380
+ ctx.effect(() => {
3381
+ const port = createAnnotationPersistence();
3382
+ setAnnotationPersistence(port);
3383
+ port.prune();
3384
+ return () => setAnnotationPersistence(null);
3385
+ }, "dsh-select-quote: annotation persistence");
3080
3386
  ctx.slots.inject("shell.overlay", () => {
3081
3387
  ctx.slots.register({
3082
3388
  name: "shell.overlay",
@@ -3108,7 +3414,7 @@ function apply(ctx) {
3108
3414
  }
3109
3415
  /** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
3110
3416
  function SessionBridge({ sessionId }) {
3111
- setRuntimeSession(sessionId);
3417
+ bindAnnotationSession(sessionId);
3112
3418
  return null;
3113
3419
  }
3114
3420
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-select-quote",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",