dsh-side-memo 1.2.0 → 1.3.0

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,21 @@ 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
+ '.dsm-menu{position:absolute;z-index:20;min-width:172px;max-width:240px;padding:4px;box-sizing:border-box;',
354
+ 'display:flex;flex-direction:column;gap:1px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;',
355
+ 'background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);',
356
+ 'box-shadow:var(--dsw-elevation-prominent)}',
357
+ '.dsm-menu-item{display:flex;align-items:center;gap:8px;width:100%;height:30px;padding:0 8px;box-sizing:border-box;',
358
+ 'border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;',
359
+ 'font-size:12.5px;line-height:1.2;text-align:left;white-space:nowrap;cursor:pointer}',
360
+ '.dsm-menu-item:hover{background:var(--dsw-alias-bg-layer-1)}',
361
+ '.dsm-menu-item:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px;',
362
+ 'background:var(--dsw-alias-bg-layer-1)}',
363
+ '.dsm-menu-item[data-danger="true"]{color:var(--dsw-alias-state-error-primary)}',
364
+ '.dsm-menu-icon{display:flex;flex:0 0 auto;color:var(--dsw-alias-label-secondary)}',
365
+ '.dsm-menu-item[data-danger="true"] .dsm-menu-icon{color:inherit}',
366
+ '.dsm-menu-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}',
367
+ '.dsm-menu-sep{flex:0 0 auto;height:1px;margin:3px 4px;background:var(--dsw-alias-border-l1)}',
307
368
  '.dsm-foot-hint{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
308
369
  '[data-composer-input].dsm-composer-drop{outline:2px dashed var(--dsw-alias-brand-primary);outline-offset:2px;border-radius:10px}',
309
370
  '.dsm-icon-btn[data-favorite="true"]{color:var(--dsw-alias-brand-primary)}',
