dsh-side-memo 1.2.0 → 1.3.1

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/client.js CHANGED
@@ -134,6 +134,12 @@ window.__ModuleLoader__.load({
134
134
  'favorite.remove': '取消收藏',
135
135
  'favorite.pinned': '已收藏并置顶',
136
136
  'favorite.unpinned': '已取消收藏',
137
+ // Right-click menu on a memo row.
138
+ 'menu.label': '便签操作',
139
+ 'menu.open': '打开',
140
+ 'menu.duplicate': '复制一份',
141
+ 'menu.deleted': '已删除便签',
142
+ 'menu.duplicated': '已复制为新便签',
137
143
  'image.add': '插入图片',
138
144
  'image.remove': '移除图片',
139
145
  'image.memo': '图片便签',
@@ -153,7 +159,24 @@ window.__ModuleLoader__.load({
153
159
  'image.resize': '调整图片大小',
154
160
  'image.size': '尺寸 {width} × {height}',
155
161
  'image.resetSize': '还原原始大小',
156
- 'image.sizeReset': '已还原图片大小'
162
+ 'image.sizeReset': '已还原图片大小',
163
+ // Saving a whole conversation as a memo.
164
+ 'chat.create': '保存为便签',
165
+ 'chat.createAria': '把这段对话保存为便签',
166
+ 'chat.saved': '已保存为便签',
167
+ 'chat.empty': '这段对话还没有可保存的内容。',
168
+ 'chat.limit': '便签数量已达上限,先删除一些再保存。',
169
+ 'chat.fallbackTitle': '对话便签',
170
+ 'chat.you': '你',
171
+ 'chat.assistant': '助手',
172
+ 'chat.emptyPrompts': '你还没有发送任何内容。',
173
+ 'chat.menu.label': '保存对话的方式',
174
+ 'chat.menu.chat': '保存整段对话',
175
+ 'chat.menu.chatNote': '我的提问和助手的回答',
176
+ 'chat.menu.prompts': '只保存我的提问',
177
+ 'chat.menu.promptsNote': '保留你发送过的内容',
178
+ 'prompt.create': '把这条提问存成便签',
179
+ 'prompt.createAria': '把这条提问保存为便签'
157
180
  };
158
181
  var en = {
159
182
  'type.label': 'Side Memo',
@@ -221,6 +244,12 @@ window.__ModuleLoader__.load({
221
244
  'favorite.remove': 'Remove from favorites',
222
245
  'favorite.pinned': 'Added to favorites and pinned to top',
223
246
  'favorite.unpinned': 'Removed from favorites',
247
+ // Right-click menu on a memo row.
248
+ 'menu.label': 'Memo actions',
249
+ 'menu.open': 'Open',
250
+ 'menu.duplicate': 'Duplicate',
251
+ 'menu.deleted': 'Memo deleted',
252
+ 'menu.duplicated': 'Duplicated as a new memo',
224
253
  'image.add': 'Insert image',
225
254
  'image.remove': 'Remove image',
226
255
  'image.memo': 'Image memo',
@@ -240,13 +269,30 @@ window.__ModuleLoader__.load({
240
269
  'image.resize': 'Resize image',
241
270
  'image.size': '{width} × {height}',
242
271
  'image.resetSize': 'Reset to original size',
243
- 'image.sizeReset': 'Image size reset'
272
+ 'image.sizeReset': 'Image size reset',
273
+ // Saving a whole conversation as a memo.
274
+ 'chat.create': 'Save chat as memo',
275
+ 'chat.createAria': 'Save this conversation as a memo',
276
+ 'chat.saved': 'Saved to memos',
277
+ 'chat.empty': 'This conversation has nothing to save yet.',
278
+ 'chat.limit': 'Memo limit reached. Delete a few before saving.',
279
+ 'chat.fallbackTitle': 'Chat memo',
280
+ 'chat.you': 'You',
281
+ 'chat.assistant': 'Assistant',
282
+ 'chat.emptyPrompts': 'You have not sent anything yet.',
283
+ 'chat.menu.label': 'How to save this conversation',
284
+ 'chat.menu.chat': 'Save whole conversation',
285
+ 'chat.menu.chatNote': 'My prompts and the replies',
286
+ 'chat.menu.prompts': 'Save only my prompts',
287
+ 'chat.menu.promptsNote': 'What I sent, nothing else',
288
+ 'prompt.create': 'Save this prompt as memo',
289
+ 'prompt.createAria': 'Save this prompt as a memo'
244
290
  };
245
291
  // ---------------------------------------------------------------------
246
292
  // Styles (theme tokens only; literal colors are reserved for artwork)
247
293
  // ---------------------------------------------------------------------
248
294
  var STYLE = [
249
- '.dsm-root{display:flex;flex-direction:column;height:100%;min-height:0;box-sizing:border-box;',
295
+ '.dsm-root{position:relative;display:flex;flex-direction:column;height:100%;min-height:0;box-sizing:border-box;',
250
296
  'color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13.5px;line-height:1.45;background:transparent}',
251
297
  '.dsm-toolbar{display:flex;align-items:center;gap:6px;padding:10px 10px 8px;flex:0 0 auto}',
252
298
  '.dsm-toolbar-actions{display:flex;align-items:center;gap:4px;flex:0 0 auto}',
@@ -304,6 +350,25 @@ window.__ModuleLoader__.load({
304
350
  '.dsm-row[draggable="true"]{cursor:grab}',
305
351
  '.dsm-row[draggable="true"]:active{cursor:grabbing}',
306
352
  '.dsm-row[data-dragging="true"]{opacity:.55;background:var(--dsw-alias-bg-layer-2)}',
353
+ // Rows far outside the panel viewport skip layout and paint; the intrinsic
354
+ // size keeps the scrollbar stable until a row has been rendered once. This
355
+ // is what keeps a 300-memo library cheap when the chat column re-layouts.
356
+ '.dsm-row{content-visibility:auto;contain-intrinsic-size:auto 78px}',
357
+ '.dsm-menu{position:absolute;z-index:20;min-width:172px;max-width:240px;padding:4px;box-sizing:border-box;',
358
+ 'display:flex;flex-direction:column;gap:1px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;',
359
+ 'background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);',
360
+ 'box-shadow:var(--dsw-elevation-prominent)}',
361
+ '.dsm-menu-item{display:flex;align-items:center;gap:8px;width:100%;height:30px;padding:0 8px;box-sizing:border-box;',
362
+ 'border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;',
363
+ 'font-size:12.5px;line-height:1.2;text-align:left;white-space:nowrap;cursor:pointer}',
364
+ '.dsm-menu-item:hover{background:var(--dsw-alias-bg-layer-1)}',
365
+ '.dsm-menu-item:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px;',
366
+ 'background:var(--dsw-alias-bg-layer-1)}',
367
+ '.dsm-menu-item[data-danger="true"]{color:var(--dsw-alias-state-error-primary)}',
368
+ '.dsm-menu-icon{display:flex;flex:0 0 auto;color:var(--dsw-alias-label-secondary)}',
369
+ '.dsm-menu-item[data-danger="true"] .dsm-menu-icon{color:inherit}',
370
+ '.dsm-menu-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}',
371
+ '.dsm-menu-sep{flex:0 0 auto;height:1px;margin:3px 4px;background:var(--dsw-alias-border-l1)}',
307
372
  '.dsm-foot-hint{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
308
373
  '[data-composer-input].dsm-composer-drop{outline:2px dashed var(--dsw-alias-brand-primary);outline-offset:2px;border-radius:10px}',
309
374
  '.dsm-icon-btn[data-favorite="true"]{color:var(--dsw-alias-brand-primary)}',
@@ -420,6 +485,26 @@ window.__ModuleLoader__.load({
420
485
  '.dsm-toggle:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}',
421
486
  '.dsm-toggle[data-active="true"]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}',
422
487
  '.dsm-toggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
488
+ '.dsm-toggle[data-state="saved"]{color:var(--dsw-alias-brand-primary)}',
489
+ '.dsm-toggle[data-state="failed"]{color:var(--dsw-alias-label-primary);opacity:.85}',
490
+ '.dsm-chat-memo{display:inline-flex;align-items:center;position:relative}',
491
+ '.dsm-chat-memo-menu{position:fixed;z-index:120;min-width:214px;padding:5px;display:flex;flex-direction:column;gap:2px;',
492
+ 'box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-specific-menu);',
493
+ 'backdrop-filter:var(--dsw-menu-backdrop-filter);box-shadow:var(--dsw-elevation-prominent)}',
494
+ '.dsm-chat-memo-item{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;padding:7px 9px;',
495
+ 'border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;',
496
+ 'line-height:1.35;text-align:left;cursor:pointer}',
497
+ '.dsm-chat-memo-item:hover{background:var(--dsw-alias-bg-layer-2)}',
498
+ '.dsm-chat-memo-item:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
499
+ '.dsm-chat-memo-note{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1.3}',
500
+ '.dsm-prompt-memo{position:fixed;z-index:115;display:inline-flex;align-items:center;justify-content:center;',
501
+ 'width:22px;height:22px;padding:0;pointer-events:auto;-webkit-app-region:no-drag;cursor:pointer;',
502
+ 'border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-specific-menu);',
503
+ 'color:var(--dsw-alias-label-secondary);box-shadow:var(--dsw-elevation-prominent)}',
504
+ '.dsm-prompt-memo:hover{color:var(--dsw-alias-label-primary)}',
505
+ '.dsm-prompt-memo:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
506
+ '.dsm-prompt-memo[data-state="saved"]{color:var(--dsw-alias-brand-primary)}',
507
+ '.dsm-prompt-memo[data-state="failed"]{color:var(--dsw-alias-label-primary);opacity:.85}',
423
508
  '.dsm-header-toggle{position:absolute;top:11px;right:62px;z-index:2}',
424
509
  '.dsm-drawer-layer{position:absolute;inset:0;pointer-events:none}',
425
510
  '.dsm-drawer{position:absolute;top:max(56px,var(--dsh-frame-overlay-top,12px));right:12px;bottom:12px;',
@@ -461,6 +546,10 @@ window.__ModuleLoader__.load({
461
546
  function IconNote(props) {
462
547
  return h(IconBase, props, h('path', { d: 'M5.5 5.25A1.75 1.75 0 0 1 7.25 3.5h6.9L19 8.35v10.4a1.75 1.75 0 0 1-1.75 1.75h-10A1.75 1.75 0 0 1 5.5 18.75z' }), h('path', { d: 'M14 3.5v5h5' }), h('path', { d: 'M8.75 12.25h6.5' }), h('path', { d: 'M8.75 15.75h4.25' }));
463
548
  }
549
+ /** A speech bubble with memo lines: "turn this conversation into a memo". */
550
+ function IconChatMemo(props) {
551
+ return h(IconBase, props, h('path', { d: 'M4.75 6.25A2.5 2.5 0 0 1 7.25 3.75h9.5a2.5 2.5 0 0 1 2.5 2.5v6.5a2.5 2.5 0 0 1-2.5 2.5h-6.4L5.5 19.5v-4.25h-.75a2.5 2.5 0 0 1-2.5-2.5z' }), h('path', { d: 'M8.5 7.75h7' }), h('path', { d: 'M8.5 10.75h4.5' }));
552
+ }
464
553
  function IconSearch(props) {
465
554
  return h(IconBase, props, h('circle', { cx: 10.5, cy: 10.5, r: 6.25 }), h('path', { d: 'M15.25 15.25 20 20' }));
466
555
  }
@@ -1366,20 +1455,35 @@ window.__ModuleLoader__.load({
1366
1455
  });
1367
1456
  }
1368
1457
  function addImage(record) {
1369
- if (!imagesAvailable)
1370
- return Promise.resolve({ ok: false, reason: 'unavailable' });
1371
- var clean = normalizeImageRecord(record);
1372
- if (clean === undefined)
1373
- return Promise.resolve({ ok: false, reason: 'unsupported' });
1374
- var image = clean;
1375
- if (imageBytes() + image.size <= MAX_TOTAL_IMAGE_BYTES)
1376
- return persistImage(image);
1377
- return collectOrphans().then(function () {
1378
- if (imageBytes() + image.size > MAX_TOTAL_IMAGE_BYTES)
1379
- return { ok: false, reason: 'budget' };
1380
- return persistImage(image);
1458
+ // The image cache is lazy, but an insert still needs the open database
1459
+ // handle and the in-memory budget, so force hydration before writing.
1460
+ return ensureImages().then(function () {
1461
+ if (!imagesAvailable)
1462
+ return { ok: false, reason: 'unavailable' };
1463
+ var clean = normalizeImageRecord(record);
1464
+ if (clean === undefined)
1465
+ return { ok: false, reason: 'unsupported' };
1466
+ var image = clean;
1467
+ if (imageBytes() + image.size <= MAX_TOTAL_IMAGE_BYTES)
1468
+ return persistImage(image);
1469
+ return collectOrphans().then(function () {
1470
+ if (imageBytes() + image.size > MAX_TOTAL_IMAGE_BYTES)
1471
+ return { ok: false, reason: 'budget' };
1472
+ return persistImage(image);
1473
+ });
1381
1474
  });
1382
1475
  }
1476
+ var imagesRequest = null;
1477
+ /**
1478
+ * Open the image database on first use instead of at plugin boot. Records
1479
+ * can be tens of megabytes, and most Sessions never open the notes surface,
1480
+ * so this work belongs to the panel mount rather than to `apply()`.
1481
+ */
1482
+ function ensureImages() {
1483
+ if (imagesRequest === null)
1484
+ imagesRequest = loadImages();
1485
+ return imagesRequest;
1486
+ }
1383
1487
  function loadImages() {
1384
1488
  return openImageDb().then(function (db) {
1385
1489
  imageDb = db;
@@ -1439,7 +1543,7 @@ window.__ModuleLoader__.load({
1439
1543
  notes = [];
1440
1544
  }
1441
1545
  emit();
1442
- loadImages();
1546
+ // Images hydrate on first panel/drawer mount (see ensureImages), not here.
1443
1547
  return {
1444
1548
  subscribe: function (listener) {
1445
1549
  listeners.add(listener);
@@ -1549,6 +1653,46 @@ window.__ModuleLoader__.load({
1549
1653
  emit();
1550
1654
  return next;
1551
1655
  },
1656
+ duplicate: function (id) {
1657
+ var index = -1;
1658
+ for (var at = 0; at < notes.length; at += 1) {
1659
+ if (notes[at].id === id) {
1660
+ index = at;
1661
+ break;
1662
+ }
1663
+ }
1664
+ if (index < 0)
1665
+ return undefined;
1666
+ if (notes.length >= MAX_NOTES)
1667
+ return undefined;
1668
+ var source = notes[index];
1669
+ var now = Date.now();
1670
+ var clonedContent = source.content.map(function (block) {
1671
+ if (block.type === 'image') {
1672
+ var image = { type: 'image', id: block.id };
1673
+ if (typeof block.width === 'number')
1674
+ image.width = block.width;
1675
+ if (typeof block.height === 'number')
1676
+ image.height = block.height;
1677
+ return image;
1678
+ }
1679
+ return { type: 'text', text: block.text };
1680
+ });
1681
+ var copy = {
1682
+ id: newNoteId(),
1683
+ title: source.title,
1684
+ body: source.body,
1685
+ content: clonedContent,
1686
+ createdAt: now,
1687
+ updatedAt: now,
1688
+ favorite: false,
1689
+ labels: normalizeLabels(source.labels)
1690
+ };
1691
+ notes = [copy].concat(notes);
1692
+ schedule();
1693
+ emit();
1694
+ return copy;
1695
+ },
1552
1696
  remove: function (id) {
1553
1697
  var next = notes.filter(function (note) {
1554
1698
  return note.id !== id;
@@ -1604,6 +1748,7 @@ window.__ModuleLoader__.load({
1604
1748
  write();
1605
1749
  },
1606
1750
  addImage: addImage,
1751
+ ensureImages: ensureImages,
1607
1752
  collectOrphans: collectOrphans,
1608
1753
  limits: { maxNotes: MAX_NOTES, maxTitle: MAX_TITLE, maxBody: MAX_BODY, maxImages: MAX_IMAGES }
1609
1754
  };
@@ -2036,6 +2181,199 @@ window.__ModuleLoader__.load({
2036
2181
  }
2037
2182
  };
2038
2183
  }
2184
+ /**
2185
+ * One-shot "open this memo" hand-off used by the chat-memo button.
2186
+ *
2187
+ * It is a store rather than local state because the notes surface may not be
2188
+ * mounted yet when the memo is written: the button reveals the panel first,
2189
+ * and the notes body picks the request up as it mounts.
2190
+ */
2191
+ function createOpenRequestStore() {
2192
+ var requested = null;
2193
+ var listeners = new Set();
2194
+ function emit() {
2195
+ listeners.forEach(function (listener) {
2196
+ try {
2197
+ listener();
2198
+ }
2199
+ catch (error) {
2200
+ console.error('[dsh-side-memo] open-request listener failed', error);
2201
+ }
2202
+ });
2203
+ }
2204
+ return {
2205
+ subscribe: function (listener) {
2206
+ listeners.add(listener);
2207
+ return function () {
2208
+ listeners.delete(listener);
2209
+ };
2210
+ },
2211
+ getSnapshot: function () {
2212
+ return requested;
2213
+ },
2214
+ set: function (id) {
2215
+ var next = typeof id === 'string' && id.length > 0 ? id : null;
2216
+ if (next === requested)
2217
+ return;
2218
+ requested = next;
2219
+ emit();
2220
+ }
2221
+ };
2222
+ }
2223
+ /** Inert store for notes surfaces rendered without a chat-memo hand-off. */
2224
+ var nullOpenRequest = {
2225
+ subscribe: function () {
2226
+ return function () { };
2227
+ },
2228
+ getSnapshot: function () {
2229
+ return null;
2230
+ },
2231
+ set: function () { }
2232
+ };
2233
+ // ---------------------------------------------------------------------
2234
+ // Chat → memo: condense the visible conversation into one memo draft.
2235
+ // ---------------------------------------------------------------------
2236
+ // The title comes from the first user message; long prompts make hostile
2237
+ // titles, so cap the derived title well below the stored MAX_TITLE.
2238
+ var CHAT_MEMO_TITLE_MAX = 80;
2239
+ function chatMemoText(value) {
2240
+ return typeof value === 'string' ? value.replace(/\r\n?/g, '\n').trim() : '';
2241
+ }
2242
+ /** Join the text entries of one chat block list (images, tools, reasoning skipped). */
2243
+ function chatMemoBlocks(value, key) {
2244
+ if (!Array.isArray(value))
2245
+ return '';
2246
+ var parts = [];
2247
+ for (var index = 0; index < value.length; index += 1) {
2248
+ var block = value[index];
2249
+ if (block === null || typeof block !== 'object')
2250
+ continue;
2251
+ if (block[key] !== 'text')
2252
+ continue;
2253
+ var text = chatMemoText(block.text);
2254
+ if (text !== '')
2255
+ parts.push(text);
2256
+ }
2257
+ return parts.join('\n\n');
2258
+ }
2259
+ /**
2260
+ * Reduce visible chat nodes to ordered user/assistant text turns.
2261
+ * `mode` "prompts" keeps only what the user sent.
2262
+ */
2263
+ function chatMemoEntries(nodes, mode) {
2264
+ var entries = [];
2265
+ if (!Array.isArray(nodes))
2266
+ return entries;
2267
+ for (var index = 0; index < nodes.length; index += 1) {
2268
+ var node = nodes[index];
2269
+ if (node === null || typeof node !== 'object')
2270
+ continue;
2271
+ var kind = typeof node.kind === 'string' ? node.kind : '';
2272
+ var data = node.data === null || typeof node.data !== 'object' ? {} : node.data;
2273
+ var role = null;
2274
+ var text = '';
2275
+ if (kind === 'user' || kind === 'steering') {
2276
+ role = 'user';
2277
+ text = chatMemoBlocks(data.content, 'type');
2278
+ }
2279
+ else if (kind === 'assistant' || kind === 'assistant-step') {
2280
+ role = 'assistant';
2281
+ text = chatMemoBlocks(data.blocks, 'kind');
2282
+ }
2283
+ if (role === null || text === '')
2284
+ continue;
2285
+ if (mode === 'prompts' && role !== 'user')
2286
+ continue;
2287
+ entries.push({ role: role, text: text });
2288
+ }
2289
+ return entries;
2290
+ }
2291
+ function chatMemoHeading(entry, labels) {
2292
+ return (entry.role === 'user' ? labels.you : labels.assistant) + ':\n';
2293
+ }
2294
+ function chatMemoTitle(entries, labels) {
2295
+ var limit = Math.min(CHAT_MEMO_TITLE_MAX, MAX_TITLE);
2296
+ for (var index = 0; index < entries.length; index += 1) {
2297
+ if (entries[index].role !== 'user')
2298
+ continue;
2299
+ var collapsed = entries[index].text.replace(/\s+/g, ' ').trim();
2300
+ if (collapsed === '')
2301
+ continue;
2302
+ return collapsed.length > limit ? collapsed.slice(0, limit).trim() : collapsed;
2303
+ }
2304
+ return labels.fallbackTitle;
2305
+ }
2306
+ function chatMemoBody(entries, labels) {
2307
+ var parts = [];
2308
+ for (var index = 0; index < entries.length; index += 1) {
2309
+ parts.push(chatMemoHeading(entries[index], labels) + entries[index].text);
2310
+ }
2311
+ return parts.join('\n\n');
2312
+ }
2313
+ /**
2314
+ * Build the memo title/body for one conversation. Returns null when the chat
2315
+ * holds no user or assistant text (blank session, tools only, …).
2316
+ *
2317
+ * Transcripts are bounded twice: whole turns are dropped from the old end
2318
+ * until the body fits MAX_BODY, and a single oversized turn keeps its tail so
2319
+ * the newest words survive. `truncated` reports either adjustment.
2320
+ *
2321
+ * `mode` "prompts" saves only the user's messages; the title still comes from
2322
+ * the first prompt, and a chat with no prompts returns null.
2323
+ */
2324
+ function chatMemoDraft(nodes, labels, mode) {
2325
+ var entries = chatMemoEntries(nodes, mode);
2326
+ if (entries.length === 0)
2327
+ return null;
2328
+ var maxBody = MAX_BODY;
2329
+ var kept = entries.slice();
2330
+ var truncated = false;
2331
+ var body = chatMemoBody(kept, labels);
2332
+ while (body.length > maxBody && kept.length > 1) {
2333
+ kept.shift();
2334
+ truncated = true;
2335
+ body = chatMemoBody(kept, labels);
2336
+ }
2337
+ if (body.length > maxBody) {
2338
+ var last = kept[0];
2339
+ var room = maxBody - chatMemoHeading(last, labels).length;
2340
+ if (room > 0 && last.text.length > room) {
2341
+ kept = [{ role: last.role, text: last.text.slice(last.text.length - room) }];
2342
+ truncated = true;
2343
+ body = chatMemoBody(kept, labels);
2344
+ }
2345
+ }
2346
+ if (body.length > maxBody) {
2347
+ body = body.slice(body.length - maxBody);
2348
+ truncated = true;
2349
+ }
2350
+ if (body.trim() === '')
2351
+ return null;
2352
+ return {
2353
+ title: chatMemoTitle(entries, labels),
2354
+ body: body,
2355
+ messages: kept.length,
2356
+ truncated: truncated
2357
+ };
2358
+ }
2359
+ /**
2360
+ * Memo title/body for one prompt captured from the transcript. The body keeps
2361
+ * the prompt verbatim (only normalised line endings); the title is collapsed
2362
+ * and capped, and an oversized prompt keeps its tail so a memo can never
2363
+ * exceed the stored body limit.
2364
+ */
2365
+ function promptMemoDraft(value, labels) {
2366
+ var text = chatMemoText(value);
2367
+ if (text === '')
2368
+ return null;
2369
+ var body = text.length > MAX_BODY ? text.slice(text.length - MAX_BODY) : text;
2370
+ return {
2371
+ title: chatMemoTitle([{ role: 'user', text: text }], labels),
2372
+ body: body,
2373
+ messages: 1,
2374
+ truncated: body.length !== text.length
2375
+ };
2376
+ }
2039
2377
  /**
2040
2378
  * Read the Session the right sidebar can act on. `ctx.sidebarRight.mounted`
2041
2379
  * is the observable of the on-screen Session — undefined while no Session is
@@ -2159,6 +2497,38 @@ window.__ModuleLoader__.load({
2159
2497
  return right.updatedAt - left.updatedAt;
2160
2498
  });
2161
2499
  }
2500
+ /**
2501
+ * Fit a context menu inside its container. Coordinates are container-relative,
2502
+ * so the menu flips above the anchor when it would overflow the bottom edge and
2503
+ * is clamped to the container with a small margin on every side.
2504
+ */
2505
+ function placeContextMenu(anchor, size, bounds, margin) {
2506
+ var gap = typeof margin === 'number' && isFinite(margin) ? Math.max(0, margin) : 4;
2507
+ var anchorX = typeof anchor.x === 'number' && isFinite(anchor.x) ? anchor.x : gap;
2508
+ var anchorY = typeof anchor.y === 'number' && isFinite(anchor.y) ? anchor.y : gap;
2509
+ var width = typeof size.width === 'number' && isFinite(size.width) ? Math.max(0, size.width) : 0;
2510
+ var height = typeof size.height === 'number' && isFinite(size.height) ? Math.max(0, size.height) : 0;
2511
+ var boundWidth = typeof bounds.width === 'number' && isFinite(bounds.width) ? Math.max(0, bounds.width) : 0;
2512
+ var boundHeight = typeof bounds.height === 'number' && isFinite(bounds.height) ? Math.max(0, bounds.height) : 0;
2513
+ var left = Math.min(Math.max(anchorX, gap), Math.max(gap, boundWidth - width - gap));
2514
+ var top = anchorY;
2515
+ if (top + height + gap > boundHeight)
2516
+ top = anchorY - height - gap;
2517
+ top = Math.min(Math.max(top, gap), Math.max(gap, boundHeight - height - gap));
2518
+ return { left: Math.round(left), top: Math.round(top) };
2519
+ }
2520
+ /** One row of the memo context menu; keyboard focus is managed by the menu. */
2521
+ function contextMenuItem(key, label, icon, danger, onSelect) {
2522
+ return h('button', {
2523
+ type: 'button',
2524
+ role: 'menuitem',
2525
+ key: key,
2526
+ className: 'dsm-menu-item',
2527
+ 'data-danger': danger ? 'true' : undefined,
2528
+ tabIndex: -1,
2529
+ onClick: onSelect
2530
+ }, h('span', { className: 'dsm-menu-icon' }, icon), h('span', { className: 'dsm-menu-label' }, label));
2531
+ }
2162
2532
  function copyToClipboard(text) {
2163
2533
  if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
2164
2534
  return navigator.clipboard.writeText(text).then(function () {
@@ -2928,6 +3298,50 @@ window.__ModuleLoader__.load({
2928
3298
  var labelNoticeState = React.useState('');
2929
3299
  var labelNotice = labelNoticeState[0];
2930
3300
  var setLabelNotice = labelNoticeState[1];
3301
+ // Right-click menu on a memo row. Coordinates are relative to `.dsm-root`.
3302
+ var menuState = React.useState(null);
3303
+ var menu = menuState[0];
3304
+ var setMenu = menuState[1];
3305
+ var menuPositionState = React.useState(null);
3306
+ var menuPosition = menuPositionState[0];
3307
+ var setMenuPosition = menuPositionState[1];
3308
+ var menuArmedState = React.useState(false);
3309
+ var menuDeleteArmed = menuArmedState[0];
3310
+ var setMenuDeleteArmed = menuArmedState[1];
3311
+ var rootRef = React.useRef(null);
3312
+ var menuRef = React.useRef(null);
3313
+ var menuReturnFocusRef = React.useRef(null);
3314
+ var openRequest = props.openRequest === undefined ? nullOpenRequest : props.openRequest;
3315
+ var requestedNoteId = useSyncExternalStore(openRequest.subscribe, openRequest.getSnapshot, openRequest.getSnapshot);
3316
+ // Inline images live in IndexedDB and can be large. Hydrate them when this
3317
+ // surface mounts (panel tab or pre-Session drawer), not at plugin boot.
3318
+ React.useEffect(function () {
3319
+ notes.ensureImages();
3320
+ }, [notes]);
3321
+ // A memo created from the chat header asks to be opened here. The panel can
3322
+ // mount after the request was filed, so the store is read on mount as well;
3323
+ // the request is consumed exactly once.
3324
+ React.useEffect(function () {
3325
+ if (requestedNoteId === null)
3326
+ return;
3327
+ openRequest.set(null);
3328
+ var found = false;
3329
+ for (var index = 0; index < snapshot.notes.length; index += 1) {
3330
+ if (snapshot.notes[index].id === requestedNoteId) {
3331
+ found = true;
3332
+ break;
3333
+ }
3334
+ }
3335
+ if (!found)
3336
+ return;
3337
+ setTransfer(null);
3338
+ setQuery('');
3339
+ setCopiedId(null);
3340
+ setEditorCopied(false);
3341
+ setDeleteArmed(false);
3342
+ setNewId(null);
3343
+ setEditingId(requestedNoteId);
3344
+ }, [requestedNoteId, openRequest, snapshot.notes]);
2931
3345
  var now = Date.now();
2932
3346
  var normalizedQuery = query.trim().toLocaleLowerCase();
2933
3347
  var labelFilterActive = activeLabels.length > 0;
@@ -3183,6 +3597,103 @@ window.__ModuleLoader__.load({
3183
3597
  }
3184
3598
  }
3185
3599
  }
3600
+ var menuTarget;
3601
+ if (menu !== null) {
3602
+ for (var menuAt = 0; menuAt < snapshot.notes.length; menuAt += 1) {
3603
+ if (snapshot.notes[menuAt].id === menu.id) {
3604
+ menuTarget = snapshot.notes[menuAt];
3605
+ break;
3606
+ }
3607
+ }
3608
+ }
3609
+ // Drop the menu when its memo disappears (deleted elsewhere, imported over, ...).
3610
+ React.useEffect(function () {
3611
+ if (menu === null || menuTarget !== undefined)
3612
+ return;
3613
+ setMenu(null);
3614
+ setMenuDeleteArmed(false);
3615
+ setMenuPosition(null);
3616
+ }, [menu, menuTarget, setMenu, setMenuDeleteArmed, setMenuPosition]);
3617
+ // Measure after render so the menu can be clamped or flipped inside the panel.
3618
+ React.useLayoutEffect(function () {
3619
+ if (menu === null)
3620
+ return;
3621
+ var root = rootRef.current;
3622
+ var element = menuRef.current;
3623
+ if (root === null || element === null)
3624
+ return;
3625
+ var next = placeContextMenu({ x: menu.x, y: menu.y }, { width: element.offsetWidth, height: element.offsetHeight }, { width: root.clientWidth, height: root.clientHeight });
3626
+ setMenuPosition(function (current) {
3627
+ if (current !== null && current.left === next.left && current.top === next.top)
3628
+ return current;
3629
+ return next;
3630
+ });
3631
+ }, [menu, menuDeleteArmed, setMenuPosition]);
3632
+ React.useLayoutEffect(function () {
3633
+ if (menu === null)
3634
+ return;
3635
+ var element = menuRef.current;
3636
+ if (element === null)
3637
+ return;
3638
+ var items = element.querySelectorAll('[role="menuitem"]');
3639
+ if (items.length === 0)
3640
+ return;
3641
+ try {
3642
+ items[0].focus();
3643
+ }
3644
+ catch (error) {
3645
+ // Focus is best effort; the menu still works with the mouse.
3646
+ }
3647
+ }, [menu]);
3648
+ React.useEffect(function () {
3649
+ if (menu === null)
3650
+ return undefined;
3651
+ if (typeof document === 'undefined' || typeof window === 'undefined')
3652
+ return undefined;
3653
+ function isInsideMenu(target) {
3654
+ var element = menuRef.current;
3655
+ if (element === null || target === null)
3656
+ return false;
3657
+ return element.contains(target);
3658
+ }
3659
+ function onPointerDown(event) {
3660
+ if (isInsideMenu(event.target))
3661
+ return;
3662
+ closeRowMenu(false);
3663
+ }
3664
+ function onKeyDown(event) {
3665
+ if (event.key !== 'Escape')
3666
+ return;
3667
+ event.preventDefault();
3668
+ event.stopPropagation();
3669
+ closeRowMenu(true);
3670
+ }
3671
+ function onDismiss() {
3672
+ closeRowMenu(false);
3673
+ }
3674
+ document.addEventListener('pointerdown', onPointerDown, true);
3675
+ document.addEventListener('keydown', onKeyDown, true);
3676
+ document.addEventListener('scroll', onDismiss, true);
3677
+ window.addEventListener('resize', onDismiss);
3678
+ window.addEventListener('blur', onDismiss);
3679
+ return function () {
3680
+ document.removeEventListener('pointerdown', onPointerDown, true);
3681
+ document.removeEventListener('keydown', onKeyDown, true);
3682
+ document.removeEventListener('scroll', onDismiss, true);
3683
+ window.removeEventListener('resize', onDismiss);
3684
+ window.removeEventListener('blur', onDismiss);
3685
+ };
3686
+ }, [menu]);
3687
+ React.useEffect(function () {
3688
+ if (!menuDeleteArmed)
3689
+ return undefined;
3690
+ var timer = setTimeout(function () {
3691
+ setMenuDeleteArmed(false);
3692
+ }, DELETE_ARM_MS);
3693
+ return function () {
3694
+ clearTimeout(timer);
3695
+ };
3696
+ }, [menuDeleteArmed, setMenuDeleteArmed]);
3186
3697
  React.useEffect(function () {
3187
3698
  if (editingId !== null && editing === undefined) {
3188
3699
  setEditingId(null);
@@ -3492,6 +4003,130 @@ window.__ModuleLoader__.load({
3492
4003
  setNewId(null);
3493
4004
  setDeleteArmed(false);
3494
4005
  }
4006
+ function openRowMenu(note, clientX, clientY, returnFocus) {
4007
+ var root = rootRef.current;
4008
+ var rect = root === null ? null : root.getBoundingClientRect();
4009
+ var x = rect === null ? clientX : clientX - rect.left;
4010
+ var y = rect === null ? clientY : clientY - rect.top;
4011
+ menuReturnFocusRef.current = returnFocus;
4012
+ setMenuDeleteArmed(false);
4013
+ setMenuPosition(null);
4014
+ setMenu({ id: note.id, x: x, y: y });
4015
+ }
4016
+ function closeRowMenu(restoreFocus) {
4017
+ var target = menuReturnFocusRef.current;
4018
+ menuReturnFocusRef.current = null;
4019
+ setMenu(null);
4020
+ setMenuDeleteArmed(false);
4021
+ setMenuPosition(null);
4022
+ if (!restoreFocus || target === null)
4023
+ return;
4024
+ if (typeof target.isConnected === 'boolean' && !target.isConnected)
4025
+ return;
4026
+ try {
4027
+ target.focus();
4028
+ }
4029
+ catch (error) {
4030
+ /* focusing is best effort */
4031
+ }
4032
+ }
4033
+ function menuNote() {
4034
+ if (menu === null)
4035
+ return undefined;
4036
+ for (var index = 0; index < snapshot.notes.length; index += 1) {
4037
+ if (snapshot.notes[index].id === menu.id)
4038
+ return snapshot.notes[index];
4039
+ }
4040
+ return undefined;
4041
+ }
4042
+ function handleMenuOpen() {
4043
+ var note = menuNote();
4044
+ closeRowMenu(true);
4045
+ if (note !== undefined)
4046
+ openNote(note.id);
4047
+ }
4048
+ function handleMenuCopy() {
4049
+ var note = menuNote();
4050
+ closeRowMenu(true);
4051
+ if (note !== undefined)
4052
+ copyNote(note);
4053
+ }
4054
+ function handleMenuDuplicate() {
4055
+ var note = menuNote();
4056
+ closeRowMenu(true);
4057
+ if (note === undefined)
4058
+ return;
4059
+ var copy = notes.duplicate(note.id);
4060
+ if (copy === undefined) {
4061
+ setAnnounce(t('limit.reached', { max: limits.maxNotes }));
4062
+ return;
4063
+ }
4064
+ setAnnounce(t('menu.duplicated'));
4065
+ }
4066
+ function handleMenuFavorite() {
4067
+ var note = menuNote();
4068
+ closeRowMenu(true);
4069
+ if (note !== undefined)
4070
+ toggleFavorite(note);
4071
+ }
4072
+ function handleMenuDelete() {
4073
+ var note = menuNote();
4074
+ if (note === undefined) {
4075
+ closeRowMenu(false);
4076
+ return;
4077
+ }
4078
+ if (!menuDeleteArmed) {
4079
+ setMenuDeleteArmed(true);
4080
+ return;
4081
+ }
4082
+ closeRowMenu(false);
4083
+ notes.remove(note.id);
4084
+ notes.flush();
4085
+ setAnnounce(t('menu.deleted'));
4086
+ }
4087
+ function moveMenuFocus(event) {
4088
+ if (event.key === 'Tab') {
4089
+ closeRowMenu(false);
4090
+ return;
4091
+ }
4092
+ var forward = event.key === 'ArrowDown';
4093
+ var backward = event.key === 'ArrowUp';
4094
+ var home = event.key === 'Home';
4095
+ var end = event.key === 'End';
4096
+ if (!forward && !backward && !home && !end)
4097
+ return;
4098
+ event.preventDefault();
4099
+ event.stopPropagation();
4100
+ var element = menuRef.current;
4101
+ if (element === null)
4102
+ return;
4103
+ var items = element.querySelectorAll('[role="menuitem"]');
4104
+ if (items.length === 0)
4105
+ return;
4106
+ var active = document.activeElement;
4107
+ var current = -1;
4108
+ for (var index = 0; index < items.length; index += 1) {
4109
+ if (items[index] === active) {
4110
+ current = index;
4111
+ break;
4112
+ }
4113
+ }
4114
+ var next;
4115
+ if (home)
4116
+ next = 0;
4117
+ else if (end)
4118
+ next = items.length - 1;
4119
+ else if (forward)
4120
+ next = current < 0 ? 0 : (current + 1) % items.length;
4121
+ else
4122
+ next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
4123
+ try {
4124
+ items[next].focus();
4125
+ }
4126
+ catch (error) {
4127
+ /* focusing is best effort */
4128
+ }
4129
+ }
3495
4130
  function updateEditing(field, value) {
3496
4131
  if (editing === undefined)
3497
4132
  return;
@@ -4266,6 +4901,20 @@ window.__ModuleLoader__.load({
4266
4901
  }
4267
4902
  fillMemoDragData(event.dataTransfer, note);
4268
4903
  setDraggingId(note.id);
4904
+ },
4905
+ onContextMenu: function (event) {
4906
+ event.preventDefault();
4907
+ event.stopPropagation();
4908
+ var focusTarget = event.currentTarget.querySelector('.dsm-row-main');
4909
+ var x = event.clientX;
4910
+ var y = event.clientY;
4911
+ if (x === 0 && y === 0) {
4912
+ // Keyboard-triggered context menu: anchor it to the row instead of 0,0.
4913
+ var rect = event.currentTarget.getBoundingClientRect();
4914
+ x = rect.left + Math.min(24, rect.width / 2);
4915
+ y = rect.bottom - 6;
4916
+ }
4917
+ openRowMenu(note, x, y, focusTarget);
4269
4918
  }
4270
4919
  }, h('button', {
4271
4920
  type: 'button',
@@ -4273,6 +4922,15 @@ window.__ModuleLoader__.load({
4273
4922
  title: title,
4274
4923
  onClick: function () {
4275
4924
  openNote(note.id);
4925
+ },
4926
+ onKeyDown: function (event) {
4927
+ var isMenuKey = event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10');
4928
+ if (!isMenuKey)
4929
+ return;
4930
+ event.preventDefault();
4931
+ event.stopPropagation();
4932
+ var rect = event.currentTarget.getBoundingClientRect();
4933
+ openRowMenu(note, rect.left + Math.min(24, rect.width / 2), rect.bottom - 6, event.currentTarget);
4276
4934
  }
4277
4935
  }, h('span', { className: 'dsm-row-title' }, h('span', { className: 'dsm-row-title-text' }, title), imageTotal > 0
4278
4936
  ? h('span', { className: 'dsm-row-images', title: imageCountLabel(t, imageTotal), 'aria-label': imageCountLabel(t, imageTotal) }, h(IconImage, { size: 11 }), String(imageTotal))
@@ -4333,7 +4991,33 @@ window.__ModuleLoader__.load({
4333
4991
  ? t('count.one')
4334
4992
  : t('count.other', { count: snapshot.notes.length });
4335
4993
  }
4336
- return h('div', { className: 'dsm-root', 'data-side-memo': 'list' }, h('div', { className: 'dsm-toolbar' }, h('div', { className: 'dsm-search' }, h('span', { className: 'dsm-search-icon' }, h(IconSearch, { size: 14 })), h('input', {
4994
+ var menuElement = null;
4995
+ if (menu !== null && menuTarget !== undefined) {
4996
+ var targetNote = menuTarget;
4997
+ var menuItems = [
4998
+ contextMenuItem('open', t('menu.open'), h(IconNote, { size: 14 }), false, handleMenuOpen),
4999
+ contextMenuItem('copy', t('copy'), h(IconCopy, { size: 14 }), false, handleMenuCopy),
5000
+ contextMenuItem('duplicate', t('menu.duplicate'), h(IconPlus, { size: 14 }), false, handleMenuDuplicate),
5001
+ contextMenuItem('favorite', targetNote.favorite === true ? t('favorite.remove') : t('favorite.add'), targetNote.favorite === true ? h(IconStarFilled, { size: 14 }) : h(IconStar, { size: 14 }), false, handleMenuFavorite),
5002
+ h('div', { className: 'dsm-menu-sep', role: 'separator', key: 'sep' }),
5003
+ contextMenuItem('delete', menuDeleteArmed ? t('delete.confirm') : t('delete'), h(IconX, { size: 14 }), true, handleMenuDelete)
5004
+ ];
5005
+ menuElement = h('div', {
5006
+ className: 'dsm-menu',
5007
+ role: 'menu',
5008
+ 'aria-label': t('menu.label'),
5009
+ ref: menuRef,
5010
+ style: {
5011
+ left: String(menuPosition === null ? menu.x : menuPosition.left) + 'px',
5012
+ top: String(menuPosition === null ? menu.y : menuPosition.top) + 'px'
5013
+ },
5014
+ onKeyDown: moveMenuFocus,
5015
+ onContextMenu: function (event) {
5016
+ event.preventDefault();
5017
+ }
5018
+ }, menuItems);
5019
+ }
5020
+ return h('div', { className: 'dsm-root', 'data-side-memo': 'list', ref: rootRef }, h('div', { className: 'dsm-toolbar' }, h('div', { className: 'dsm-search' }, h('span', { className: 'dsm-search-icon' }, h(IconSearch, { size: 14 })), h('input', {
4337
5021
  type: 'search',
4338
5022
  value: query,
4339
5023
  placeholder: t('search.placeholder'),
@@ -4420,7 +5104,7 @@ window.__ModuleLoader__.load({
4420
5104
  }, t('labels.all'))
4421
5105
  : null), h('div', { className: 'dsm-list-scroll' }, listContent), h('div', { className: 'dsm-foot' }, h('span', null, countLabel), visibleNotes.length === 0 || props.inputActions === undefined
4422
5106
  ? null
4423
- : h('span', { className: 'dsm-foot-hint', title: t('drag.hint') }, t('drag.hint')), atLimit ? h('span', { title: t('limit.hint', { max: limits.maxNotes }) }, t('limit.reached', { max: limits.maxNotes })) : null), liveRegion);
5107
+ : h('span', { className: 'dsm-foot-hint', title: t('drag.hint') }, t('drag.hint')), atLimit ? h('span', { title: t('limit.hint', { max: limits.maxNotes }) }, t('limit.reached', { max: limits.maxNotes })) : null), liveRegion, menuElement);
4424
5108
  }
4425
5109
  // ---------------------------------------------------------------------
4426
5110
  // Chip title and header toggle
@@ -4445,6 +5129,387 @@ window.__ModuleLoader__.load({
4445
5129
  }
4446
5130
  }, h(IconNote, { size: 16 }));
4447
5131
  }
5132
+ /**
5133
+ * "Save this conversation as a memo" header button.
5134
+ *
5135
+ * The chat snapshot hook only exists inside a Session-scope slot, so this
5136
+ * wrapper renders nothing on hosts that do not supply it and defers the real
5137
+ * button — whose hook calls must be unconditional — to a child component.
5138
+ */
5139
+ function NotesChatMemo(props) {
5140
+ if (typeof props.useChat !== 'function')
5141
+ return null;
5142
+ return h(NotesChatMemoButton, props);
5143
+ }
5144
+ function chatVisibleNodes(chat) {
5145
+ var list = [];
5146
+ if (chat === null || chat === undefined)
5147
+ return list;
5148
+ var order = Array.isArray(chat.order) ? chat.order : [];
5149
+ var nodes = chat.nodes;
5150
+ if (nodes === null || nodes === undefined || typeof nodes.get !== 'function')
5151
+ return list;
5152
+ for (var index = 0; index < order.length; index += 1) {
5153
+ var node = nodes.get(order[index]);
5154
+ if (node !== null && node !== undefined)
5155
+ list.push(node);
5156
+ }
5157
+ return list;
5158
+ }
5159
+ /**
5160
+ * Menu body for "Save chat as memo". The chat subscription lives here so it
5161
+ * exists only while the menu is open; the closed header button subscribes to
5162
+ * nothing and therefore never re-renders when the conversation changes.
5163
+ */
5164
+ function ChatMemoMenu(props) {
5165
+ var t = props.t;
5166
+ var chat = props.useChat(function (snapshot) {
5167
+ return snapshot;
5168
+ });
5169
+ var labels = {
5170
+ you: t('chat.you'),
5171
+ assistant: t('chat.assistant'),
5172
+ fallbackTitle: t('chat.fallbackTitle')
5173
+ };
5174
+ function pick(mode) {
5175
+ props.onPick(mode, chatMemoDraft(chatVisibleNodes(chat), labels, mode));
5176
+ }
5177
+ var menuStyle = props.anchor === null ? undefined : { left: props.anchor.left + 'px', top: props.anchor.top + 'px' };
5178
+ return h('div', {
5179
+ className: 'dsm-chat-memo-menu',
5180
+ role: 'menu',
5181
+ 'aria-label': t('chat.menu.label'),
5182
+ style: menuStyle
5183
+ }, h('button', {
5184
+ type: 'button',
5185
+ role: 'menuitem',
5186
+ className: 'dsm-chat-memo-item',
5187
+ onClick: function () {
5188
+ pick('chat');
5189
+ }
5190
+ }, h('span', null, t('chat.menu.chat')), h('span', { className: 'dsm-chat-memo-note' }, t('chat.menu.chatNote'))), h('button', {
5191
+ type: 'button',
5192
+ role: 'menuitem',
5193
+ className: 'dsm-chat-memo-item',
5194
+ onClick: function () {
5195
+ pick('prompts');
5196
+ }
5197
+ }, h('span', null, t('chat.menu.prompts')), h('span', { className: 'dsm-chat-memo-note' }, t('chat.menu.promptsNote'))));
5198
+ }
5199
+ function NotesChatMemoButton(props) {
5200
+ var t = translateFor(props);
5201
+ var notes = props.notes;
5202
+ var openRequest = props.openRequest;
5203
+ var feedbackState = React.useState('');
5204
+ var feedback = feedbackState[0];
5205
+ var setFeedback = feedbackState[1];
5206
+ var menuState = React.useState(false);
5207
+ var menuOpen = menuState[0];
5208
+ var setMenuOpen = menuState[1];
5209
+ var anchorState = React.useState(null);
5210
+ var anchor = anchorState[0];
5211
+ var setAnchor = anchorState[1];
5212
+ var rootRef = React.useRef(null);
5213
+ var buttonRef = React.useRef(null);
5214
+ React.useEffect(function () {
5215
+ if (feedback === '')
5216
+ return undefined;
5217
+ var timer = setTimeout(function () {
5218
+ setFeedback('');
5219
+ }, feedback === 'saved' ? COPY_FEEDBACK_MS : COPY_FAILED_MS);
5220
+ return function () {
5221
+ clearTimeout(timer);
5222
+ };
5223
+ }, [feedback]);
5224
+ // The header clips its own children, so the menu is fixed-positioned under
5225
+ // the button and dismissed like a native menu: outside press, Escape, or
5226
+ // any scroll/resize that would leave it floating in the wrong place.
5227
+ React.useEffect(function () {
5228
+ if (!menuOpen)
5229
+ return undefined;
5230
+ function close() {
5231
+ setMenuOpen(false);
5232
+ }
5233
+ function onPointerDown(event) {
5234
+ var root = rootRef.current;
5235
+ if (root !== null && typeof root.contains === 'function' && root.contains(event.target))
5236
+ return;
5237
+ close();
5238
+ }
5239
+ function onKeyDown(event) {
5240
+ if (event.key === 'Escape')
5241
+ close();
5242
+ }
5243
+ document.addEventListener('pointerdown', onPointerDown, true);
5244
+ document.addEventListener('keydown', onKeyDown);
5245
+ window.addEventListener('resize', close);
5246
+ window.addEventListener('scroll', close, true);
5247
+ return function () {
5248
+ document.removeEventListener('pointerdown', onPointerDown, true);
5249
+ document.removeEventListener('keydown', onKeyDown);
5250
+ window.removeEventListener('resize', close);
5251
+ window.removeEventListener('scroll', close, true);
5252
+ };
5253
+ }, [menuOpen]);
5254
+ function openMenu() {
5255
+ var button = buttonRef.current;
5256
+ if (button !== null && typeof button.getBoundingClientRect === 'function') {
5257
+ var rect = button.getBoundingClientRect();
5258
+ var width = 226;
5259
+ var viewport = typeof window === 'undefined' ? width : window.innerWidth;
5260
+ var left = Math.max(8, Math.min(rect.right - width, viewport - width - 8));
5261
+ setAnchor({ left: left, top: rect.bottom + 6 });
5262
+ }
5263
+ setMenuOpen(true);
5264
+ }
5265
+ function take(mode, draft) {
5266
+ setMenuOpen(false);
5267
+ if (draft === null) {
5268
+ setFeedback(mode === 'prompts' ? 'empty-prompts' : 'empty');
5269
+ return;
5270
+ }
5271
+ var note = notes.create();
5272
+ if (note === undefined) {
5273
+ setFeedback('limit');
5274
+ return;
5275
+ }
5276
+ notes.update(note.id, { title: draft.title, body: draft.body });
5277
+ notes.flush();
5278
+ openRequest.set(note.id);
5279
+ if (typeof props.reveal === 'function')
5280
+ props.reveal();
5281
+ setFeedback('saved');
5282
+ }
5283
+ var saved = feedback === 'saved';
5284
+ var failed = feedback !== '' && !saved;
5285
+ var label = saved
5286
+ ? t('chat.saved')
5287
+ : feedback === 'limit'
5288
+ ? t('chat.limit')
5289
+ : feedback === 'empty-prompts'
5290
+ ? t('chat.emptyPrompts')
5291
+ : feedback === 'empty'
5292
+ ? t('chat.empty')
5293
+ : t('chat.create');
5294
+ return h('div', { className: 'dsm-chat-memo', ref: rootRef }, h('button', {
5295
+ type: 'button',
5296
+ className: 'dsm-toggle',
5297
+ ref: buttonRef,
5298
+ 'data-side-memo-chat-memo': 'true',
5299
+ 'data-state': saved ? 'saved' : failed ? 'failed' : 'idle',
5300
+ 'data-active': menuOpen ? 'true' : 'false',
5301
+ 'aria-haspopup': 'menu',
5302
+ 'aria-expanded': menuOpen ? 'true' : 'false',
5303
+ title: label,
5304
+ 'aria-label': saved || failed ? label : t('chat.createAria'),
5305
+ onClick: function () {
5306
+ if (menuOpen)
5307
+ setMenuOpen(false);
5308
+ else
5309
+ openMenu();
5310
+ }
5311
+ }, saved ? h(IconCheck, { size: 16 }) : h(IconChatMemo, { size: 16 })), menuOpen
5312
+ ? h(ChatMemoMenu, {
5313
+ t: t,
5314
+ useChat: props.useChat,
5315
+ anchor: anchor,
5316
+ onPick: take
5317
+ })
5318
+ : null, h('span', { className: 'dsm-sr-only', role: 'status', 'aria-live': 'polite' }, saved ? t('chat.saved') : ''));
5319
+ }
5320
+ /**
5321
+ * Quick "save this prompt as a memo" affordance.
5322
+ *
5323
+ * DSH renders a user message's action row itself and exposes no slot for it,
5324
+ * so the button is fixed-positioned next to that row and follows the pointer
5325
+ * from message to message. The prompt text is read from the
5326
+ * `conversation.chat.user.content` slot anchor, and the button lives in the
5327
+ * click-through `shell.overlay` layer so the message list's overflow and
5328
+ * hover scopes cannot clip it.
5329
+ */
5330
+ function NotesPromptMemo(props) {
5331
+ var t = translateFor(props);
5332
+ var labels = {
5333
+ you: t('chat.you'),
5334
+ assistant: t('chat.assistant'),
5335
+ fallbackTitle: t('chat.fallbackTitle')
5336
+ };
5337
+ var notes = props.notes;
5338
+ var openRequest = props.openRequest;
5339
+ var labelsRef = React.useRef(labels);
5340
+ labelsRef.current = labels;
5341
+ var targetState = React.useState(null);
5342
+ var target = targetState[0];
5343
+ var setTarget = targetState[1];
5344
+ var targetRef = React.useRef(target);
5345
+ targetRef.current = target;
5346
+ var feedbackState = React.useState('');
5347
+ var feedback = feedbackState[0];
5348
+ var setFeedback = feedbackState[1];
5349
+ var feedbackRef = React.useRef(feedback);
5350
+ feedbackRef.current = feedback;
5351
+ var hideTimer = React.useRef(null);
5352
+ function clearHide() {
5353
+ var timer = hideTimer.current;
5354
+ if (timer === null)
5355
+ return;
5356
+ clearTimeout(timer);
5357
+ hideTimer.current = null;
5358
+ }
5359
+ function hide() {
5360
+ clearHide();
5361
+ setTarget(null);
5362
+ }
5363
+ function scheduleHide() {
5364
+ clearHide();
5365
+ hideTimer.current = window.setTimeout(function () {
5366
+ hideTimer.current = null;
5367
+ setTarget(null);
5368
+ }, 140);
5369
+ }
5370
+ React.useEffect(function () {
5371
+ if (feedback === '')
5372
+ return undefined;
5373
+ var timer = setTimeout(function () {
5374
+ setFeedback('');
5375
+ }, feedback === 'saved' ? COPY_FEEDBACK_MS : COPY_FAILED_MS);
5376
+ return function () {
5377
+ clearTimeout(timer);
5378
+ };
5379
+ }, [feedback]);
5380
+ React.useEffect(function () {
5381
+ if (typeof document === 'undefined')
5382
+ return undefined;
5383
+ function elementOf(value) {
5384
+ return value !== null && typeof value === 'object' && 'closest' in value ? value : null;
5385
+ }
5386
+ function locate(item) {
5387
+ var row = item.querySelector('[data-clock="start"]');
5388
+ if (row === null)
5389
+ return null;
5390
+ var copy = row.querySelector('button');
5391
+ if (copy === null)
5392
+ return null;
5393
+ var copyBox = copy.getBoundingClientRect();
5394
+ if (copyBox.width === 0 && copyBox.height === 0)
5395
+ return null;
5396
+ var first = row.children.length === 0 ? null : row.children[0];
5397
+ var time = first !== null && first.tagName === 'SPAN' ? first : null;
5398
+ var left = Math.round(copyBox.right + 5);
5399
+ if (time !== null) {
5400
+ var timeBox = time.getBoundingClientRect();
5401
+ if (timeBox.right + 26 <= copyBox.left)
5402
+ left = Math.round(timeBox.right + 4);
5403
+ else
5404
+ left = Math.round((timeBox.right + copyBox.left) / 2 - 11);
5405
+ }
5406
+ var content = item.querySelector('[data-slot="conversation.chat.user.content"]');
5407
+ var text = content === null ? '' : content.innerText || content.textContent || '';
5408
+ if (promptMemoDraft(text, labelsRef.current) === null)
5409
+ return null;
5410
+ return {
5411
+ text: text,
5412
+ left: left,
5413
+ top: Math.round(copyBox.top + copyBox.height / 2 - 11)
5414
+ };
5415
+ }
5416
+ function onPointerOver(event) {
5417
+ var node = elementOf(event.target);
5418
+ if (node === null)
5419
+ return;
5420
+ if (node.closest('[data-side-memo-prompt-memo]') !== null) {
5421
+ clearHide();
5422
+ return;
5423
+ }
5424
+ var item = node.closest('[data-chat-flow-kind="user"]');
5425
+ if (item === null)
5426
+ return;
5427
+ var next = locate(item);
5428
+ if (next === null)
5429
+ return;
5430
+ var current = targetRef.current;
5431
+ if (current !== null && current.text === next.text && current.left === next.left && current.top === next.top)
5432
+ return;
5433
+ clearHide();
5434
+ if (feedbackRef.current !== '')
5435
+ setFeedback('');
5436
+ setTarget(next);
5437
+ }
5438
+ function onPointerOut(event) {
5439
+ var node = elementOf(event.target);
5440
+ if (node === null)
5441
+ return;
5442
+ var item = node.closest('[data-chat-flow-kind="user"]');
5443
+ if (item === null)
5444
+ return;
5445
+ var related = elementOf(event.relatedTarget);
5446
+ if (related !== null && (related.closest('[data-chat-flow-kind="user"]') === item || related.closest('[data-side-memo-prompt-memo]') !== null))
5447
+ return;
5448
+ scheduleHide();
5449
+ }
5450
+ function onPointerDown(event) {
5451
+ var node = elementOf(event.target);
5452
+ if (node !== null && node.closest('[data-side-memo-prompt-memo]') !== null)
5453
+ return;
5454
+ hide();
5455
+ }
5456
+ function onKeyDown(event) {
5457
+ if (event.key === 'Escape')
5458
+ hide();
5459
+ }
5460
+ document.addEventListener('pointerover', onPointerOver, true);
5461
+ document.addEventListener('pointerout', onPointerOut, true);
5462
+ document.addEventListener('pointerdown', onPointerDown, true);
5463
+ document.addEventListener('keydown', onKeyDown);
5464
+ window.addEventListener('scroll', hide, true);
5465
+ window.addEventListener('resize', hide);
5466
+ return function () {
5467
+ clearHide();
5468
+ document.removeEventListener('pointerover', onPointerOver, true);
5469
+ document.removeEventListener('pointerout', onPointerOut, true);
5470
+ document.removeEventListener('pointerdown', onPointerDown, true);
5471
+ document.removeEventListener('keydown', onKeyDown);
5472
+ window.removeEventListener('scroll', hide, true);
5473
+ window.removeEventListener('resize', hide);
5474
+ };
5475
+ }, []);
5476
+ function save() {
5477
+ if (target === null)
5478
+ return;
5479
+ var draft = promptMemoDraft(target.text, labels);
5480
+ if (draft === null)
5481
+ return;
5482
+ var note = notes.create();
5483
+ if (note === undefined) {
5484
+ setFeedback('limit');
5485
+ return;
5486
+ }
5487
+ notes.update(note.id, { title: draft.title, body: draft.body });
5488
+ notes.flush();
5489
+ openRequest.set(note.id);
5490
+ if (typeof props.reveal === 'function')
5491
+ props.reveal();
5492
+ setFeedback('saved');
5493
+ }
5494
+ var saved = feedback === 'saved';
5495
+ var failed = feedback !== '' && !saved;
5496
+ var status = saved ? t('chat.saved') : feedback === 'limit' ? t('chat.limit') : '';
5497
+ if (target === null) {
5498
+ return h('span', { className: 'dsm-sr-only', role: 'status', 'aria-live': 'polite' }, status);
5499
+ }
5500
+ return h(React.Fragment, null, h('button', {
5501
+ type: 'button',
5502
+ className: 'dsm-prompt-memo',
5503
+ 'data-side-memo-prompt-memo': 'true',
5504
+ 'data-state': saved ? 'saved' : failed ? 'failed' : 'idle',
5505
+ style: { left: target.left + 'px', top: target.top + 'px' },
5506
+ title: saved ? t('chat.saved') : t('prompt.create'),
5507
+ 'aria-label': t('prompt.createAria'),
5508
+ onClick: save,
5509
+ onPointerEnter: clearHide,
5510
+ onPointerLeave: scheduleHide
5511
+ }, saved ? h(IconCheck, { size: 13 }) : h(IconChatMemo, { size: 13 })), h('span', { className: 'dsm-sr-only', role: 'status', 'aria-live': 'polite' }, status));
5512
+ }
4448
5513
  /**
4449
5514
  * The header toggle for the phases without the normal Session chrome.
4450
5515
  *
@@ -4594,7 +5659,7 @@ window.__ModuleLoader__.load({
4594
5659
  onClick: function () {
4595
5660
  drawer.set(false);
4596
5661
  }
4597
- }, h(IconX, { size: 16 }))), h('div', { className: 'dsm-drawer-body' }, h(NotesBody, { t: t, notes: props.notes }))));
5662
+ }, h(IconX, { size: 16 }))), h('div', { className: 'dsm-drawer-body' }, h(NotesBody, { t: t, notes: props.notes, openRequest: props.openRequest }))));
4598
5663
  }
4599
5664
  // ---------------------------------------------------------------------
4600
5665
  // Plugin body
@@ -4620,6 +5685,7 @@ window.__ModuleLoader__.load({
4620
5685
  fallbackT = ctx.locale.bind(NS);
4621
5686
  var notes = createNotesStore();
4622
5687
  var drawer = createDrawerStore();
5688
+ var openRequest = createOpenRequestStore();
4623
5689
  var mountedBinding = createMountedBinding(function () {
4624
5690
  return ctx.sidebarRight;
4625
5691
  });
@@ -4719,6 +5785,22 @@ window.__ModuleLoader__.load({
4719
5785
  }, 'could not toggle the notes panel in the right sidebar');
4720
5786
  drawer.set(false);
4721
5787
  }
5788
+ // Unlike toggleNotes, this never collapses an already-open panel: the
5789
+ // chat-memo button saved a memo and has to show it, not hide it.
5790
+ function revealNotes() {
5791
+ var mounted = readMountedSession(ctx.sidebarRight);
5792
+ if (mounted === undefined) {
5793
+ drawer.set(true);
5794
+ return;
5795
+ }
5796
+ retrySidebarCommand(function () {
5797
+ var right = ctx.sidebarRight;
5798
+ right.openTab(KIND);
5799
+ if (!right.isExpanded())
5800
+ right.toggleExpanded();
5801
+ }, 'could not open the notes panel in the right sidebar');
5802
+ drawer.set(false);
5803
+ }
4722
5804
  ctx.effect(function () {
4723
5805
  return ctx.slots.inject('conversation.session.header.utilities', function () {
4724
5806
  return ctx.slots.register({
@@ -4732,6 +5814,37 @@ window.__ModuleLoader__.load({
4732
5814
  }, NotesToggle);
4733
5815
  });
4734
5816
  }, 'side-memo: header toggle');
5817
+ // Turn the visible conversation into a memo in one click. The slot is
5818
+ // Session-scope, which is what supplies the standard `useChat` hook.
5819
+ ctx.effect(function () {
5820
+ return ctx.slots.inject('conversation.session.header.utilities', function () {
5821
+ return ctx.slots.register({
5822
+ name: 'conversation.session.header.utilities',
5823
+ id: 'side-memo-chat-memo',
5824
+ order: 31,
5825
+ locale: NS,
5826
+ inject: function () {
5827
+ return { reveal: revealNotes, notes: notes, openRequest: openRequest };
5828
+ }
5829
+ }, NotesChatMemo);
5830
+ });
5831
+ }, 'side-memo: chat memo');
5832
+ // One click next to a prompt turns that prompt into a memo. User messages
5833
+ // have no action slot, so the button rides the click-through overlay layer
5834
+ // and locates the hovered prompt in the message list.
5835
+ ctx.effect(function () {
5836
+ return ctx.slots.inject('shell.overlay', function () {
5837
+ return ctx.slots.register({
5838
+ name: 'shell.overlay',
5839
+ id: 'side-memo.prompt-memo',
5840
+ order: 45,
5841
+ locale: NS,
5842
+ inject: function () {
5843
+ return { notes: notes, openRequest: openRequest, reveal: revealNotes };
5844
+ }
5845
+ }, NotesPromptMemo);
5846
+ });
5847
+ }, 'side-memo: prompt memo');
4735
5848
  // The resident header's leading seat is the one place that exists before a
4736
5849
  // Session and while the new-Session (blank) chrome is up, so the toggle is
4737
5850
  // there exactly for those phases; once the Session is established the
@@ -4760,7 +5873,7 @@ window.__ModuleLoader__.load({
4760
5873
  order: 40,
4761
5874
  locale: NS,
4762
5875
  inject: function () {
4763
- return { notes: notes, drawer: drawer };
5876
+ return { notes: notes, drawer: drawer, openRequest: openRequest };
4764
5877
  }
4765
5878
  }, NotesDrawer);
4766
5879
  });
@@ -4798,7 +5911,7 @@ window.__ModuleLoader__.load({
4798
5911
  locale: NS,
4799
5912
  // Slot inject is a factory called as inject(bindingKey, actions).
4800
5913
  inject: function () {
4801
- return { notes: notes };
5914
+ return { notes: notes, openRequest: openRequest };
4802
5915
  },
4803
5916
  children: {
4804
5917
  'sidebar.right.tab.notes.actions': {
@@ -4887,6 +6000,7 @@ window.__ModuleLoader__.load({
4887
6000
  noteImageIds: noteImageIds,
4888
6001
  normalizeImageRecord: normalizeImageRecord,
4889
6002
  sortNotes: sortNotes,
6003
+ placeContextMenu: placeContextMenu,
4890
6004
  ageLabel: ageLabel,
4891
6005
  crc32: crc32,
4892
6006
  encodeText: encodeText,
@@ -4904,7 +6018,12 @@ window.__ModuleLoader__.load({
4904
6018
  importFavorite: importFavorite,
4905
6019
  parseLabelCell: parseLabelCell,
4906
6020
  normalizedHeader: normalizedHeader,
4907
- stripXmlPrefixes: stripXmlPrefixes
6021
+ stripXmlPrefixes: stripXmlPrefixes,
6022
+ // Chat → memo helpers (also used by the Node unit suite).
6023
+ chatMemoEntries: chatMemoEntries,
6024
+ chatMemoDraft: chatMemoDraft,
6025
+ createOpenRequestStore: createOpenRequestStore,
6026
+ promptMemoDraft: promptMemoDraft
4908
6027
  };
4909
6028
  }
4910
6029
  return module.exports;