@logic-dante/dsh-conversation-manager 0.0.0-stage → 1.5.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 ADDED
@@ -0,0 +1,1161 @@
1
+ /**
2
+ * dsh-conversation-manager — browser half (distributed as-is, no build step).
3
+ *
4
+ * Adds one red row to a session's "..." menu, right after "Copy Session ID":
5
+ * clicking it deletes the conversation for good (host half: index.js).
6
+ */
7
+ window.__ModuleLoader__.load({
8
+ id: '@logic-dante/dsh-conversation-manager',
9
+ factory: (require) => {
10
+ const React = require('react');
11
+ const h = React.createElement;
12
+
13
+ const SLOT = 'sidebar.workspaces.session.menu.item';
14
+ const NAMESPACE = 'conversation-manager';
15
+ const ROUTE = 'api/conversation-manager/delete';
16
+ const ACTION_HEADER = 'x-dsh-conversation-manager';
17
+
18
+ const FALLBACK = {
19
+ 'menu.delete': '删除对话',
20
+ 'menu.busy': '正在删除…',
21
+ 'menu.done': '已删除',
22
+ 'menu.retry': '删除失败,点击重试',
23
+ 'error.empty': '没有收到主进程响应,请重试。',
24
+ 'multi.label': '多选',
25
+ 'multi.selected': '已选 {count} 个对话',
26
+ 'multi.none': '未选择对话',
27
+ 'multi.cancel': '取消',
28
+ 'multi.delete': '删除',
29
+ 'multi.busy': '正在删除…',
30
+ 'multi.failed': '有 {count} 个对话删除失败',
31
+ 'multi.confirm.title': '删除选中的对话?',
32
+ 'multi.confirm.body': '将永久删除 {count} 个对话及其本地记录,无法恢复。',
33
+ 'multi.confirm.cancel': '取消',
34
+ 'multi.confirm.confirm': '确认删除',
35
+ };
36
+
37
+ const dictionaries = {
38
+ zh: FALLBACK,
39
+ en: {
40
+ 'menu.delete': 'Delete conversation',
41
+ 'menu.busy': 'Deleting…',
42
+ 'menu.done': 'Deleted',
43
+ 'menu.retry': 'Delete failed, click to retry',
44
+ 'error.empty': 'No response from the host. Try again.',
45
+ 'multi.label': 'Select',
46
+ 'multi.selected': '{count} selected',
47
+ 'multi.none': 'Nothing selected',
48
+ 'multi.cancel': 'Cancel',
49
+ 'multi.delete': 'Delete',
50
+ 'multi.busy': 'Deleting…',
51
+ 'multi.failed': '{count} conversation(s) failed to delete',
52
+ 'multi.confirm.title': 'Delete the selected conversations?',
53
+ 'multi.confirm.body': 'This permanently deletes {count} conversations and their local records. It cannot be undone.',
54
+ 'multi.confirm.cancel': 'Cancel',
55
+ 'multi.confirm.confirm': 'Delete',
56
+ },
57
+ };
58
+
59
+ /** Ask the Host half to delete one conversation; throws the Host's reason. */
60
+ async function requestDelete(sessionId) {
61
+ const response = await fetch(new URL(ROUTE, document.baseURI), {
62
+ method: 'POST',
63
+ credentials: 'same-origin',
64
+ headers: { 'content-type': 'application/json', [ACTION_HEADER]: '1' },
65
+ body: JSON.stringify({ sessionId }),
66
+ });
67
+ let payload;
68
+ try {
69
+ payload = await response.json();
70
+ } catch {
71
+ payload = undefined;
72
+ }
73
+ if (!response.ok || !payload || payload.ok !== true) {
74
+ throw new Error((payload && (payload.message || payload.error)) || `HTTP ${response.status}`);
75
+ }
76
+ return payload;
77
+ }
78
+
79
+ // How long the sidebar gets to drop the row by itself (Host pushes the new
80
+ // workspace snapshot) before falling back to a full page reload.
81
+ const LIVE_UPDATE_GRACE_MS = 1600;
82
+
83
+ // v1 removal choreography (milliseconds): a strike line sweeps left→right over
84
+ // the Session title, the row rests a beat, then grays out, sinks, and flies off
85
+ // to the left. Deliberately slow: the whole exit is meant to be watched.
86
+ const STRIKE_MS = 650;
87
+ const HOLD_MS = 160;
88
+ const SINK_MS = 420;
89
+ const REST_MS = 200;
90
+ const FLY_MS = 640;
91
+ const REMOVAL_MS = STRIKE_MS + HOLD_MS + SINK_MS + REST_MS + FLY_MS;
92
+ const OWN_ROW_SELECTOR = '[data-dsh-conversation-manager="delete"]';
93
+
94
+ // The Session list lives in the left sidebar column, while the conversation
95
+ // header at the top of the page repeats the very same title text. A row is
96
+ // therefore only accepted when it hugs the window's left edge, which is what
97
+ // separates a sidebar row from the page header.
98
+ const SIDEBAR_MAX_LEFT = 140;
99
+ const SIDEBAR_MAX_WIDTH = 640;
100
+
101
+ /** Is this element shaped like one Session row of the sidebar list? */
102
+ function looksLikeSessionRow(element) {
103
+ if (!element || element.querySelector(OWN_ROW_SELECTOR)) return false;
104
+ const rect = element.getBoundingClientRect();
105
+ if (!(rect.height >= 22 && rect.height <= 64)) return false;
106
+ if (!(rect.width >= 140 && rect.width <= SIDEBAR_MAX_WIDTH)) return false;
107
+ return rect.left <= SIDEBAR_MAX_LEFT;
108
+ }
109
+
110
+ /** Walk up from `element` to the nearest ancestor that reads as a Session row. */
111
+ function closestSessionRow(element, displayTitle) {
112
+ let node = element;
113
+ for (let depth = 0; depth < 6 && node; depth += 1) {
114
+ if (looksLikeSessionRow(node)) {
115
+ if (!displayTitle) return node;
116
+ const text = (node.textContent || '').replace(/\s+/g, ' ').trim();
117
+ if (text.indexOf(displayTitle) !== -1) return node;
118
+ }
119
+ node = node.parentElement;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Locate the sidebar row this menu belongs to. The row belongs to the Host's own
126
+ * list, so it is found from the live DOM: first through the open "..." trigger
127
+ * (it carries aria-expanded), then by the row title itself.
128
+ */
129
+ function findSessionRow(displayTitle) {
130
+ if (typeof document === 'undefined') return null;
131
+ const title = typeof displayTitle === 'string' ? displayTitle.replace(/\s+/g, ' ').trim() : '';
132
+ const triggers = document.querySelectorAll('[aria-expanded="true"]');
133
+ for (const trigger of triggers) {
134
+ const row = closestSessionRow(trigger.parentElement, title);
135
+ if (row) return row;
136
+ }
137
+ if (!title) return null;
138
+ const nodes = document.querySelectorAll('span, div, a, button, p');
139
+ let match = null;
140
+ for (const node of nodes) {
141
+ if (node.children.length > 0) continue;
142
+ if (node.querySelector(OWN_ROW_SELECTOR)) continue;
143
+ if ((node.textContent || '').replace(/\s+/g, ' ').trim() !== title) continue;
144
+ // Only the sidebar copy of the title counts: the page header repeats it.
145
+ const nodeRect = typeof node.getBoundingClientRect === 'function' ? node.getBoundingClientRect() : null;
146
+ if (nodeRect && nodeRect.left > SIDEBAR_MAX_LEFT) continue;
147
+ match = node;
148
+ }
149
+ if (!match) return null;
150
+ return closestSessionRow(match, title) || match.parentElement;
151
+ }
152
+
153
+ /**
154
+ * The leaf element inside the row that renders the Session title. Rows often
155
+ * carry a trailing relative time in the same text run, so a containing (but
156
+ * tightest) match is accepted too.
157
+ */
158
+ function findTitleElement(row, displayTitle) {
159
+ const title = typeof displayTitle === 'string' ? displayTitle.replace(/\s+/g, ' ').trim() : '';
160
+ if (!title || typeof row.querySelectorAll !== 'function') return null;
161
+ const nodes = row.querySelectorAll('span, div, p, a, button');
162
+ let exact = null;
163
+ let containing = null;
164
+ for (const node of nodes) {
165
+ if (node === row) continue;
166
+ if (node.children && node.children.length > 0) continue;
167
+ const text = (node.textContent || '').replace(/\s+/g, ' ').trim();
168
+ if (text === title) exact = node;
169
+ else if (!containing && text.indexOf(title) !== -1) containing = node;
170
+ }
171
+ return exact || containing;
172
+ }
173
+
174
+ /** The first text node below `element` that contains `title`. */
175
+ function firstTextRun(element, title) {
176
+ const stack = [element];
177
+ while (stack.length > 0) {
178
+ const node = stack.pop();
179
+ if (!node) continue;
180
+ if (node.nodeType === 3) {
181
+ const value = node.nodeValue || '';
182
+ const index = value.replace(/\s+/g, ' ').indexOf(title);
183
+ if (index !== -1) return { node, index, length: title.length };
184
+ continue;
185
+ }
186
+ const children = node.childNodes;
187
+ if (children) {
188
+ for (let index = children.length - 1; index >= 0; index -= 1) stack.push(children[index]);
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Where the strike line goes: across the title text itself (a pen stroke over
196
+ * the label), falling back to the whole row when the title cannot be measured.
197
+ */
198
+ function strikePlacement(row, titleElement, displayTitle) {
199
+ if (!titleElement || typeof titleElement.getBoundingClientRect !== 'function') return null;
200
+ const title = typeof displayTitle === 'string' ? displayTitle.replace(/\s+/g, ' ').trim() : '';
201
+ try {
202
+ const rowRect = row.getBoundingClientRect();
203
+ let titleRect = null;
204
+ // Exact text-run measurement: only the title characters get struck, even
205
+ // when the row also renders a relative timestamp next to it.
206
+ if (title && typeof document !== 'undefined' && typeof document.createRange === 'function') {
207
+ const run = firstTextRun(titleElement, title);
208
+ if (run) {
209
+ try {
210
+ const range = document.createRange();
211
+ range.setStart(run.node, run.index);
212
+ range.setEnd(run.node, run.index + run.length);
213
+ const measured = range.getBoundingClientRect();
214
+ if (measured && measured.width > 0) titleRect = measured;
215
+ range.detach && range.detach();
216
+ } catch {
217
+ titleRect = null;
218
+ }
219
+ }
220
+ }
221
+ if (!titleRect) titleRect = titleElement.getBoundingClientRect();
222
+ if (!titleRect || !(titleRect.width > 0) || !(titleRect.height > 0)) return null;
223
+ return {
224
+ left: titleRect.left - rowRect.left,
225
+ top: titleRect.top - rowRect.top + titleRect.height / 2 - 1,
226
+ width: titleRect.width,
227
+ };
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Play the v1 removal choreography on the row itself, before the Host drops it.
235
+ * `done` settles when the choreography finishes; `restore` puts the row back
236
+ * when the delete turns out to have failed.
237
+ */
238
+ function startRemovalAnimation(row, displayTitle) {
239
+ const media = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
240
+ const reduced = Boolean(media && media.matches);
241
+ const previous = { position: row.style.position, overflow: row.style.overflow, pointerEvents: row.style.pointerEvents };
242
+ const animations = [];
243
+ let line = null;
244
+ if (!reduced) {
245
+ try {
246
+ row.style.position = 'relative';
247
+ row.style.overflow = 'hidden';
248
+ row.style.pointerEvents = 'none';
249
+ // Strike the title text, like crossing the words out with a pen.
250
+ const placement = strikePlacement(row, findTitleElement(row, displayTitle), displayTitle);
251
+ line = document.createElement('span');
252
+ line.setAttribute('aria-hidden', 'true');
253
+ // A pen stroke leaves a little overshoot on both ends of the word.
254
+ const overshoot = 4;
255
+ Object.assign(line.style, {
256
+ position: 'absolute',
257
+ left: placement ? `${Math.max(0, placement.left - overshoot)}px` : '8px',
258
+ top: placement ? `${placement.top}px` : '50%',
259
+ width: placement ? `${placement.width + overshoot * 2}px` : 'calc(100% - 16px)',
260
+ height: '2px',
261
+ borderRadius: '2px',
262
+ pointerEvents: 'none',
263
+ zIndex: '3',
264
+ background:
265
+ 'linear-gradient(90deg, rgba(229,72,77,0.35), var(--dsw-alias-state-error-primary, #e5484d) 18%, #ff8a8d)',
266
+ boxShadow: '0 0 6px rgba(229,72,77,0.35)',
267
+ transform: 'scaleX(0)',
268
+ transformOrigin: 'left center',
269
+ });
270
+ row.appendChild(line);
271
+ if (typeof line.animate === 'function') {
272
+ animations.push(
273
+ line.animate([{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }], {
274
+ duration: STRIKE_MS,
275
+ easing: 'cubic-bezier(0.65, 0, 0.35, 1)',
276
+ fill: 'forwards',
277
+ }),
278
+ );
279
+ }
280
+ if (typeof row.animate === 'function') {
281
+ const exitTotal = SINK_MS + REST_MS + FLY_MS;
282
+ const sinkEnd = SINK_MS / exitTotal;
283
+ const restEnd = (SINK_MS + REST_MS) / exitTotal;
284
+ animations.push(
285
+ row.animate(
286
+ [
287
+ { filter: 'grayscale(0)', transform: 'translate3d(0, 0, 0) scale(1)', opacity: 1, offset: 0 },
288
+ { filter: 'grayscale(1)', transform: 'translate3d(0, 9px, 0) scale(0.985)', opacity: 1, offset: sinkEnd },
289
+ { filter: 'grayscale(1)', transform: 'translate3d(0, 10px, 0) scale(0.985)', opacity: 0.98, offset: restEnd },
290
+ { filter: 'grayscale(1)', transform: 'translate3d(-120%, 18px, 0) scale(0.96)', opacity: 0, offset: 1 },
291
+ ],
292
+ {
293
+ duration: exitTotal,
294
+ delay: STRIKE_MS + HOLD_MS,
295
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
296
+ fill: 'forwards',
297
+ },
298
+ ),
299
+ );
300
+ }
301
+ } catch {
302
+ /* the animation is a nicety: never let it block the delete */
303
+ }
304
+ }
305
+ const done = new Promise((resolve) => {
306
+ if (reduced || animations.length === 0) {
307
+ resolve();
308
+ return;
309
+ }
310
+ window.setTimeout(resolve, REMOVAL_MS);
311
+ });
312
+ return {
313
+ done,
314
+ restore() {
315
+ for (const animation of animations) {
316
+ try {
317
+ animation.cancel();
318
+ } catch {
319
+ /* already finished */
320
+ }
321
+ }
322
+ if (line && line.parentNode) line.parentNode.removeChild(line);
323
+ row.style.position = previous.position;
324
+ row.style.overflow = previous.overflow;
325
+ row.style.pointerEvents = previous.pointerEvents;
326
+ },
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Ask the slot's live Workspace snapshot whether this Session is still listed
332
+ * anywhere (workspace rows, archive set, pin set). A boolean selector keeps the
333
+ * subscription stable, and an unusable hook only costs us the fallback path.
334
+ */
335
+ function useSessionListed(props, sessionId) {
336
+ const hook = props.useWorkspaces;
337
+ if (typeof hook !== 'function') return false;
338
+ try {
339
+ return hook((snapshot) => snapshotHasSession(snapshot, sessionId)) === true;
340
+ } catch {
341
+ return false;
342
+ }
343
+ }
344
+
345
+ /** Does the deleted Session still show up anywhere in the sidebar snapshot? */
346
+ function snapshotHasSession(snapshot, sessionId) {
347
+ if (!snapshot || typeof snapshot !== 'object' || typeof sessionId !== 'string') return false;
348
+ const seen = new Set();
349
+ const stack = [snapshot];
350
+ let budget = 20000;
351
+ while (stack.length > 0 && budget-- > 0) {
352
+ const value = stack.pop();
353
+ if (typeof value === 'string') {
354
+ if (value === sessionId) return true;
355
+ continue;
356
+ }
357
+ if (!value || typeof value !== 'object' || seen.has(value)) continue;
358
+ seen.add(value);
359
+ if (Array.isArray(value)) {
360
+ for (const item of value) stack.push(item);
361
+ continue;
362
+ }
363
+ if (value instanceof Map) {
364
+ for (const [key, nested] of value) {
365
+ if (key === sessionId) return true;
366
+ stack.push(nested);
367
+ }
368
+ continue;
369
+ }
370
+ if (value instanceof Set) {
371
+ for (const item of value) if (item === sessionId) return true;
372
+ continue;
373
+ }
374
+ for (const key of Object.keys(value)) {
375
+ if (key === sessionId) return true;
376
+ stack.push(value[key]);
377
+ }
378
+ }
379
+ return false;
380
+ }
381
+
382
+ /** Close the "..." menu from outside: the owner listens for Escape. */
383
+ function dismissMenu() {
384
+ try {
385
+ const event = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true });
386
+ document.dispatchEvent(event);
387
+ } catch {
388
+ /* no document: ignore */
389
+ }
390
+ }
391
+
392
+ /* ---------------------------------------------------------------------- *
393
+ * Multi-select: toggle beside the search box, a circle per Session row, a
394
+ * delete bar above the account footer, and one confirmation dialog.
395
+ * ---------------------------------------------------------------------- */
396
+
397
+ const MULTI_CSS = `
398
+ html.dsh-cm-multi [data-row-key^="session:"] > span:first-child { position: relative; }
399
+ html.dsh-cm-multi [data-row-key^="session:"] > span:first-child > * { visibility: hidden; }
400
+ /* The ring lives in the row's leading cell, so it lines up with the folder icons. */
401
+ html.dsh-cm-multi [data-row-key^="session:"] > span:first-child::before {
402
+ content: ""; position: absolute; left: 50%; top: 50%; width: 14px; height: 14px;
403
+ margin: -7px 0 0 -7px; box-sizing: border-box; border: 0; border-radius: 50%; padding: 0;
404
+ background: radial-gradient(circle 7px at 50% 50%,
405
+ transparent 0 5.5px,
406
+ var(--dsw-alias-label-secondary, #8a8f98) 5.6px 6.5px,
407
+ transparent 6.6px);
408
+ pointer-events: none; z-index: 2;
409
+ }
410
+ html.dsh-cm-multi [data-row-key^="session:"][data-dsh-cm-selected] > span:first-child::after {
411
+ content: ""; position: absolute; left: 50%; top: 50%; width: 7px; height: 7px;
412
+ margin: -3.5px 0 0 -3.5px; box-sizing: border-box; border-radius: 50%;
413
+ background: radial-gradient(circle 3.5px at 50% 50%, #7AAAFF 3.3px, transparent 3.4px);
414
+ pointer-events: none; z-index: 2;
415
+ }
416
+ html.dsh-cm-multi [data-row-key^="session:"][data-dsh-cm-selected] { background: var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12)); }
417
+
418
+ .dsh-cm-toggle {
419
+ position: fixed; display: flex; align-items: center; justify-content: center;
420
+ width: 28px; height: 28px; padding: 0; box-sizing: border-box; cursor: pointer; z-index: 61;
421
+ border: 0; border-radius: var(--dsw-radius-sm, 6px);
422
+ background: transparent;
423
+ color: var(--dsw-alias-label-secondary, #8a8f98);
424
+ font: inherit; box-shadow: none; outline: none;
425
+ }
426
+ .dsh-cm-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.14)); color: var(--dsw-alias-label-primary, #e6e6e6); }
427
+ .dsh-cm-toggle:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #4d6bfe); outline-offset: 1px; }
428
+
429
+ .dsh-cm-tooltip {
430
+ position: fixed; z-index: 100; transform: translateX(-50%);
431
+ background: var(--dsw-alias-tooltip-bg, #3a3a3a);
432
+ color: var(--dsw-static-neutral-bluish-00, #ffffff);
433
+ font: inherit; font-size: 13px; line-height: 20px;
434
+ padding: 3px 7px; border-radius: var(--dsw-radius-sm, 6px); white-space: pre-line;
435
+ width: max-content; max-width: min(50vw, calc(100vw - 24px)); pointer-events: none;
436
+ }
437
+
438
+ .dsh-cm-bar {
439
+ position: fixed; display: flex; align-items: center; gap: 8px; padding: 8px 10px; box-sizing: border-box;
440
+ z-index: 61; border: .5px solid var(--dsw-alias-border-l3, var(--dsw-alias-border-l1, rgba(127,127,127,.24)));
441
+ border-radius: var(--dsw-radius-md, 10px);
442
+ background: var(--dsw-alias-bg-layer-1, var(--dsw-alias-bg-overlay, #232326));
443
+ color: var(--dsw-alias-label-primary, #e6e6e6);
444
+ font: inherit; font-size: 12px; box-shadow: none;
445
+ }
446
+ .dsh-cm-ghost {
447
+ border: .5px solid var(--dsw-alias-border-l3, var(--dsw-alias-border-l1, rgba(127,127,127,.32)));
448
+ background: transparent; color: inherit; font: inherit; font-size: 12px;
449
+ padding: 5px 10px; border-radius: 8px; cursor: pointer;
450
+ }
451
+ .dsh-cm-ghost:hover { background: var(--dsw-alias-button-floating-hover, rgba(127,127,127,.12)); }
452
+ .dsh-cm-danger {
453
+ border: 0; border-radius: 8px; padding: 5px 12px; font: inherit; font-size: 12px; font-weight: 500;
454
+ color: #fff; background: var(--dsw-alias-state-error-primary, #e5484d); cursor: pointer;
455
+ }
456
+ .dsh-cm-danger:disabled { opacity: .5; cursor: default; }
457
+
458
+ .dsh-cm-modal-frame {
459
+ position: fixed; inset: 0; z-index: 80; display: flex; align-items: center; justify-content: center;
460
+ background: rgba(0, 0, 0, .38);
461
+ }
462
+ .dsh-cm-modal {
463
+ width: 360px; max-width: calc(100vw - 48px); box-sizing: border-box; padding: 20px;
464
+ border-radius: 16px;
465
+ border: .5px solid var(--dsw-alias-border-l3, var(--dsw-alias-border-l1, rgba(127,127,127,.2)));
466
+ background: var(--dsw-alias-bg-overlay, #232326);
467
+ color: var(--dsw-alias-label-primary, #e6e6e6);
468
+ box-shadow: 0 18px 48px rgba(0, 0, 0, .28);
469
+ font: inherit;
470
+ }
471
+ .dsh-cm-modal-title { margin: 0 0 8px; font-size: 16px; font-weight: 600; line-height: 22px; }
472
+ .dsh-cm-modal-body { font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary, #a3a3a3); }
473
+ .dsh-cm-modal-error { margin-top: 8px; font-size: 12px; color: var(--dsw-alias-state-error-primary, #e5484d); }
474
+ .dsh-cm-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
475
+ .dsh-cm-modal-actions .dsh-cm-ghost, .dsh-cm-modal-actions .dsh-cm-danger { padding: 7px 14px; font-size: 13px; border-radius: 10px; }
476
+ .dsh-cm-multi-root > * { pointer-events: auto; }
477
+ `;
478
+
479
+ function createMultiSelect() {
480
+ let state = { active: false, selected: new Set() };
481
+ const listeners = new Set();
482
+ const emit = () => {
483
+ for (const listener of [...listeners]) {
484
+ try {
485
+ listener();
486
+ } catch {
487
+ /* a broken subscriber must not break the mode */
488
+ }
489
+ }
490
+ };
491
+ const set = (next) => {
492
+ state = next;
493
+ emit();
494
+ };
495
+ return {
496
+ get: () => state,
497
+ subscribe(listener) {
498
+ listeners.add(listener);
499
+ return () => listeners.delete(listener);
500
+ },
501
+ toggleActive() {
502
+ set(state.active ? { active: false, selected: new Set() } : { active: true, selected: new Set() });
503
+ },
504
+ exit() {
505
+ if (state.active) set({ active: false, selected: new Set() });
506
+ },
507
+ toggle(sessionId) {
508
+ if (!state.active) return;
509
+ const selected = new Set(state.selected);
510
+ if (selected.has(sessionId)) selected.delete(sessionId);
511
+ else selected.add(sessionId);
512
+ set({ active: state.active, selected });
513
+ },
514
+ clearAndExit() {
515
+ set({ active: false, selected: new Set() });
516
+ },
517
+ };
518
+ }
519
+
520
+ function useMultiState(multi) {
521
+ const [state, setState] = React.useState(multi.get);
522
+ React.useEffect(() => {
523
+ setState(multi.get());
524
+ return multi.subscribe(() => setState(multi.get()));
525
+ }, [multi]);
526
+ return state;
527
+ }
528
+
529
+ const ROW_KEY_PREFIX = 'session:';
530
+ const rowSessionId = (element) => {
531
+ const value = element.getAttribute('data-row-key') || '';
532
+ return value.startsWith(ROW_KEY_PREFIX) ? value.slice(ROW_KEY_PREFIX.length) : '';
533
+ };
534
+
535
+ /** The sidebar row element currently showing one Session, if any. */
536
+ function findRowElement(sessionId) {
537
+ if (typeof document === 'undefined') return null;
538
+ for (const row of document.querySelectorAll('[data-row-key^="session:"]')) {
539
+ if (rowSessionId(row) === sessionId) return row;
540
+ }
541
+ return null;
542
+ }
543
+
544
+ /** The row's rendered title text (children: slot, title, status, actions). */
545
+ function rowTitleText(row) {
546
+ try {
547
+ const title = row.children && row.children[1];
548
+ return title && typeof title.textContent === 'string' ? title.textContent.replace(/\s+/g, ' ').trim() : '';
549
+ } catch {
550
+ return '';
551
+ }
552
+ }
553
+
554
+ /** Wire the mode into the shipped row DOM: circles, click interception, Escape. */
555
+ function installMultiSelectDom(multi) {
556
+ if (typeof document === 'undefined') return () => {};
557
+ let frame = 0;
558
+ const sync = () => {
559
+ frame = 0;
560
+ const state = multi.get();
561
+ document.documentElement.classList.toggle('dsh-cm-multi', state.active);
562
+ for (const row of document.querySelectorAll('[data-row-key^="session:"]')) {
563
+ const id = rowSessionId(row);
564
+ if (state.active && state.selected.has(id)) row.setAttribute('data-dsh-cm-selected', '');
565
+ else row.removeAttribute('data-dsh-cm-selected');
566
+ }
567
+ };
568
+ const schedule = () => {
569
+ if (frame) return;
570
+ frame = window.requestAnimationFrame(sync);
571
+ };
572
+ const unsubscribe = multi.subscribe(schedule);
573
+ const observer = new MutationObserver(schedule);
574
+ observer.observe(document.body, { childList: true, subtree: true });
575
+
576
+ const onClick = (event) => {
577
+ const state = multi.get();
578
+ if (!state.active) return;
579
+ const target = event.target;
580
+ if (!(target instanceof Element)) return;
581
+ if (target.closest('.dsh-cm-multi-root')) return;
582
+ const row = target.closest('[data-row-key^="session:"]');
583
+ if (!row) return;
584
+ event.preventDefault();
585
+ event.stopPropagation();
586
+ multi.toggle(rowSessionId(row));
587
+ };
588
+ const onKeyDown = (event) => {
589
+ const state = multi.get();
590
+ if (!state.active) return;
591
+ if (event.key === 'Escape') {
592
+ event.preventDefault();
593
+ event.stopPropagation();
594
+ multi.exit();
595
+ return;
596
+ }
597
+ if (event.key !== 'Enter' && event.key !== ' ') return;
598
+ const target = event.target;
599
+ if (!(target instanceof Element)) return;
600
+ const row = target.closest('[data-row-key^="session:"]');
601
+ if (!row) return;
602
+ event.preventDefault();
603
+ event.stopPropagation();
604
+ multi.toggle(rowSessionId(row));
605
+ };
606
+ document.addEventListener('click', onClick, true);
607
+ document.addEventListener('keydown', onKeyDown, true);
608
+ schedule();
609
+ return () => {
610
+ unsubscribe();
611
+ observer.disconnect();
612
+ document.removeEventListener('click', onClick, true);
613
+ document.removeEventListener('keydown', onKeyDown, true);
614
+ if (frame) window.cancelAnimationFrame(frame);
615
+ document.documentElement.classList.remove('dsh-cm-multi');
616
+ for (const row of document.querySelectorAll('[data-row-key^="session:"]')) row.removeAttribute('data-dsh-cm-selected');
617
+ };
618
+ }
619
+
620
+ /** Locate the sidebar search input and the column around it. */
621
+ function measureAnchors() {
622
+ if (typeof document === 'undefined') return { toggle: null, bar: null };
623
+ const viewportHeight = window.innerHeight || 800;
624
+ const viewportWidth = window.innerWidth || 1200;
625
+ let found = null;
626
+ for (const input of document.querySelectorAll('input')) {
627
+ const type = (input.getAttribute('type') || '').toLowerCase();
628
+ if (type !== 'text' && type !== 'search') continue;
629
+ const box = input.parentElement || input;
630
+ const boxRect = box.getBoundingClientRect();
631
+ if (!(boxRect.width >= 16 && boxRect.height >= 14)) continue;
632
+ if (boxRect.left < 0 || boxRect.left > Math.min(460, viewportWidth * 0.45)) continue;
633
+ if (boxRect.top > viewportHeight * 0.45) continue;
634
+ found = { input, box, boxRect };
635
+ break;
636
+ }
637
+ if (!found) return { toggle: null, bar: null };
638
+ const { boxRect } = found;
639
+ const expanded = boxRect.width > 130;
640
+ let sidebarRect = null;
641
+ let node = found.input;
642
+ while (node && node !== document.body) {
643
+ const rect = node.getBoundingClientRect();
644
+ if (rect.left <= 12 && rect.width >= 180 && rect.width <= 560 && rect.height >= viewportHeight * 0.6) {
645
+ sidebarRect = rect;
646
+ break;
647
+ }
648
+ node = node.parentElement;
649
+ }
650
+ const toggleWidth = 28;
651
+ const toggle = expanded || boxRect.left < 90
652
+ ? null
653
+ : {
654
+ left: boxRect.left - toggleWidth - 4,
655
+ top: boxRect.top + (boxRect.height - toggleWidth) / 2,
656
+ width: toggleWidth,
657
+ center: boxRect.left - toggleWidth - 4 + toggleWidth / 2,
658
+ };
659
+ const bar = sidebarRect
660
+ ? { left: sidebarRect.left + 12, width: Math.max(150, sidebarRect.width - 24), bottom: 66 }
661
+ : null;
662
+ return { toggle, bar };
663
+ }
664
+
665
+ const sameAnchors = (a, b) => JSON.stringify(a) === JSON.stringify(b);
666
+
667
+ function multiIcon(kind) {
668
+ // Square-check, drawn on the native 24px grid with stroke-width 1.5 so the
669
+ // 16px rendering lands on the same ~1px optical weight as the host icons.
670
+ return h(
671
+ 'svg',
672
+ { viewBox: '0 0 24 24', width: 16, height: 16, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
673
+ h('path', { d: 'M21 10.6V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.5' }),
674
+ h('path', { d: 'm9 11 3 3L22 4' }),
675
+ );
676
+ }
677
+
678
+ function MultiSelectOverlay(props) {
679
+ const multi = props.multi;
680
+ const translate = typeof props.translate === 'function' ? props.translate : (key) => FALLBACK[key] || key;
681
+ const requestDelete = props.requestDelete;
682
+ const state = useMultiState(multi);
683
+ const [anchors, setAnchors] = React.useState(measureAnchors);
684
+ const [confirming, setConfirming] = React.useState(false);
685
+ const [busy, setBusy] = React.useState(false);
686
+ const [errorText, setErrorText] = React.useState('');
687
+ const [tooltip, setTooltip] = React.useState(false);
688
+ const hoverTimer = React.useRef(0);
689
+ React.useEffect(() => () => window.clearTimeout(hoverTimer.current), []);
690
+ React.useEffect(() => {
691
+ window.clearTimeout(hoverTimer.current);
692
+ setTooltip(false);
693
+ }, [state.active]);
694
+
695
+ React.useEffect(() => {
696
+ let alive = true;
697
+ let frame = 0;
698
+ const update = () => {
699
+ if (!alive) return;
700
+ const next = measureAnchors();
701
+ setAnchors((previous) => (sameAnchors(previous, next) ? previous : next));
702
+ };
703
+ const schedule = () => {
704
+ if (frame || !alive) return;
705
+ frame = window.requestAnimationFrame(() => {
706
+ frame = 0;
707
+ update();
708
+ });
709
+ };
710
+ update();
711
+ // Search expands/collapses on click, the sidebar collapses on click, and a
712
+ // window resize moves both: refresh on the next frame instead of waiting for
713
+ // a poll, so the toggle never floats over a changed layout.
714
+ const timer = window.setInterval(update, 250);
715
+ const onPointerDown = () => {
716
+ schedule();
717
+ window.setTimeout(update, 90);
718
+ window.setTimeout(update, 260);
719
+ };
720
+ const onFocusIn = onPointerDown;
721
+ window.addEventListener('resize', schedule);
722
+ document.addEventListener('pointerdown', onPointerDown, true);
723
+ document.addEventListener('focusin', onFocusIn, true);
724
+ window.addEventListener('scroll', schedule, true);
725
+ return () => {
726
+ alive = false;
727
+ window.clearInterval(timer);
728
+ window.removeEventListener('resize', schedule);
729
+ document.removeEventListener('pointerdown', onPointerDown, true);
730
+ document.removeEventListener('focusin', onFocusIn, true);
731
+ window.removeEventListener('scroll', schedule, true);
732
+ if (frame) window.cancelAnimationFrame(frame);
733
+ };
734
+ }, []);
735
+
736
+ const count = state.selected.size;
737
+
738
+ const runDelete = React.useCallback(async () => {
739
+ if (busy) return;
740
+ const ids = [...multi.get().selected];
741
+ if (ids.length === 0) return;
742
+ setBusy(true);
743
+ setErrorText('');
744
+ // Play the same strike-out choreography on every selected row before the
745
+ // Host is asked to delete them, so multi-delete looks like one delete.
746
+ const effects = new Map();
747
+ for (const id of ids) {
748
+ const row = findRowElement(id);
749
+ if (!row) continue;
750
+ try {
751
+ effects.set(id, startRemovalAnimation(row, rowTitleText(row)));
752
+ } catch {
753
+ /* the animation is optional */
754
+ }
755
+ }
756
+ if (effects.size > 0) await new Promise((resolve) => window.setTimeout(resolve, REMOVAL_MS));
757
+ const failures = [];
758
+ for (const id of ids) {
759
+ try {
760
+ await requestDelete(id);
761
+ } catch (error) {
762
+ failures.push(error && error.message ? error.message : String(error));
763
+ const effect = effects.get(id);
764
+ if (effect) {
765
+ try {
766
+ effect.restore();
767
+ } catch {
768
+ /* row already gone */
769
+ }
770
+ }
771
+ }
772
+ }
773
+ setBusy(false);
774
+ setConfirming(false);
775
+ multi.clearAndExit();
776
+ if (failures.length > 0) {
777
+ try {
778
+ window.alert(translate('multi.failed').replace('{count}', String(failures.length)) + '\n' + failures[0]);
779
+ } catch {
780
+ /* alert unavailable */
781
+ }
782
+ }
783
+ }, [busy, multi, requestDelete, translate]);
784
+
785
+ const children = [];
786
+ if (!state.active && anchors.toggle) {
787
+ children.push(
788
+ h(
789
+ 'button',
790
+ {
791
+ key: 'toggle',
792
+ type: 'button',
793
+ className: 'dsh-cm-toggle',
794
+ 'aria-label': translate('multi.label'),
795
+ onClick: () => {
796
+ window.clearTimeout(hoverTimer.current);
797
+ setTooltip(false);
798
+ multi.toggleActive();
799
+ },
800
+ onMouseEnter: () => {
801
+ window.clearTimeout(hoverTimer.current);
802
+ hoverTimer.current = window.setTimeout(() => setTooltip(true), 400);
803
+ },
804
+ onMouseLeave: () => {
805
+ window.clearTimeout(hoverTimer.current);
806
+ setTooltip(false);
807
+ },
808
+ onFocus: () => setTooltip(true),
809
+ onBlur: () => setTooltip(false),
810
+ style: { left: anchors.toggle.left, top: anchors.toggle.top },
811
+ },
812
+ multiIcon('checklist'),
813
+ ),
814
+ );
815
+ if (tooltip) {
816
+ children.push(
817
+ h(
818
+ 'div',
819
+ {
820
+ key: 'tooltip',
821
+ className: 'dsh-cm-tooltip',
822
+ role: 'tooltip',
823
+ style: { left: anchors.toggle.center, top: anchors.toggle.top + 28 + 6 },
824
+ },
825
+ translate('multi.label'),
826
+ ),
827
+ );
828
+ }
829
+ }
830
+
831
+ if (state.active && anchors.bar) {
832
+ children.push(
833
+ h(
834
+ 'div',
835
+ {
836
+ key: 'bar',
837
+ className: 'dsh-cm-bar',
838
+ style: { left: anchors.bar.left, width: anchors.bar.width, bottom: anchors.bar.bottom },
839
+ },
840
+ h('span', { style: { flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
841
+ count > 0 ? translate('multi.selected').replace('{count}', String(count)) : translate('multi.none')),
842
+ h(
843
+ 'button',
844
+ {
845
+ type: 'button',
846
+ className: 'dsh-cm-ghost',
847
+ disabled: busy,
848
+ onClick: () => multi.exit(),
849
+ },
850
+ translate('multi.cancel'),
851
+ ),
852
+ h(
853
+ 'button',
854
+ {
855
+ type: 'button',
856
+ className: 'dsh-cm-danger',
857
+ disabled: busy || count === 0,
858
+ onClick: () => setConfirming(true),
859
+ },
860
+ busy ? translate('multi.busy') : translate('multi.delete'),
861
+ ),
862
+ ),
863
+ );
864
+ }
865
+
866
+ if (confirming) {
867
+ children.push(
868
+ h(
869
+ 'div',
870
+ {
871
+ key: 'confirm',
872
+ className: 'dsh-cm-modal-frame',
873
+ onClick: (event) => {
874
+ if (event.target === event.currentTarget) setConfirming(false);
875
+ },
876
+ },
877
+ h(
878
+ 'div',
879
+ { className: 'dsh-cm-modal', role: 'dialog', 'aria-modal': 'true' },
880
+ h('div', { className: 'dsh-cm-modal-title' }, translate('multi.confirm.title')),
881
+ h('div', { className: 'dsh-cm-modal-body' },
882
+ translate('multi.confirm.body').replace('{count}', String(count))),
883
+ errorText
884
+ ? h('div', { className: 'dsh-cm-modal-error' }, errorText)
885
+ : null,
886
+ h(
887
+ 'div',
888
+ { className: 'dsh-cm-modal-actions' },
889
+ h(
890
+ 'button',
891
+ {
892
+ type: 'button',
893
+ className: 'dsh-cm-ghost',
894
+ disabled: busy,
895
+ onClick: () => setConfirming(false),
896
+ },
897
+ translate('multi.confirm.cancel'),
898
+ ),
899
+ h(
900
+ 'button',
901
+ {
902
+ type: 'button',
903
+ className: 'dsh-cm-danger',
904
+ disabled: busy,
905
+ onClick: () => runDelete(),
906
+ },
907
+ busy ? translate('multi.busy') : translate('multi.confirm.confirm'),
908
+ ),
909
+ ),
910
+ ),
911
+ ),
912
+ );
913
+ }
914
+
915
+ return h(
916
+ 'div',
917
+ { className: 'dsh-cm-multi-root', style: { position: 'fixed', inset: 0, zIndex: 60, pointerEvents: 'none' } },
918
+ children,
919
+ );
920
+ }
921
+
922
+ const styles = {
923
+ button: {
924
+ display: 'flex',
925
+ alignItems: 'center',
926
+ gap: 6,
927
+ boxSizing: 'border-box',
928
+ width: '100%',
929
+ minHeight: 34,
930
+ margin: 0,
931
+ padding: '6px 8px',
932
+ border: 0,
933
+ borderRadius: 10,
934
+ background: 'transparent',
935
+ color: 'var(--dsw-alias-state-error-primary, #e5484d)',
936
+ font: 'inherit',
937
+ fontSize: 13,
938
+ lineHeight: '20px',
939
+ textAlign: 'left',
940
+ cursor: 'pointer',
941
+ outline: 'none',
942
+ whiteSpace: 'nowrap',
943
+ },
944
+ icon: { width: 14, height: 14, flex: 'none', display: 'block' },
945
+ iconBox: { width: 14, height: 14, flex: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'currentColor' },
946
+ label: { flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
947
+ };
948
+
949
+ function trashIcon() {
950
+ return h(
951
+ 'svg',
952
+ {
953
+ viewBox: '0 0 16 16',
954
+ width: 14,
955
+ height: 14,
956
+ fill: 'none',
957
+ stroke: 'currentColor',
958
+ strokeWidth: 1.4,
959
+ strokeLinecap: 'round',
960
+ strokeLinejoin: 'round',
961
+ 'aria-hidden': 'true',
962
+ style: styles.icon,
963
+ },
964
+ h('path', { d: 'M2.5 4.5h11' }),
965
+ h('path', { d: 'M6 4.5V3.2c0-.4.3-.7.7-.7h2.6c.4 0 .7.3.7.7v1.3' }),
966
+ h('path', { d: 'M4.2 4.5l.6 8.1c.05.7.6 1.2 1.3 1.2h3.8c.7 0 1.25-.5 1.3-1.2l.6-8.1' }),
967
+ h('path', { d: 'M6.6 7.2v4' }),
968
+ h('path', { d: 'M9.4 7.2v4' }),
969
+ );
970
+ }
971
+
972
+ function DeleteRow(props) {
973
+ const translate = typeof props.translate === 'function' ? props.translate : (key) => FALLBACK[key] || key;
974
+ const [phase, setPhase] = React.useState('idle');
975
+ const [hover, setHover] = React.useState(false);
976
+ const [errorText, setErrorText] = React.useState('');
977
+ const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '';
978
+ const busy = phase === 'busy';
979
+ const done = phase === 'done';
980
+
981
+ // The sidebar rows come from the Host's live Workspace snapshot: after the
982
+ // delete the row should disappear on its own. Watch it, and only fall back
983
+ // to a full reload when that push did not happen.
984
+ const observedListed = useSessionListed(props, sessionId);
985
+ const sawListed = React.useRef(false);
986
+ if (observedListed) sawListed.current = true;
987
+ // Only an observed disappearance proves the UI already refreshed itself.
988
+ const updatedLive = done && sawListed.current && !observedListed;
989
+
990
+ React.useEffect(() => {
991
+ if (!done || updatedLive) return undefined;
992
+ const timer = window.setTimeout(() => window.location.reload(), LIVE_UPDATE_GRACE_MS);
993
+ return () => window.clearTimeout(timer);
994
+ }, [done, updatedLive]);
995
+
996
+ const activate = React.useCallback(
997
+ async (event) => {
998
+ if (event) {
999
+ event.preventDefault();
1000
+ event.stopPropagation();
1001
+ }
1002
+ if (busy || done) return;
1003
+ if (!sessionId) {
1004
+ setPhase('error');
1005
+ setErrorText(translate('error.empty'));
1006
+ return;
1007
+ }
1008
+ setPhase('busy');
1009
+ setErrorText('');
1010
+ // v1: play the removal choreography on the sidebar row first, then ask the
1011
+ // Host to delete. Animating in place only works while the row is still
1012
+ // mounted, so the POST waits for the effect to settle.
1013
+ const title = typeof props.displayTitle === 'string' ? props.displayTitle : '';
1014
+ const row = findSessionRow(title);
1015
+ const effect = row ? startRemovalAnimation(row, title) : null;
1016
+ // Clear the menu once the strike has landed, so the exit animation is not
1017
+ // hidden behind the popup.
1018
+ if (effect) window.setTimeout(dismissMenu, STRIKE_MS);
1019
+ else dismissMenu();
1020
+ try {
1021
+ if (effect) await effect.done;
1022
+ await requestDelete(sessionId);
1023
+ setPhase('done');
1024
+ dismissMenu();
1025
+ } catch (error) {
1026
+ if (effect) {
1027
+ try {
1028
+ effect.restore();
1029
+ } catch {
1030
+ /* row already gone */
1031
+ }
1032
+ }
1033
+ setPhase('error');
1034
+ setErrorText(error && error.message ? error.message : translate('error.empty'));
1035
+ try {
1036
+ console.warn('[conversation-manager] delete failed', error);
1037
+ } catch {
1038
+ /* console unavailable */
1039
+ }
1040
+ }
1041
+ },
1042
+ [busy, done, sessionId, props.displayTitle, translate],
1043
+ );
1044
+
1045
+ const label = busy
1046
+ ? translate('menu.busy')
1047
+ : done
1048
+ ? translate('menu.done')
1049
+ : phase === 'error'
1050
+ ? translate('menu.retry')
1051
+ : translate('menu.delete');
1052
+
1053
+ return h(
1054
+ 'button',
1055
+ {
1056
+ type: 'button',
1057
+ role: 'menuitem',
1058
+ 'aria-label': label,
1059
+ title: errorText || label,
1060
+ 'data-dsh-conversation-manager': 'delete',
1061
+ disabled: busy || done,
1062
+ style: Object.assign({}, styles.button, {
1063
+ background: hover && !busy && !done ? 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,.14))' : 'transparent',
1064
+ cursor: busy ? 'progress' : done ? 'default' : 'pointer',
1065
+ opacity: busy ? 0.65 : 1,
1066
+ }),
1067
+ onMouseEnter: () => setHover(true),
1068
+ onMouseLeave: () => setHover(false),
1069
+ onClick: activate,
1070
+ },
1071
+ h('span', { style: styles.iconBox }, trashIcon()),
1072
+ h('span', { style: styles.label }, label),
1073
+ );
1074
+ }
1075
+
1076
+ return {
1077
+ inject: ['slots', 'locale'],
1078
+ apply(ctx) {
1079
+ const track = (register) => {
1080
+ if (typeof ctx.effect === 'function') {
1081
+ try {
1082
+ ctx.effect(register);
1083
+ return;
1084
+ } catch {
1085
+ /* fall through to a bare call */
1086
+ }
1087
+ }
1088
+ register();
1089
+ };
1090
+
1091
+ const locale = (() => {
1092
+ try {
1093
+ return ctx.locale;
1094
+ } catch {
1095
+ return undefined;
1096
+ }
1097
+ })();
1098
+
1099
+ let bound;
1100
+ if (locale && typeof locale.register === 'function') {
1101
+ for (const localeId of Object.keys(dictionaries)) {
1102
+ track(() => locale.register(NAMESPACE, localeId, dictionaries[localeId]));
1103
+ }
1104
+ }
1105
+ try {
1106
+ if (locale && typeof locale.bind === 'function') bound = locale.bind(NAMESPACE);
1107
+ } catch {
1108
+ bound = undefined;
1109
+ }
1110
+
1111
+ // Stable per plugin load so the row does not re-render on every locale tick.
1112
+ const translate = (key) => {
1113
+ if (typeof bound === 'function') {
1114
+ try {
1115
+ const value = bound(key);
1116
+ if (typeof value === 'string' && value.length > 0 && value !== key) return value;
1117
+ } catch {
1118
+ /* fall back to the built-in dictionary */
1119
+ }
1120
+ }
1121
+ return FALLBACK[key] || key;
1122
+ };
1123
+
1124
+ const Row = (props) => h(DeleteRow, Object.assign({}, props, { translate }));
1125
+
1126
+ track(() =>
1127
+ ctx.slots.inject(SLOT, () =>
1128
+ ctx.slots.register({ name: SLOT, id: 'dsh-conversation-manager.delete', order: 500 }, Row),
1129
+ ),
1130
+ );
1131
+
1132
+ // Multi-select: row circles + click interception live on the document,
1133
+ // the toggle / delete bar / confirmation dialog render in the overlay.
1134
+ const multi = createMultiSelect();
1135
+ track(() => installMultiSelectDom(multi));
1136
+ track(() => {
1137
+ if (typeof document === 'undefined') return () => {};
1138
+ const style = document.createElement('style');
1139
+ style.setAttribute('data-dsh-conversation-manager', 'multi-select');
1140
+ style.textContent = MULTI_CSS;
1141
+ document.head.appendChild(style);
1142
+ return () => style.remove();
1143
+ });
1144
+
1145
+ const Overlay = (props) =>
1146
+ h(MultiSelectOverlay, Object.assign({}, props, { multi, translate, requestDelete }));
1147
+ track(() =>
1148
+ ctx.slots.inject('shell.overlay', () =>
1149
+ ctx.slots.register(
1150
+ { name: 'shell.overlay', id: 'dsh-conversation-manager.multi-select', order: 900 },
1151
+ Overlay,
1152
+ ),
1153
+ ),
1154
+ );
1155
+ },
1156
+ };
1157
+ },
1158
+ });
1159
+
1160
+
1161
+