@@ -420,6 +481,26 @@ window.__ModuleLoader__.load({
420
481
  '.dsm-toggle:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}',
421
482
  '.dsm-toggle[data-active="true"]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}',
422
483
  '.dsm-toggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
484
+ '.dsm-toggle[data-state="saved"]{color:var(--dsw-alias-brand-primary)}',
485
+ '.dsm-toggle[data-state="failed"]{color:var(--dsw-alias-label-primary);opacity:.85}',
486
+ '.dsm-chat-memo{display:inline-flex;align-items:center;position:relative}',
487
+ '.dsm-chat-memo-menu{position:fixed;z-index:120;min-width:214px;padding:5px;display:flex;flex-direction:column;gap:2px;',
488
+ 'box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-specific-menu);',
489
+ 'backdrop-filter:var(--dsw-menu-backdrop-filter);box-shadow:var(--dsw-elevation-prominent)}',
490
+ '.dsm-chat-memo-item{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;padding:7px 9px;',
491
+ 'border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;',
492
+ 'line-height:1.35;text-align:left;cursor:pointer}',
493
+ '.dsm-chat-memo-item:hover{background:var(--dsw-alias-bg-layer-2)}',
494
+ '.dsm-chat-memo-item:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
495
+ '.dsm-chat-memo-note{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1.3}',
496
+ '.dsm-prompt-memo{position:fixed;z-index:115;display:inline-flex;align-items:center;justify-content:center;',
497
+ 'width:22px;height:22px;padding:0;pointer-events:auto;-webkit-app-region:no-drag;cursor:pointer;',
498
+ 'border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-specific-menu);',
499
+ 'color:var(--dsw-alias-label-secondary);box-shadow:var(--dsw-elevation-prominent)}',
500
+ '.dsm-prompt-memo:hover{color:var(--dsw-alias-label-primary)}',
501
+ '.dsm-prompt-memo:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
502
+ '.dsm-prompt-memo[data-state="saved"]{color:var(--dsw-alias-brand-primary)}',
503
+ '.dsm-prompt-memo[data-state="failed"]{color:var(--dsw-alias-label-primary);opacity:.85}',
423
504
  '.dsm-header-toggle{position:absolute;top:11px;right:62px;z-index:2}',
424
505
  '.dsm-drawer-layer{position:absolute;inset:0;pointer-events:none}',
425
506
  '.dsm-drawer{position:absolute;top:max(56px,var(--dsh-frame-overlay-top,12px));right:12px;bottom:12px;',
@@ -461,6 +542,10 @@ window.__ModuleLoader__.load({
461
542
  function IconNote(props) {
462
543
  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
544
  }
545
+ /** A speech bubble with memo lines: "turn this conversation into a memo". */
546
+ function IconChatMemo(props) {
547
+ 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' }));
548
+ }
464
549
  function IconSearch(props) {
465
550
  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
551
  }
@@ -1549,6 +1634,46 @@ window.__ModuleLoader__.load({
1549
1634
  emit();
1550
1635
  return next;
1551
1636
  },
1637
+ duplicate: function (id) {
1638
+ var index = -1;
1639
+ for (var at = 0; at < notes.length; at += 1) {
1640
+ if (notes[at].id === id) {
1641
+ index = at;
1642
+ break;
1643
+ }
1644
+ }
1645
+ if (index < 0)
1646
+ return undefined;
1647
+ if (notes.length >= MAX_NOTES)
1648
+ return undefined;
1649
+ var source = notes[index];
1650
+ var now = Date.now();
1651
+ var clonedContent = source.content.map(function (block) {
1652
+ if (block.type === 'image') {
1653
+ var image = { type: 'image', id: block.id };
1654
+ if (typeof block.width === 'number')
1655
+ image.width = block.width;
1656
+ if (typeof block.height === 'number')
1657
+ image.height = block.height;
1658
+ return image;
1659
+ }
1660
+ return { type: 'text', text: block.text };
1661
+ });
1662
+ var copy = {
1663
+ id: newNoteId(),
1664
+ title: source.title,
1665
+ body: source.body,
1666
+ content: clonedContent,
1667
+ createdAt: now,
1668
+ updatedAt: now,
1669
+ favorite: false,
1670
+ labels: normalizeLabels(source.labels)
1671
+ };
1672
+ notes = [copy].concat(notes);
1673
+ schedule();
1674
+ emit();
1675
+ return copy;
1676
+ },
1552
1677
  remove: function (id) {
1553
1678
  var next = notes.filter(function (note) {
1554
1679
  return note.id !== id;
@@ -2036,6 +2161,199 @@ window.__ModuleLoader__.load({
2036
2161
  }
2037
2162
  };
2038
2163
  }
2164
+ /**
2165
+ * One-shot "open this memo" hand-off used by the chat-memo button.
2166
+ *
2167
+ * It is a store rather than local state because the notes surface may not be
2168
+ * mounted yet when the memo is written: the button reveals the panel first,
2169
+ * and the notes body picks the request up as it mounts.
2170
+ */
2171
+ function createOpenRequestStore() {
2172
+ var requested = null;
2173
+ var listeners = new Set();
2174
+ function emit() {
2175
+ listeners.forEach(function (listener) {
2176
+ try {
2177
+ listener();
2178
+ }
2179
+ catch (error) {
2180
+ console.error('[dsh-side-memo] open-request listener failed', error);
2181
+ }
2182
+ });
2183
+ }
2184
+ return {
2185
+ subscribe: function (listener) {
2186
+ listeners.add(listener);
2187
+ return function () {
2188
+ listeners.delete(listener);
2189
+ };
2190
+ },
2191
+ getSnapshot: function () {
2192
+ return requested;
2193
+ },
2194
+ set: function (id) {
2195
+ var next = typeof id === 'string' && id.length > 0 ? id : null;
2196
+ if (next === requested)
2197
+ return;
2198
+ requested = next;
2199
+ emit();
2200
+ }
2201
+ };
2202
+ }
2203
+ /** Inert store for notes surfaces rendered without a chat-memo hand-off. */
2204
+ var nullOpenRequest = {
2205
+ subscribe: function () {
2206
+ return function () { };
2207
+ },
2208
+ getSnapshot: function () {
2209
+ return null;
2210
+ },
2211
+ set: function () { }
2212
+ };
2213
+ // ---------------------------------------------------------------------
2214
+ // Chat → memo: condense the visible conversation into one memo draft.
2215
+ // ---------------------------------------------------------------------
2216
+ // The title comes from the first user message; long prompts make hostile
2217
+ // titles, so cap the derived title well below the stored MAX_TITLE.
2218
+ var CHAT_MEMO_TITLE_MAX = 80;
2219
+ function chatMemoText(value) {
2220
+ return typeof value === 'string' ? value.replace(/\r\n?/g, '\n').trim() : '';
2221
+ }
2222
+ /** Join the text entries of one chat block list (images, tools, reasoning skipped). */
2223
+ function chatMemoBlocks(value, key) {
2224
+ if (!Array.isArray(value))
2225
+ return '';
2226
+ var parts = [];
2227
+ for (var index = 0; index < value.length; index += 1) {
2228
+ var block = value[index];
2229
+ if (block === null || typeof block !== 'object')
2230
+ continue;
2231
+ if (block[key] !== 'text')
2232
+ continue;
2233
+ var text = chatMemoText(block.text);
2234
+ if (text !== '')
2235
+ parts.push(text);
2236
+ }
2237
+ return parts.join('\n\n');
2238
+ }
2239
+ /**
2240
+ * Reduce visible chat nodes to ordered user/assistant text turns.
2241
+ * `mode` "prompts" keeps only what the user sent.
2242
+ */
2243
+ function chatMemoEntries(nodes, mode) {
2244
+ var entries = [];
2245
+ if (!Array.isArray(nodes))
2246
+ return entries;
2247
+ for (var index = 0; index < nodes.length; index += 1) {
2248
+ var node = nodes[index];
2249
+ if (node === null || typeof node !== 'object')
2250
+ continue;
2251
+ var kind = typeof node.kind === 'string' ? node.kind : '';
2252
+ var data = node.data === null || typeof node.data !== 'object' ? {} : node.data;
2253
+ var role = null;
2254
+ var text = '';
2255
+ if (kind === 'user' || kind === 'steering') {
2256
+ role = 'user';
2257
+ text = chatMemoBlocks(data.content, 'type');
2258
+ }
2259
+ else if (kind === 'assistant' || kind === 'assistant-step') {
2260
+ role = 'assistant';
2261
+ text = chatMemoBlocks(data.blocks, 'kind');
2262
+ }
2263
+ if (role === null || text === '')
2264
+ continue;
2265
+ if (mode === 'prompts' && role !== 'user')
2266
+ continue;
2267
+ entries.push({ role: role, text: text });
2268
+ }
2269
+ return entries;
2270
+ }
2271
+ function chatMemoHeading(entry, labels) {
2272
+ return (entry.role === 'user' ? labels.you : labels.assistant) + ':\n';
2273
+ }
2274
+ function chatMemoTitle(entries, labels) {
2275
+ var limit = Math.min(CHAT_MEMO_TITLE_MAX, MAX_TITLE);
2276
+ for (var index = 0; index < entries.length; index += 1) {
2277
+ if (entries[index].role !== 'user')
2278
+ continue;
2279
+ var collapsed = entries[index].text.replace(/\s+/g, ' ').trim();
2280
+ if (collapsed === '')
2281
+ continue;
2282
+ return collapsed.length > limit ? collapsed.slice(0, limit).trim() : collapsed;
2283
+ }
2284
+ return labels.fallbackTitle;
2285
+ }
2286
+ function chatMemoBody(entries, labels) {
2287
+ var parts = [];
2288
+ for (var index = 0; index < entries.length; index += 1) {
2289
+ parts.push(chatMemoHeading(entries[index], labels) + entries[index].text);
2290
+ }
2291
+ return parts.join('\n\n');
2292
+ }
2293
+ /**
2294
+ * Build the memo title/body for one conversation. Returns null when the chat
2295
+ * holds no user or assistant text (blank session, tools only, …).
2296
+ *
2297
+ * Transcripts are bounded twice: whole turns are dropped from the old end
2298
+ * until the body fits MAX_BODY, and a single oversized turn keeps its tail so
2299
+ * the newest words survive. `truncated` reports either adjustment.
2300
+ *
2301
+ * `mode` "prompts" saves only the user's messages; the title still comes from
2302
+ * the first prompt, and a chat with no prompts returns null.
2303
+ */
2304
+ function chatMemoDraft(nodes, labels, mode) {
2305
+ var entries = chatMemoEntries(nodes, mode);
2306
+ if (entries.length === 0)
2307
+ return null;
2308
+ var maxBody = MAX_BODY;
2309
+ var kept = entries.slice();
2310
+ var truncated = false;
2311
+ var body = chatMemoBody(kept, labels);
2312
+ while (body.length > maxBody && kept.length > 1) {
2313
+ kept.shift();
2314
+ truncated = true;
2315
+ body = chatMemoBody(kept, labels);
2316
+ }
2317
+ if (body.length > maxBody) {
2318
+ var last = kept[0];
2319
+ var room = maxBody - chatMemoHeading(last, labels).length;
2320
+ if (room > 0 && last.text.length > room) {
2321
+ kept = [{ role: last.role, text: last.text.slice(last.text.length - room) }];
2322
+ truncated = true;
2323
+ body = chatMemoBody(kept, labels);
2324
+ }
2325
+ }
2326
+ if (body.length > maxBody) {
2327
+ body = body.slice(body.length - maxBody);
2328
+ truncated = true;
2329
+ }
2330
+ if (body.trim() === '')
2331
+ return null;
2332
+ return {
2333
+ title: chatMemoTitle(entries, labels),
2334
+ body: body,
2335
+ messages: kept.length,
2336
+ truncated: truncated
2337
+ };
2338
+ }
2339
+ /**
2340
+ * Memo title/body for one prompt captured from the transcript. The body keeps
2341
+ * the prompt verbatim (only normalised line endings); the title is collapsed
2342
+ * and capped, and an oversized prompt keeps its tail so a memo can never
2343
+ * exceed the stored body limit.
2344
+ */
2345
+ function promptMemoDraft(value, labels) {
2346
+ var text = chatMemoText(value);
2347
+ if (text === '')
2348
+ return null;
2349
+ var body = text.length > MAX_BODY ? text.slice(text.length - MAX_BODY) : text;
2350
+ return {
2351
+ title: chatMemoTitle([{ role: 'user', text: text }], labels),
2352
+ body: body,
2353
+ messages: 1,
2354
+ truncated: body.length !== text.length
2355
+ };
2356
+ }
2039
2357
  /**
2040
2358
  * Read the Session the right sidebar can act on. `ctx.sidebarRight.mounted`
2041
2359
  * is the observable of the on-screen Session — undefined while no Session is
@@ -2159,6 +2477,38 @@ window.__ModuleLoader__.load({
2159
2477
  return right.updatedAt - left.updatedAt;
2160
2478
  });
2161
2479
  }
2480
+ /**
2481
+ * Fit a context menu inside its container. Coordinates are container-relative,
2482
+ * so the menu flips above the anchor when it would overflow the bottom edge and
2483
+ * is clamped to the container with a small margin on every side.
2484
+ */
2485
+ function placeContextMenu(anchor, size, bounds, margin) {
2486
+ var gap = typeof margin === 'number' && isFinite(margin) ? Math.max(0, margin) : 4;
2487
+ var anchorX = typeof anchor.x === 'number' && isFinite(anchor.x) ? anchor.x : gap;
2488
+ var anchorY = typeof anchor.y === 'number' && isFinite(anchor.y) ? anchor.y : gap;
2489
+ var width = typeof size.width === 'number' && isFinite(size.width) ? Math.max(0, size.width) : 0;
2490
+ var height = typeof size.height === 'number' && isFinite(size.height) ? Math.max(0, size.height) : 0;
2491
+ var boundWidth = typeof bounds.width === 'number' && isFinite(bounds.width) ? Math.max(0, bounds.width) : 0;
2492
+ var boundHeight = typeof bounds.height === 'number' && isFinite(bounds.height) ? Math.max(0, bounds.height) : 0;
2493
+ var left = Math.min(Math.max(anchorX, gap), Math.max(gap, boundWidth - width - gap));
2494
+ var top = anchorY;
2495
+ if (top + height + gap > boundHeight)
2496
+ top = anchorY - height - gap;
2497
+ top = Math.min(Math.max(top, gap), Math.max(gap, boundHeight - height - gap));
2498
+ return { left: Math.round(left), top: Math.round(top) };
2499
+ }
2500
+ /** One row of the memo context menu; keyboard focus is managed by the menu. */
2501
+ function contextMenuItem(key, label, icon, danger, onSelect) {
2502
+ return h('button', {
2503
+ type: 'button',
2504
+ role: 'menuitem',
2505
+ key: key,
2506
+ className: 'dsm-menu-item',
2507
+ 'data-danger': danger ? 'true' : undefined,
2508
+ tabIndex: -1,
2509
+ onClick: onSelect
2510
+ }, h('span', { className: 'dsm-menu-icon' }, icon), h('span', { className: 'dsm-menu-label' }, label));
2511
+ }
2162
2512
  function copyToClipboard(text) {
2163
2513
  if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
2164
2514
  return navigator.clipboard.writeText(text).then(function () {
@@ -2928,6 +3278,45 @@ window.__ModuleLoader__.load({
2928
3278
  var labelNoticeState = React.useState('');
2929
3279
  var labelNotice = labelNoticeState[0];
2930
3280
  var setLabelNotice = labelNoticeState[1];
3281
+ // Right-click menu on a memo row. Coordinates are relative to `.dsm-root`.
3282
+ var menuState = React.useState(null);
3283
+ var menu = menuState[0];
3284
+ var setMenu = menuState[1];
3285
+ var menuPositionState = React.useState(null);
3286
+ var menuPosition = menuPositionState[0];
3287
+ var setMenuPosition = menuPositionState[1];
3288
+ var menuArmedState = React.useState(false);
3289
+ var menuDeleteArmed = menuArmedState[0];
3290
+ var setMenuDeleteArmed = menuArmedState[1];
3291
+ var rootRef = React.useRef(null);
3292
+ var menuRef = React.useRef(null);
3293
+ var menuReturnFocusRef = React.useRef(null);
3294
+ var openRequest = props.openRequest === undefined ? nullOpenRequest : props.openRequest;
3295
+ var requestedNoteId = useSyncExternalStore(openRequest.subscribe, openRequest.getSnapshot, openRequest.getSnapshot);
3296
+ // A memo created from the chat header asks to be opened here. The panel can
3297
+ // mount after the request was filed, so the store is read on mount as well;
3298
+ // the request is consumed exactly once.
3299
+ React.useEffect(function () {
3300
+ if (requestedNoteId === null)
3301
+ return;
3302
+ openRequest.set(null);
3303
+ var found = false;
3304
+ for (var index = 0; index < snapshot.notes.length; index += 1) {
3305
+ if (snapshot.notes[index].id === requestedNoteId) {
3306
+ found = true;
3307
+ break;
3308
+ }
3309
+ }
3310
+ if (!found)
3311
+ return;
3312
+ setTransfer(null);
3313
+ setQuery('');
3314
+ setCopiedId(null);
3315
+ setEditorCopied(false);
3316
+ setDeleteArmed(false);
3317
+ setNewId(null);
3318
+ setEditingId(requestedNoteId);
3319
+ }, [requestedNoteId, openRequest, snapshot.notes]);
2931
3320
  var now = Date.now();
2932
3321
  var normalizedQuery = query.trim().toLocaleLowerCase();
2933
3322
  var labelFilterActive = activeLabels.length > 0;
@@ -3183,6 +3572,103 @@ window.__ModuleLoader__.load({
3183
3572
  }
3184
3573
  }
3185
3574
  }
3575
+ var menuTarget;
3576
+ if (menu !== null) {
3577
+ for (var menuAt = 0; menuAt < snapshot.notes.length; menuAt += 1) {
3578
+ if (snapshot.notes[menuAt].id === menu.id) {
3579
+ menuTarget = snapshot.notes[menuAt];
3580
+ break;
3581
+ }
3582
+ }
3583
+ }
3584
+ // Drop the menu when its memo disappears (deleted elsewhere, imported over, ...).
3585
+ React.useEffect(function () {
3586
+ if (menu === null || menuTarget !== undefined)
3587
+ return;
3588
+ setMenu(null);
3589
+ setMenuDeleteArmed(false);
3590
+ setMenuPosition(null);
3591
+ }, [menu, menuTarget, setMenu, setMenuDeleteArmed, setMenuPosition]);
3592
+ // Measure after render so the menu can be clamped or flipped inside the panel.
3593
+ React.useLayoutEffect(function () {
3594
+ if (menu === null)
3595
+ return;
3596
+ var root = rootRef.current;
3597
+ var element = menuRef.current;
3598
+ if (root === null || element === null)
3599
+ return;
3600
+ var next = placeContextMenu({ x: menu.x, y: menu.y }, { width: element.offsetWidth, height: element.offsetHeight }, { width: root.clientWidth, height: root.clientHeight });
3601
+ setMenuPosition(function (current) {
3602
+ if (current !== null && current.left === next.left && current.top === next.top)
3603
+ return current;
3604
+ return next;
3605
+ });
3606
+ }, [menu, menuDeleteArmed, setMenuPosition]);
3607
+ React.useLayoutEffect(function () {
3608
+ if (menu === null)
3609
+ return;
3610
+ var element = menuRef.current;
3611
+ if (element === null)
3612
+ return;
3613
+ var items = element.querySelectorAll('[role="menuitem"]');
3614
+ if (items.length === 0)
3615
+ return;
3616
+ try {
3617
+ items[0].focus();
3618
+ }
3619
+ catch (error) {
3620
+ // Focus is best effort; the menu still works with the mouse.
3621
+ }
3622
+ }, [menu]);
3623
+ React.useEffect(function () {
3624
+ if (menu === null)
3625
+ return undefined;
3626
+ if (typeof document === 'undefined' || typeof window === 'undefined')
3627
+ return undefined;
3628
+ function isInsideMenu(target) {
3629
+ var element = menuRef.current;
3630
+ if (element === null || target === null)
3631
+ return false;
3632
+ return element.contains(target);
3633
+ }
3634
+ function onPointerDown(event) {
3635
+ if (isInsideMenu(event.target))
3636
+ return;
3637
+ closeRowMenu(false);
3638
+ }
3639
+ function onKeyDown(event) {
3640
+ if (event.key !== 'Escape')
3641
+ return;
3642
+ event.preventDefault();
3643
+ event.stopPropagation();
3644
+ closeRowMenu(true);
3645
+ }
3646
+ function onDismiss() {
3647
+ closeRowMenu(false);
3648
+ }
3649
+ document.addEventListener('pointerdown', onPointerDown, true);
3650
+ document.addEventListener('keydown', onKeyDown, true);
3651
+ document.addEventListener('scroll', onDismiss, true);
3652
+ window.addEventListener('resize', onDismiss);
3653
+ window.addEventListener('blur', onDismiss);
3654
+ return function () {
3655
+ document.removeEventListener('pointerdown', onPointerDown, true);
3656
+ document.removeEventListener('keydown', onKeyDown, true);
3657
+ document.removeEventListener('scroll', onDismiss, true);
3658
+ window.removeEventListener('resize', onDismiss);
3659
+ window.removeEventListener('blur', onDismiss);
3660
+ };
3661
+ }, [menu]);
3662
+ React.useEffect(function () {
3663
+ if (!menuDeleteArmed)
3664
+ return undefined;
3665
+ var timer = setTimeout(function () {
3666
+ setMenuDeleteArmed(false);
3667
+ }, DELETE_ARM_MS);
3668
+ return function () {
3669
+ clearTimeout(timer);
3670
+ };
3671
+ }, [menuDeleteArmed, setMenuDeleteArmed]);
3186
3672
  React.useEffect(function () {
3187
3673
  if (editingId !== null && editing === undefined) {
3188
3674
  setEditingId(null);
@@ -3492,6 +3978,130 @@ window.__ModuleLoader__.load({
3492
3978
  setNewId(null);
3493
3979
  setDeleteArmed(false);
3494
3980
  }
3981
+ function openRowMenu(note, clientX, clientY, returnFocus) {
3982
+ var root = rootRef.current;
3983
+ var rect = root === null ? null : root.getBoundingClientRect();
3984
+ var x = rect === null ? clientX : clientX - rect.left;
3985
+ var y = rect === null ? clientY : clientY - rect.top;
3986
+ menuReturnFocusRef.current = returnFocus;
3987
+ setMenuDeleteArmed(false);
3988
+ setMenuPosition(null);
3989
+ setMenu({ id: note.id, x: x, y: y });
3990
+ }
3991
+ function closeRowMenu(restoreFocus) {
3992
+ var target = menuReturnFocusRef.current;
3993
+ menuReturnFocusRef.current = null;
3994
+ setMenu(null);
3995
+ setMenuDeleteArmed(false);
3996
+ setMenuPosition(null);
3997
+ if (!restoreFocus || target === null)
3998
+ return;
3999
+ if (typeof target.isConnected === 'boolean' && !target.isConnected)
4000
+ return;
4001
+ try {
4002
+ target.focus();
4003
+ }
4004
+ catch (error) {
4005
+ /* focusing is best effort */
4006
+ }
4007
+ }
4008
+ function menuNote() {
4009
+ if (menu === null)
4010
+ return undefined;
4011
+ for (var index = 0; index < snapshot.notes.length; index += 1) {
4012
+ if (snapshot.notes[index].id === menu.id)
4013
+ return snapshot.notes[index];
4014
+ }
4015
+ return undefined;
4016
+ }
4017
+ function handleMenuOpen() {
4018
+ var note = menuNote();
4019
+ closeRowMenu(true);
4020
+ if (note !== undefined)
4021
+ openNote(note.id);
4022
+ }
4023
+ function handleMenuCopy() {
4024
+ var note = menuNote();
4025
+ closeRowMenu(true);
4026
+ if (note !== undefined)
4027
+ copyNote(note);
4028
+ }
4029
+ function handleMenuDuplicate() {
4030
+ var note = menuNote();
4031
+ closeRowMenu(true);
4032
+ if (note === undefined)
4033
+ return;
4034
+ var copy = notes.duplicate(note.id);
4035
+ if (copy === undefined) {
4036
+ setAnnounce(t('limit.reached', { max: limits.maxNotes }));
4037
+ return;
4038
+ }
4039
+ setAnnounce(t('menu.duplicated'));
4040
+ }
4041
+ function handleMenuFavorite() {
4042
+ var note = menuNote();
4043
+ closeRowMenu(true);
4044
+ if (note !== undefined)
4045
+ toggleFavorite(note);
4046
+ }
4047
+ function handleMenuDelete() {
4048
+ var note = menuNote();
4049
+ if (note === undefined) {
4050
+ closeRowMenu(false);
4051
+ return;
4052
+ }
4053
+ if (!menuDeleteArmed) {
4054
+ setMenuDeleteArmed(true);
4055
+ return;
4056
+ }
4057
+ closeRowMenu(false);
4058
+ notes.remove(note.id);
4059
+ notes.flush();
4060
+ setAnnounce(t('menu.deleted'));
4061
+ }
4062
+ function moveMenuFocus(event) {
4063
+ if (event.key === 'Tab') {
4064
+ closeRowMenu(false);
4065
+ return;
4066
+ }
4067
+ var forward = event.key === 'ArrowDown';
4068
+ var backward = event.key === 'ArrowUp';
4069
+ var home = event.key === 'Home';
4070
+ var end = event.key === 'End';
4071
+ if (!forward && !backward && !home && !end)
4072
+ return;
4073
+ event.preventDefault();
4074
+ event.stopPropagation();
4075
+ var element = menuRef.current;
4076
+ if (element === null)
4077
+ return;
4078
+ var items = element.querySelectorAll('[role="menuitem"]');
4079
+ if (items.length === 0)
4080
+ return;
4081
+ var active = document.activeElement;
4082
+ var current = -1;
4083
+ for (var index = 0; index < items.length; index += 1) {
4084
+ if (items[index] === active) {
4085
+ current = index;
4086
+ break;
4087
+ }
4088
+ }
4089
+ var next;
4090
+ if (home)
4091
+ next = 0;
4092
+ else if (end)
4093
+ next = items.length - 1;
4094
+ else if (forward)
4095
+ next = current < 0 ? 0 : (current + 1) % items.length;
4096
+ else
4097
+ next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
4098
+ try {
4099
+ items[next].focus();
4100
+ }
4101
+ catch (error) {
4102
+ /* focusing is best effort */
4103
+ }
4104
+ }
3495
4105
  function updateEditing(field, value) {
3496
4106
  if (editing === undefined)
3497
4107
  return;
@@ -4266,6 +4876,20 @@ window.__ModuleLoader__.load({
4266
4876
  }
4267
4877
  fillMemoDragData(event.dataTransfer, note);
4268
4878
  setDraggingId(note.id);
4879
+ },
4880
+ onContextMenu: function (event) {
4881
+ event.preventDefault();
4882
+ event.stopPropagation();
4883
+ var focusTarget = event.currentTarget.querySelector('.dsm-row-main');
4884
+ var x = event.clientX;
4885
+ var y = event.clientY;
4886
+ if (x === 0 && y === 0) {
4887
+ // Keyboard-triggered context menu: anchor it to the row instead of 0,0.
4888
+ var rect = event.currentTarget.getBoundingClientRect();
4889
+ x = rect.left + Math.min(24, rect.width / 2);
4890
+ y = rect.bottom - 6;
4891
+ }
4892
+ openRowMenu(note, x, y, focusTarget);
4269
4893
  }
4270
4894
  }, h('button', {
4271
4895
  type: 'button',
@@ -4273,6 +4897,15 @@ window.__ModuleLoader__.load({
4273
4897
  title: title,
4274
4898
  onClick: function () {
4275
4899
  openNote(note.id);
4900
+ },
4901
+ onKeyDown: function (event) {
4902
+ var isMenuKey = event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10');
4903
+ if (!isMenuKey)
4904
+ return;
4905
+ event.preventDefault();
4906
+ event.stopPropagation();
4907
+ var rect = event.currentTarget.getBoundingClientRect();
4908
+ openRowMenu(note, rect.left + Math.min(24, rect.width / 2), rect.bottom - 6, event.currentTarget);
4276
4909
  }
4277
4910
  }, h('span', { className: 'dsm-row-title' }, h('span', { className: 'dsm-row-title-text' }, title), imageTotal > 0
4278
4911
  ? h('span', { className: 'dsm-row-images', title: imageCountLabel(t, imageTotal), 'aria-label': imageCountLabel(t, imageTotal) }, h(IconImage, { size: 11 }), String(imageTotal))
@@ -4333,7 +4966,33 @@ window.__ModuleLoader__.load({
4333
4966
  ? t('count.one')
4334
4967
  : t('count.other', { count: snapshot.notes.length });
4335
4968
  }
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', {
4969
+ var menuElement = null;
4970
+ if (menu !== null && menuTarget !== undefined) {
4971
+ var targetNote = menuTarget;
4972
+ var menuItems = [
4973
+ contextMenuItem('open', t('menu.open'), h(IconNote, { size: 14 }), false, handleMenuOpen),
4974
+ contextMenuItem('copy', t('copy'), h(IconCopy, { size: 14 }), false, handleMenuCopy),
4975
+ contextMenuItem('duplicate', t('menu.duplicate'), h(IconPlus, { size: 14 }), false, handleMenuDuplicate),
4976
+ contextMenuItem('favorite', targetNote.favorite === true ? t('favorite.remove') : t('favorite.add'), targetNote.favorite === true ? h(IconStarFilled, { size: 14 }) : h(IconStar, { size: 14 }), false, handleMenuFavorite),
4977
+ h('div', { className: 'dsm-menu-sep', role: 'separator', key: 'sep' }),
4978
+ contextMenuItem('delete', menuDeleteArmed ? t('delete.confirm') : t('delete'), h(IconX, { size: 14 }), true, handleMenuDelete)
4979
+ ];
4980
+ menuElement = h('div', {
4981
+ className: 'dsm-menu',
4982
+ role: 'menu',
4983
+ 'aria-label': t('menu.label'),
4984
+ ref: menuRef,
4985
+ style: {
4986
+ left: String(menuPosition === null ? menu.x : menuPosition.left) + 'px',
4987
+ top: String(menuPosition === null ? menu.y : menuPosition.top) + 'px'
4988
+ },
4989
+ onKeyDown: moveMenuFocus,
4990
+ onContextMenu: function (event) {
4991
+ event.preventDefault();
4992
+ }
4993
+ }, menuItems);
4994
+ }
4995
+ 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
4996
  type: 'search',
4338
4997
  value: query,
4339
4998
  placeholder: t('search.placeholder'),
@@ -4420,7 +5079,7 @@ window.__ModuleLoader__.load({
4420
5079
  }, t('labels.all'))
4421
5080
  : null), h('div', { className: 'dsm-list-scroll' }, listContent), h('div', { className: 'dsm-foot' }, h('span', null, countLabel), visibleNotes.length === 0 || props.inputActions === undefined
4422
5081
  ? 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);
5082
+ : 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
5083
  }
4425
5084
  // ---------------------------------------------------------------------
4426
5085
  // Chip title and header toggle
@@ -4445,6 +5104,374 @@ window.__ModuleLoader__.load({
4445
5104
  }
4446
5105
  }, h(IconNote, { size: 16 }));
4447
5106
  }
5107
+ /**
5108
+ * "Save this conversation as a memo" header button.
5109
+ *
5110
+ * The chat snapshot hook only exists inside a Session-scope slot, so this
5111
+ * wrapper renders nothing on hosts that do not supply it and defers the real
5112
+ * button — whose hook calls must be unconditional — to a child component.
5113
+ */
5114
+ function NotesChatMemo(props) {
5115
+ if (typeof props.useChat !== 'function')
5116
+ return null;
5117
+ return h(NotesChatMemoButton, props);
5118
+ }
5119
+ function NotesChatMemoButton(props) {
5120
+ var t = translateFor(props);
5121
+ var notes = props.notes;
5122
+ var openRequest = props.openRequest;
5123
+ var useChat = props.useChat;
5124
+ // Subscribe to the whole (identity-stable) snapshot; the transcript is
5125
+ // reduced only when the user picks a menu item, so this never allocates per
5126
+ // render and stays a safe useSyncExternalStore consumer.
5127
+ var chat = useChat(function (snapshot) {
5128
+ return snapshot;
5129
+ });
5130
+ var feedbackState = React.useState('');
5131
+ var feedback = feedbackState[0];
5132
+ var setFeedback = feedbackState[1];
5133
+ var menuState = React.useState(false);
5134
+ var menuOpen = menuState[0];
5135
+ var setMenuOpen = menuState[1];
5136
+ var anchorState = React.useState(null);
5137
+ var anchor = anchorState[0];
5138
+ var setAnchor = anchorState[1];
5139
+ var rootRef = React.useRef(null);
5140
+ var buttonRef = React.useRef(null);
5141
+ React.useEffect(function () {
5142
+ if (feedback === '')
5143
+ return undefined;
5144
+ var timer = setTimeout(function () {
5145
+ setFeedback('');
5146
+ }, feedback === 'saved' ? COPY_FEEDBACK_MS : COPY_FAILED_MS);
5147
+ return function () {
5148
+ clearTimeout(timer);
5149
+ };
5150
+ }, [feedback]);
5151
+ // The header clips its own children, so the menu is fixed-positioned under
5152
+ // the button and dismissed like a native menu: outside press, Escape, or
5153
+ // any scroll/resize that would leave it floating in the wrong place.
5154
+ React.useEffect(function () {
5155
+ if (!menuOpen)
5156
+ return undefined;
5157
+ function close() {
5158
+ setMenuOpen(false);
5159
+ }
5160
+ function onPointerDown(event) {
5161
+ var root = rootRef.current;
5162
+ if (root !== null && typeof root.contains === 'function' && root.contains(event.target))
5163
+ return;
5164
+ close();
5165
+ }
5166
+ function onKeyDown(event) {
5167
+ if (event.key === 'Escape')
5168
+ close();
5169
+ }
5170
+ document.addEventListener('pointerdown', onPointerDown, true);
5171
+ document.addEventListener('keydown', onKeyDown);
5172
+ window.addEventListener('resize', close);
5173
+ window.addEventListener('scroll', close, true);
5174
+ return function () {
5175
+ document.removeEventListener('pointerdown', onPointerDown, true);
5176
+ document.removeEventListener('keydown', onKeyDown);
5177
+ window.removeEventListener('resize', close);
5178
+ window.removeEventListener('scroll', close, true);
5179
+ };
5180
+ }, [menuOpen]);
5181
+ function visibleNodes() {
5182
+ var list = [];
5183
+ if (chat === null || chat === undefined)
5184
+ return list;
5185
+ var order = Array.isArray(chat.order) ? chat.order : [];
5186
+ var nodes = chat.nodes;
5187
+ if (nodes === null || nodes === undefined || typeof nodes.get !== 'function')
5188
+ return list;
5189
+ for (var index = 0; index < order.length; index += 1) {
5190
+ var node = nodes.get(order[index]);
5191
+ if (node !== null && node !== undefined)
5192
+ list.push(node);
5193
+ }
5194
+ return list;
5195
+ }
5196
+ function openMenu() {
5197
+ var button = buttonRef.current;
5198
+ if (button !== null && typeof button.getBoundingClientRect === 'function') {
5199
+ var rect = button.getBoundingClientRect();
5200
+ var width = 226;
5201
+ var viewport = typeof window === 'undefined' ? width : window.innerWidth;
5202
+ var left = Math.max(8, Math.min(rect.right - width, viewport - width - 8));
5203
+ setAnchor({ left: left, top: rect.bottom + 6 });
5204
+ }
5205
+ setMenuOpen(true);
5206
+ }
5207
+ function save(mode) {
5208
+ setMenuOpen(false);
5209
+ var draft = chatMemoDraft(visibleNodes(), {
5210
+ you: t('chat.you'),
5211
+ assistant: t('chat.assistant'),
5212
+ fallbackTitle: t('chat.fallbackTitle')
5213
+ }, mode);
5214
+ if (draft === null) {
5215
+ setFeedback(mode === 'prompts' ? 'empty-prompts' : 'empty');
5216
+ return;
5217
+ }
5218
+ var note = notes.create();
5219
+ if (note === undefined) {
5220
+ setFeedback('limit');
5221
+ return;
5222
+ }
5223
+ notes.update(note.id, { title: draft.title, body: draft.body });
5224
+ notes.flush();
5225
+ openRequest.set(note.id);
5226
+ if (typeof props.reveal === 'function')
5227
+ props.reveal();
5228
+ setFeedback('saved');
5229
+ }
5230
+ var saved = feedback === 'saved';
5231
+ var failed = feedback !== '' && !saved;
5232
+ var label = saved
5233
+ ? t('chat.saved')
5234
+ : feedback === 'limit'
5235
+ ? t('chat.limit')
5236
+ : feedback === 'empty-prompts'
5237
+ ? t('chat.emptyPrompts')
5238
+ : feedback === 'empty'
5239
+ ? t('chat.empty')
5240
+ : t('chat.create');
5241
+ var menuStyle = anchor === null ? undefined : { left: anchor.left + 'px', top: anchor.top + 'px' };
5242
+ return h('div', { className: 'dsm-chat-memo', ref: rootRef }, h('button', {
5243
+ type: 'button',
5244
+ className: 'dsm-toggle',
5245
+ ref: buttonRef,
5246
+ 'data-side-memo-chat-memo': 'true',
5247
+ 'data-state': saved ? 'saved' : failed ? 'failed' : 'idle',
5248
+ 'data-active': menuOpen ? 'true' : 'false',
5249
+ 'aria-haspopup': 'menu',
5250
+ 'aria-expanded': menuOpen ? 'true' : 'false',
5251
+ title: label,
5252
+ 'aria-label': saved || failed ? label : t('chat.createAria'),
5253
+ onClick: function () {
5254
+ if (menuOpen)
5255
+ setMenuOpen(false);
5256
+ else
5257
+ openMenu();
5258
+ }
5259
+ }, saved ? h(IconCheck, { size: 16 }) : h(IconChatMemo, { size: 16 })), menuOpen
5260
+ ? h('div', {
5261
+ className: 'dsm-chat-memo-menu',
5262
+ role: 'menu',
5263
+ 'aria-label': t('chat.menu.label'),
5264
+ style: menuStyle
5265
+ }, h('button', {
5266
+ type: 'button',
5267
+ role: 'menuitem',
5268
+ className: 'dsm-chat-memo-item',
5269
+ onClick: function () {
5270
+ save('chat');
5271
+ }
5272
+ }, h('span', null, t('chat.menu.chat')), h('span', { className: 'dsm-chat-memo-note' }, t('chat.menu.chatNote'))), h('button', {
5273
+ type: 'button',
5274
+ role: 'menuitem',
5275
+ className: 'dsm-chat-memo-item',
5276
+ onClick: function () {
5277
+ save('prompts');
5278
+ }
5279
+ }, h('span', null, t('chat.menu.prompts')), h('span', { className: 'dsm-chat-memo-note' }, t('chat.menu.promptsNote'))))
5280
+ : null, h('span', { className: 'dsm-sr-only', role: 'status', 'aria-live': 'polite' }, saved ? t('chat.saved') : ''));
5281
+ }
5282
+ /**
5283
+ * Quick "save this prompt as a memo" affordance.
5284
+ *
5285
+ * DSH renders a user message's action row itself and exposes no slot for it,
5286
+ * so the button is fixed-positioned next to that row and follows the pointer
5287
+ * from message to message. The prompt text is read from the
5288
+ * `conversation.chat.user.content` slot anchor, and the button lives in the
5289
+ * click-through `shell.overlay` layer so the message list's overflow and
5290
+ * hover scopes cannot clip it.
5291
+ */
5292
+ function NotesPromptMemo(props) {
5293
+ var t = translateFor(props);
5294
+ var labels = {
5295
+ you: t('chat.you'),
5296
+ assistant: t('chat.assistant'),
5297
+ fallbackTitle: t('chat.fallbackTitle')
5298
+ };
5299
+ var notes = props.notes;
5300
+ var openRequest = props.openRequest;
5301
+ var labelsRef = React.useRef(labels);
5302
+ labelsRef.current = labels;
5303
+ var targetState = React.useState(null);
5304
+ var target = targetState[0];
5305
+ var setTarget = targetState[1];
5306
+ var targetRef = React.useRef(target);
5307
+ targetRef.current = target;
5308
+ var feedbackState = React.useState('');
5309
+ var feedback = feedbackState[0];
5310
+ var setFeedback = feedbackState[1];
5311
+ var feedbackRef = React.useRef(feedback);
5312
+ feedbackRef.current = feedback;
5313
+ var hideTimer = React.useRef(null);
5314
+ function clearHide() {
5315
+ var timer = hideTimer.current;
5316
+ if (timer === null)
5317
+ return;
5318
+ clearTimeout(timer);
5319
+ hideTimer.current = null;
5320
+ }
5321
+ function hide() {
5322
+ clearHide();
5323
+ setTarget(null);
5324
+ }
5325
+ function scheduleHide() {
5326
+ clearHide();
5327
+ hideTimer.current = window.setTimeout(function () {
5328
+ hideTimer.current = null;
5329
+ setTarget(null);
5330
+ }, 140);
5331
+ }
5332
+ React.useEffect(function () {
5333
+ if (feedback === '')
5334
+ return undefined;
5335
+ var timer = setTimeout(function () {
5336
+ setFeedback('');
5337
+ }, feedback === 'saved' ? COPY_FEEDBACK_MS : COPY_FAILED_MS);
5338
+ return function () {
5339
+ clearTimeout(timer);
5340
+ };
5341
+ }, [feedback]);
5342
+ React.useEffect(function () {
5343
+ if (typeof document === 'undefined')
5344
+ return undefined;
5345
+ function elementOf(value) {
5346
+ return value !== null && typeof value === 'object' && 'closest' in value ? value : null;
5347
+ }
5348
+ function locate(item) {
5349
+ var row = item.querySelector('[data-clock="start"]');
5350
+ if (row === null)
5351
+ return null;
5352
+ var copy = row.querySelector('button');
5353
+ if (copy === null)
5354
+ return null;
5355
+ var copyBox = copy.getBoundingClientRect();
5356
+ if (copyBox.width === 0 && copyBox.height === 0)
5357
+ return null;
5358
+ var first = row.children.length === 0 ? null : row.children[0];
5359
+ var time = first !== null && first.tagName === 'SPAN' ? first : null;
5360
+ var left = Math.round(copyBox.right + 5);
5361
+ if (time !== null) {
5362
+ var timeBox = time.getBoundingClientRect();
5363
+ if (timeBox.right + 26 <= copyBox.left)
5364
+ left = Math.round(timeBox.right + 4);
5365
+ else
5366
+ left = Math.round((timeBox.right + copyBox.left) / 2 - 11);
5367
+ }
5368
+ var content = item.querySelector('[data-slot="conversation.chat.user.content"]');
5369
+ var text = content === null ? '' : content.innerText || content.textContent || '';
5370
+ if (promptMemoDraft(text, labelsRef.current) === null)
5371
+ return null;
5372
+ return {
5373
+ text: text,
5374
+ left: left,
5375
+ top: Math.round(copyBox.top + copyBox.height / 2 - 11)
5376
+ };
5377
+ }
5378
+ function onPointerOver(event) {
5379
+ var node = elementOf(event.target);
5380
+ if (node === null)
5381
+ return;
5382
+ if (node.closest('[data-side-memo-prompt-memo]') !== null) {
5383
+ clearHide();
5384
+ return;
5385
+ }
5386
+ var item = node.closest('[data-chat-flow-kind="user"]');
5387
+ if (item === null)
5388
+ return;
5389
+ var next = locate(item);
5390
+ if (next === null)
5391
+ return;
5392
+ var current = targetRef.current;
5393
+ if (current !== null && current.text === next.text && current.left === next.left && current.top === next.top)
5394
+ return;
5395
+ clearHide();
5396
+ if (feedbackRef.current !== '')
5397
+ setFeedback('');
5398
+ setTarget(next);
5399
+ }
5400
+ function onPointerOut(event) {
5401
+ var node = elementOf(event.target);
5402
+ if (node === null)
5403
+ return;
5404
+ var item = node.closest('[data-chat-flow-kind="user"]');
5405
+ if (item === null)
5406
+ return;
5407
+ var related = elementOf(event.relatedTarget);
5408
+ if (related !== null && (related.closest('[data-chat-flow-kind="user"]') === item || related.closest('[data-side-memo-prompt-memo]') !== null))
5409
+ return;
5410
+ scheduleHide();
5411
+ }
5412
+ function onPointerDown(event) {
5413
+ var node = elementOf(event.target);
5414
+ if (node !== null && node.closest('[data-side-memo-prompt-memo]') !== null)
5415
+ return;
5416
+ hide();
5417
+ }
5418
+ function onKeyDown(event) {
5419
+ if (event.key === 'Escape')
5420
+ hide();
5421
+ }
5422
+ document.addEventListener('pointerover', onPointerOver, true);
5423
+ document.addEventListener('pointerout', onPointerOut, true);
5424
+ document.addEventListener('pointerdown', onPointerDown, true);
5425
+ document.addEventListener('keydown', onKeyDown);
5426
+ window.addEventListener('scroll', hide, true);
5427
+ window.addEventListener('resize', hide);
5428
+ return function () {
5429
+ clearHide();
5430
+ document.removeEventListener('pointerover', onPointerOver, true);
5431
+ document.removeEventListener('pointerout', onPointerOut, true);
5432
+ document.removeEventListener('pointerdown', onPointerDown, true);
5433
+ document.removeEventListener('keydown', onKeyDown);
5434
+ window.removeEventListener('scroll', hide, true);
5435
+ window.removeEventListener('resize', hide);
5436
+ };
5437
+ }, []);
5438
+ function save() {
5439
+ if (target === null)
5440
+ return;
5441
+ var draft = promptMemoDraft(target.text, labels);
5442
+ if (draft === null)
5443
+ return;
5444
+ var note = notes.create();
5445
+ if (note === undefined) {
5446
+ setFeedback('limit');
5447
+ return;
5448
+ }
5449
+ notes.update(note.id, { title: draft.title, body: draft.body });
5450
+ notes.flush();
5451
+ openRequest.set(note.id);
5452
+ if (typeof props.reveal === 'function')
5453
+ props.reveal();
5454
+ setFeedback('saved');
5455
+ }
5456
+ var saved = feedback === 'saved';
5457
+ var failed = feedback !== '' && !saved;
5458
+ var status = saved ? t('chat.saved') : feedback === 'limit' ? t('chat.limit') : '';
5459
+ if (target === null) {
5460
+ return h('span', { className: 'dsm-sr-only', role: 'status', 'aria-live': 'polite' }, status);
5461
+ }
5462
+ return h(React.Fragment, null, h('button', {
5463
+ type: 'button',
5464
+ className: 'dsm-prompt-memo',
5465
+ 'data-side-memo-prompt-memo': 'true',
5466
+ 'data-state': saved ? 'saved' : failed ? 'failed' : 'idle',
5467
+ style: { left: target.left + 'px', top: target.top + 'px' },
5468
+ title: saved ? t('chat.saved') : t('prompt.create'),
5469
+ 'aria-label': t('prompt.createAria'),
5470
+ onClick: save,
5471
+ onPointerEnter: clearHide,
5472
+ onPointerLeave: scheduleHide
5473
+ }, saved ? h(IconCheck, { size: 13 }) : h(IconChatMemo, { size: 13 })), h('span', { className: 'dsm-sr-only', role: 'status', 'aria-live': 'polite' }, status));
5474
+ }
4448
5475
  /**
4449
5476
  * The header toggle for the phases without the normal Session chrome.
4450
5477
  *
@@ -4594,7 +5621,7 @@ window.__ModuleLoader__.load({
4594
5621
  onClick: function () {
4595
5622
  drawer.set(false);
4596
5623
  }
4597
- }, h(IconX, { size: 16 }))), h('div', { className: 'dsm-drawer-body' }, h(NotesBody, { t: t, notes: props.notes }))));
5624
+ }, h(IconX, { size: 16 }))), h('div', { className: 'dsm-drawer-body' }, h(NotesBody, { t: t, notes: props.notes, openRequest: props.openRequest }))));
4598
5625
  }
4599
5626
  // ---------------------------------------------------------------------
4600
5627
  // Plugin body
@@ -4620,6 +5647,7 @@ window.__ModuleLoader__.load({
4620
5647
  fallbackT = ctx.locale.bind(NS);
4621
5648
  var notes = createNotesStore();
4622
5649
  var drawer = createDrawerStore();
5650
+ var openRequest = createOpenRequestStore();
4623
5651
  var mountedBinding = createMountedBinding(function () {
4624
5652
  return ctx.sidebarRight;
4625
5653
  });
@@ -4719,6 +5747,22 @@ window.__ModuleLoader__.load({
4719
5747
  }, 'could not toggle the notes panel in the right sidebar');
4720
5748
  drawer.set(false);
4721
5749
  }
5750
+ // Unlike toggleNotes, this never collapses an already-open panel: the
5751
+ // chat-memo button saved a memo and has to show it, not hide it.
5752
+ function revealNotes() {
5753
+ var mounted = readMountedSession(ctx.sidebarRight);
5754
+ if (mounted === undefined) {
5755
+ drawer.set(true);
5756
+ return;
5757
+ }
5758
+ retrySidebarCommand(function () {
5759
+ var right = ctx.sidebarRight;
5760
+ right.openTab(KIND);
5761
+ if (!right.isExpanded())
5762
+ right.toggleExpanded();
5763
+ }, 'could not open the notes panel in the right sidebar');
5764
+ drawer.set(false);
5765
+ }
4722
5766
  ctx.effect(function () {
4723
5767
  return ctx.slots.inject('conversation.session.header.utilities', function () {
4724
5768
  return ctx.slots.register({
@@ -4732,6 +5776,37 @@ window.__ModuleLoader__.load({
4732
5776
  }, NotesToggle);
4733
5777
  });
4734
5778
  }, 'side-memo: header toggle');
5779
+ // Turn the visible conversation into a memo in one click. The slot is
5780
+ // Session-scope, which is what supplies the standard `useChat` hook.
5781
+ ctx.effect(function () {
5782
+ return ctx.slots.inject('conversation.session.header.utilities', function () {
5783
+ return ctx.slots.register({
5784
+ name: 'conversation.session.header.utilities',
5785
+ id: 'side-memo-chat-memo',
5786
+ order: 31,
5787
+ locale: NS,
5788
+ inject: function () {
5789
+ return { reveal: revealNotes, notes: notes, openRequest: openRequest };
5790
+ }
5791
+ }, NotesChatMemo);
5792
+ });
5793
+ }, 'side-memo: chat memo');
5794
+ // One click next to a prompt turns that prompt into a memo. User messages
5795
+ // have no action slot, so the button rides the click-through overlay layer
5796
+ // and locates the hovered prompt in the message list.
5797
+ ctx.effect(function () {
5798
+ return ctx.slots.inject('shell.overlay', function () {
5799
+ return ctx.slots.register({
5800
+ name: 'shell.overlay',
5801
+ id: 'side-memo.prompt-memo',
5802
+ order: 45,
5803
+ locale: NS,
5804
+ inject: function () {
5805
+ return { notes: notes, openRequest: openRequest, reveal: revealNotes };
5806
+ }
5807
+ }, NotesPromptMemo);
5808
+ });
5809
+ }, 'side-memo: prompt memo');
4735
5810
  // The resident header's leading seat is the one place that exists before a
4736
5811
  // Session and while the new-Session (blank) chrome is up, so the toggle is
4737
5812
  // there exactly for those phases; once the Session is established the
@@ -4760,7 +5835,7 @@ window.__ModuleLoader__.load({
4760
5835
  order: 40,
4761
5836
  locale: NS,
4762
5837
  inject: function () {
4763
- return { notes: notes, drawer: drawer };
5838
+ return { notes: notes, drawer: drawer, openRequest: openRequest };
4764
5839
  }
4765
5840
  }, NotesDrawer);
4766
5841
  });
@@ -4798,7 +5873,7 @@ window.__ModuleLoader__.load({
4798
5873
  locale: NS,
4799
5874
  // Slot inject is a factory called as inject(bindingKey, actions).
4800
5875
  inject: function () {
4801
- return { notes: notes };
5876
+ return { notes: notes, openRequest: openRequest };
4802
5877
  },
4803
5878
  children: {
4804
5879
  'sidebar.right.tab.notes.actions': {
@@ -4887,6 +5962,7 @@ window.__ModuleLoader__.load({
4887
5962
  noteImageIds: noteImageIds,
4888
5963
  normalizeImageRecord: normalizeImageRecord,
4889
5964
  sortNotes: sortNotes,
5965
+ placeContextMenu: placeContextMenu,
4890
5966
  ageLabel: ageLabel,
4891
5967
  crc32: crc32,
4892
5968
  encodeText: encodeText,
@@ -4904,7 +5980,12 @@ window.__ModuleLoader__.load({
4904
5980
  importFavorite: importFavorite,
4905
5981
  parseLabelCell: parseLabelCell,
4906
5982
  normalizedHeader: normalizedHeader,
4907
- stripXmlPrefixes: stripXmlPrefixes
5983
+ stripXmlPrefixes: stripXmlPrefixes,
5984
+ // Chat → memo helpers (also used by the Node unit suite).
5985
+ chatMemoEntries: chatMemoEntries,
5986
+ chatMemoDraft: chatMemoDraft,
5987
+ createOpenRequestStore: createOpenRequestStore,
5988
+ promptMemoDraft: promptMemoDraft
4908
5989
  };
4909
5990
  }
4910
5991
  return module.exports;