dsh-plugin-message-edit 1.0.0 → 1.1.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/lib/client.js CHANGED
@@ -18,1648 +18,1672 @@ window.__ModuleLoader__.load({
18
18
  }
19
19
  };
20
20
  return (function () {
21
- // dsh-plugin-message-tree — client half.
22
- //
23
- // Mimics ChatGPT's edit-message behavior: hover a past prompt to edit it,
24
- // sending branches the conversation from that point (the host half performs
25
- // the true rewind); ‹ 2/3 › switches between versions of the same message;
26
- // a Versions view draws the whole tree.
27
-
28
- // Route, CSS prefix and storage keys keep the `message-tree` spelling even
29
- // though the package is dsh-plugin-message-edit: Moeblack's dsh-message-edit
30
- // owns the `message-edit` names, and colliding would break both plugins when
31
- // installed together. See lib/index.js for the full note.
32
- const ROUTE = '/message-tree';
33
- const VIEW_ORDER = 16;
34
-
35
- function realGlobal() {
36
- try { if (typeof window !== 'undefined' && window) return window; } catch (e) {}
37
- try { if (typeof globalThis !== 'undefined' && globalThis) return globalThis; } catch (e) {}
38
- return null;
39
- }
40
-
41
- /* ------------------------------------------------------------- edit style -- */
42
-
43
- // Which provider's message-edit LAYOUT to follow. All three put the controls
44
- // below the bubble; what differs is which controls exist (only Claude offers
45
- // retry), whether they wait for hover (ChatGPT and Claude) or stay visible
46
- // (DeepSeek, like DSH itself), and whether the editor's Cancel/confirm sit
47
- // inside the box or below it. Colours stay native in every preset. The choice
48
- // is one attribute on <html>, so the stylesheet keys off it and switching
49
- // takes effect live.
50
- const STYLE_KEY = 'dsh-plugin-message-tree:style';
51
- const STYLES = ['chatgpt', 'deepseek', 'claude'];
52
- const DEFAULT_STYLE = 'chatgpt';
53
-
54
- const styleStore = {
55
- value: null,
56
- listeners: [],
57
- get() {
58
- if (this.value === null) {
59
- const g = realGlobal();
60
- let stored = null;
61
- try { stored = g && g.localStorage && g.localStorage.getItem(STYLE_KEY); } catch (e) {}
62
- this.value = STYLES.indexOf(stored) !== -1 ? stored : DEFAULT_STYLE;
63
- }
64
- return this.value;
65
- },
66
- set(next) {
67
- this.value = STYLES.indexOf(next) !== -1 ? next : DEFAULT_STYLE;
68
- const g = realGlobal();
69
- try { if (g && g.localStorage) g.localStorage.setItem(STYLE_KEY, this.value); } catch (e) {}
70
- syncStyleAttribute();
71
- for (let i = 0; i < this.listeners.length; i++) {
72
- try { this.listeners[i](); } catch (e) {}
73
- }
74
- },
75
- subscribe(fn) {
76
- const listeners = this.listeners;
77
- listeners.push(fn);
78
- return function () {
79
- const at = listeners.indexOf(fn);
80
- if (at !== -1) listeners.splice(at, 1);
81
- };
82
- },
83
- };
84
-
85
- function syncStyleAttribute() {
86
- const g = realGlobal();
87
- const root = g && g.document && g.document.documentElement;
88
- if (root) root.setAttribute('data-mtx-style', styleStore.get());
89
- }
90
-
91
- /* ----------------------------------------------------------- active path -- */
92
-
93
- // A version IS a whole session, so "which version am I looking at" is just
94
- // "which session is open". Reopening a conversation lands on whichever session
95
- // the sidebar points at — normally the family root — so a branch you had
96
- // selected is silently dropped and the ring snaps back to 1/N.
97
- //
98
- // Remember the last session viewed for each family, keyed by the family's root,
99
- // and restore it when you land back on that root. Recording happens for every
100
- // family member you view, so walking the ring back to the root records the root
101
- // and the restore then correctly does nothing (no ping-pong).
102
- const PATH_KEY = 'dsh-plugin-message-tree:active-path';
103
- const PATH_LIMIT = 200;
104
-
105
- const activePathStore = {
106
- map: null,
107
- read() {
108
- if (this.map === null) {
109
- let parsed = null;
110
- try {
111
- const g = realGlobal();
112
- const raw = g && g.localStorage && g.localStorage.getItem(PATH_KEY);
113
- parsed = raw ? JSON.parse(raw) : null;
114
- } catch (e) {}
115
- this.map = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
116
- }
117
- return this.map;
118
- },
119
- get(rootId) {
120
- if (!rootId) return undefined;
121
- const v = this.read()[rootId];
122
- return typeof v === 'string' ? v : undefined;
123
- },
124
- set(rootId, sessionId) {
125
- if (!rootId || !sessionId) return;
126
- const map = this.read();
127
- if (map[rootId] === sessionId) return;
128
- map[rootId] = sessionId;
129
- // Bound the map so a long-lived profile cannot grow it without limit.
130
- // Object key order is insertion order for string keys, so the oldest
131
- // entries are at the front.
132
- const keys = Object.keys(map);
133
- if (keys.length > PATH_LIMIT) {
134
- for (let i = 0; i < keys.length - PATH_LIMIT; i++) delete map[keys[i]];
135
- }
136
- try {
137
- const g = realGlobal();
138
- if (g && g.localStorage) g.localStorage.setItem(PATH_KEY, JSON.stringify(map));
139
- } catch (e) {}
140
- },
141
- };
142
-
143
- /** The family root for `sessionId`: walk parents until one has none. */
144
- function rootOf(versions, sessionId) {
145
- if (!versions || sessionId === undefined) return undefined;
146
- const byId = new Map(versions.map(function (v) { return [v.sessionId, v]; }));
147
- let cursor = byId.get(sessionId);
148
- if (!cursor) return undefined;
149
- const seen = new Set();
150
- while (cursor.parentSessionId && !seen.has(cursor.sessionId)) {
151
- seen.add(cursor.sessionId);
152
- const parent = byId.get(cursor.parentSessionId);
153
- if (!parent) break;
154
- cursor = parent;
155
- }
156
- return cursor.sessionId;
157
- }
158
-
159
- // Families already restored in this page load. Without this the restore would
160
- // re-fire on every re-render and fight a deliberate walk back to the root.
161
- const restoredFamilies = new Set();
162
- // Restores that have been triggered but whose navigation has not landed yet.
163
- // While a root is in here we must not record it as the selection.
164
- const pendingRestore = new Set();
165
-
166
- /* --------------------------------------------------------------- prefs -- */
167
-
168
- // Behaviour toggles, persisted next to the style choice. Both default to the
169
- // behaviour the user asked for rather than the old one.
170
- const PREFS_KEY = 'dsh-plugin-message-tree:prefs';
171
- const PREFS_DEFAULTS = {
172
- // Restore the last-viewed branch when reopening a conversation.
173
- rememberPath: true,
174
- // Cancel a still-running turn before an edit forks the conversation.
175
- stopOnEdit: true,
176
- };
177
-
178
- const prefsStore = {
179
- value: null,
180
- listeners: [],
181
- get() {
182
- if (this.value === null) {
183
- let parsed = null;
184
- try {
185
- const g = realGlobal();
186
- const raw = g && g.localStorage && g.localStorage.getItem(PREFS_KEY);
187
- parsed = raw ? JSON.parse(raw) : null;
188
- } catch (e) {}
189
- const out = {};
190
- for (const k in PREFS_DEFAULTS) {
191
- out[k] = parsed && typeof parsed[k] === 'boolean' ? parsed[k] : PREFS_DEFAULTS[k];
192
- }
193
- this.value = out;
194
- }
195
- return this.value;
196
- },
197
- set(patch) {
198
- const next = Object.assign({}, this.get(), patch);
199
- this.value = next;
200
- try {
201
- const g = realGlobal();
202
- if (g && g.localStorage) g.localStorage.setItem(PREFS_KEY, JSON.stringify(next));
203
- } catch (e) {}
204
- for (let i = 0; i < this.listeners.length; i++) {
205
- try { this.listeners[i](); } catch (e) {}
206
- }
207
- },
208
- subscribe(fn) {
209
- const listeners = this.listeners;
210
- listeners.push(fn);
211
- return function () {
212
- const at = listeners.indexOf(fn);
213
- if (at !== -1) listeners.splice(at, 1);
214
- };
215
- },
216
- };
217
-
218
- function usePrefs() {
219
- const [, force] = React.useReducer(function (x) { return x + 1; }, 0);
220
- React.useEffect(function () { return prefsStore.subscribe(force); }, []);
221
- return prefsStore.get();
222
- }
223
-
224
- function useStyle() {
225
- const [, force] = React.useReducer(function (x) { return x + 1; }, 0);
226
- React.useEffect(function () { return styleStore.subscribe(force); }, []);
227
- return styleStore.get();
228
- }
229
-
230
- /* ------------------------------------------------------- timeline store -- */
231
-
232
- const MAX_CACHED_SESSIONS = 500;
233
- const MAX_CACHED_ROOTS = 50;
234
-
235
- // High-performance family-aware tree cache with zero-flicker Stale-While-Revalidate.
236
- const treeStore = {
237
- bySession: new Map(),
238
- byRoot: new Map(),
239
- inflight: new Map(),
240
- listeners: [],
241
-
242
- get(sessionId) {
243
- if (!sessionId) return null;
244
- return this.bySession.get(sessionId) || null;
245
- },
246
-
247
- notify() {
248
- for (let i = 0; i < this.listeners.length; i++) {
249
- try { this.listeners[i](); } catch (e) {}
250
- }
251
- },
252
-
253
- subscribe(fn) {
254
- const listeners = this.listeners;
255
- listeners.push(fn);
256
- return function () {
257
- const at = listeners.indexOf(fn);
258
- if (at !== -1) listeners.splice(at, 1);
259
- };
260
- },
261
-
262
- _prune() {
263
- while (this.bySession.size > MAX_CACHED_SESSIONS) {
264
- const oldestKey = this.bySession.keys().next().value;
265
- this.bySession.delete(oldestKey);
266
- }
267
- while (this.byRoot.size > MAX_CACHED_ROOTS) {
268
- const oldestKey = this.byRoot.keys().next().value;
269
- this.byRoot.delete(oldestKey);
270
- }
271
- },
272
-
273
- setTree(sessionId, versions, timestamp) {
274
- if (!Array.isArray(versions)) versions = [];
275
- const rootId = rootOf(versions, sessionId) || sessionId;
276
- const updatedAt = typeof timestamp === 'number' ? timestamp : Date.now();
277
- const existingRoot = this.byRoot.get(rootId);
278
- if (existingRoot && (existingRoot.updatedAt || 0) > updatedAt) {
279
- return;
280
- }
281
-
282
- const entry = { versions: versions, rootId: rootId, loading: false, error: null, updatedAt: updatedAt };
283
- this.byRoot.set(rootId, entry);
284
-
285
- for (let i = 0; i < versions.length; i++) {
286
- const v = versions[i];
287
- if (v && v.sessionId && !v.deleted) {
288
- this.bySession.set(v.sessionId, entry);
289
- }
290
- }
291
- this.bySession.set(sessionId, entry);
292
- this._prune();
293
- this.notify();
294
- },
295
-
296
- async load(sessionId) {
297
- if (!sessionId) return;
298
- const g = realGlobal();
299
- if (!g || typeof g.fetch !== 'function') return;
300
-
301
- if (this.inflight.has(sessionId)) return this.inflight.get(sessionId);
302
-
303
- const existing = this.bySession.get(sessionId);
304
- const reqTime = Date.now();
305
- if (existing) {
306
- this.bySession.set(sessionId, Object.assign({}, existing, { loading: true }));
307
- } else {
308
- this.bySession.set(sessionId, { versions: null, loading: true, error: null, updatedAt: 0 });
309
- }
310
-
311
- const self = this;
312
- const promise = (async function () {
313
- try {
314
- const res = await g.fetch(ROUTE + '?sessionId=' + encodeURIComponent(sessionId), { cache: 'no-store' });
315
- if (!res.ok) throw new Error('HTTP ' + res.status);
316
- const data = await res.json();
317
- self.setTree(sessionId, data.versions, reqTime);
318
- } catch (e) {
319
- const errStr = String((e && e.message) || e);
320
- const prev = self.bySession.get(sessionId);
321
- self.bySession.set(sessionId, {
322
- versions: prev ? prev.versions : null,
323
- loading: false,
324
- error: errStr,
325
- updatedAt: prev ? prev.updatedAt : 0,
326
- });
327
- self.notify();
328
- } finally {
329
- self.inflight.delete(sessionId);
330
- }
331
- })();
332
-
333
- this.inflight.set(sessionId, promise);
334
- return promise;
335
- },
336
-
337
- ensure(sessionId) {
338
- if (!sessionId) return;
339
- const entry = this.bySession.get(sessionId);
340
- if (!entry || !entry.versions) {
341
- this.load(sessionId);
342
- } else if (Date.now() - (entry.updatedAt || 0) > 8000 && !entry.loading) {
343
- this.load(sessionId);
344
- }
345
- },
346
-
347
- invalidate(sessionId) {
348
- if (sessionId) {
349
- const entry = this.bySession.get(sessionId);
350
- if (entry && entry.rootId) {
351
- const rootEntry = this.byRoot.get(entry.rootId);
352
- if (rootEntry) rootEntry.updatedAt = 0;
353
- }
354
- this.load(sessionId);
355
- } else {
356
- this.bySession.forEach(function (e) { if (e) e.updatedAt = 0; });
357
- this.byRoot.forEach(function (e) { if (e) e.updatedAt = 0; });
358
- this.notify();
359
- }
360
- },
361
- };
362
-
363
- function useTree(sessionId) {
364
- const [, force] = React.useReducer(function (x) { return x + 1; }, 0);
365
- React.useEffect(function () { return treeStore.subscribe(force); }, []);
366
- React.useEffect(function () { if (sessionId) treeStore.ensure(sessionId); }, [sessionId]);
367
- return sessionId ? treeStore.get(sessionId) : null;
368
- }
369
-
370
- /**
371
- * The ‹ › ring for the message at `turn` while viewing `sessionId`.
372
- *
373
- * Versions are whole sessions: an edit creates a child rewound to before the
374
- * turn. Walking up from the current session, sessions whose edit targets a
375
- * LATER turn still inherit this one, so they are skipped; landing on a
376
- * session that targets exactly this turn means we are viewing one of its
377
- * alternatives, whose original lives in that session's parent.
378
- */
379
- function ringFor(versions, sessionId, turn) {
380
- if (!versions) return null;
381
- const byId = new Map(versions.map(function (v) { return [v.sessionId, v]; }));
382
- let cursor = byId.get(sessionId);
383
- if (!cursor) return null;
384
- while (cursor.parentSessionId && typeof cursor.targetTurn === 'number' && cursor.targetTurn > turn) {
385
- const parent = byId.get(cursor.parentSessionId);
386
- if (!parent) break;
387
- cursor = parent;
388
- }
389
- let fork = cursor;
390
- while (fork.parentSessionId && typeof fork.targetTurn === 'number' && fork.targetTurn === turn) {
391
- const parent = byId.get(fork.parentSessionId);
392
- if (!parent) break;
393
- fork = parent;
394
- }
395
- function walksToFork(start) {
396
- let x = start;
397
- const seen = new Set();
398
- while (x && !seen.has(x.sessionId)) {
399
- seen.add(x.sessionId);
400
- if (x.sessionId === fork.sessionId) return true;
401
- if (typeof x.targetTurn !== 'number' || x.targetTurn !== turn) return false;
402
- x = x.parentSessionId ? byId.get(x.parentSessionId) : null;
403
- }
404
- return false;
405
- }
406
- // A deleted (ghost) version still anchors the fork and still bridges the
407
- // parent walks above, but it cannot be opened, so it never appears among
408
- // the alternatives: the ring renumbers over the survivors.
409
- const alternatives = versions
410
- .filter(function (v) {
411
- return !v.deleted && (v.sessionId === fork.sessionId || (v.targetTurn === turn && walksToFork(v)));
412
- })
413
- .sort(function (a, b) {
414
- return a.createdAt - b.createdAt || String(a.sessionId).localeCompare(String(b.sessionId));
415
- });
416
- if (alternatives.length < 2) return null;
417
- let index = alternatives.findIndex(function (v) { return v.sessionId === cursor.sessionId; });
418
- if (index === -1) index = alternatives.findIndex(function (v) { return v.sessionId === sessionId; });
419
- if (index === -1) index = 0;
420
- return { alternatives: alternatives, index: index };
421
- }
422
-
423
- /* ------------------------------------------------------------ mutations -- */
424
-
425
- /**
426
- * Open a version, unarchiving it first when needed. The app cannot navigate
427
- * to an archived session (it bounces to the workspace picker), so an archived
428
- * target is activated through the host route before opening. Ghosts (deleted
429
- * versions) are never openable.
430
- */
431
- async function openVersionTarget(sessions, v) {
432
- if (!v || v.deleted || !sessions) return;
433
- if (v.archived) {
434
- try {
435
- await mutate({ action: 'activate', sessionId: v.sessionId });
436
- treeStore.invalidate();
437
- } catch (e) {}
438
- }
439
- openWhenListed(sessions, v.sessionId);
440
- }
441
-
442
- function openWhenListed(sessions, sessionId) {
443
- const list = sessions.list;
444
- if (!list || typeof list.getSnapshot !== 'function') { sessions.open(sessionId); return; }
445
- if (list.getSnapshot().byId[sessionId] !== undefined) { sessions.open(sessionId); return; }
446
- const stop = list.subscribe(function () {
447
- if (list.getSnapshot().byId[sessionId] !== undefined) {
448
- stop();
449
- sessions.open(sessionId);
450
- }
451
- });
452
- }
453
-
454
- async function mutate(operation) {
455
- const g = realGlobal();
456
- const res = await g.fetch(ROUTE, {
457
- method: 'POST',
458
- headers: { 'content-type': 'application/json', accept: 'application/json' },
459
- body: JSON.stringify(operation),
460
- });
461
- const body = await res.json().catch(function () { return {}; });
462
- if (!res.ok) throw new Error(body.error || ('HTTP ' + res.status));
463
- return body;
464
- }
465
-
466
- /* ---------------------------------------------------------------- utils -- */
467
-
468
- function contentText(content) {
469
- if (!Array.isArray(content)) return '';
470
- let out = '';
471
- for (let i = 0; i < content.length; i++) {
472
- const block = content[i];
473
- if (block && block.type === 'text' && typeof block.text === 'string') {
474
- out += (out ? '\n' : '') + block.text;
475
- }
476
- }
477
- return out;
478
- }
479
-
480
- function firstTextBlockIndex(content) {
481
- if (!Array.isArray(content)) return -1;
482
- for (let i = 0; i < content.length; i++) {
483
- if (content[i] && content[i].type === 'text') return i;
484
- }
485
- return -1;
486
- }
487
-
488
- function imageCount(content) {
489
- if (!Array.isArray(content)) return 0;
490
- let n = 0;
491
- for (let i = 0; i < content.length; i++) {
492
- if (content[i] && content[i].type === 'image') n += 1;
493
- }
494
- return n;
495
- }
496
-
497
- function clip(text, max) {
498
- const t = String(text).replace(/\s+/g, ' ').trim();
499
- return t.length > max ? t.slice(0, max - 1) + '…' : t;
500
- }
501
-
502
- function timeLabel(ms) {
503
- try {
504
- const d = new Date(ms);
505
- const p = function (n) { return n < 10 ? '0' + n : String(n); };
506
- return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
507
- } catch (e) {
508
- return '';
509
- }
510
- }
511
-
512
- /* ---------------------------------------------------------- graph layout -- */
513
-
514
- const CARD_W = 176;
515
- const SLOT_X = 206;
516
- const SLOT_Y = 132;
517
-
518
- /**
519
- * Project conversation family versions into a turn-level branching tree.
520
- */
521
- function buildTurnTree(versions, currentSessionId) {
522
- if (!versions || versions.length === 0) return [];
523
- const byId = new Map(versions.map(function (v) { return [v.sessionId, v]; }));
524
-
525
- let rootVersion = versions.find(function (v) { return !v.parentSessionId; });
526
- if (!rootVersion) {
527
- const rootId = rootOf(versions, currentSessionId) || (versions[0] && versions[0].sessionId);
528
- rootVersion = (rootId && byId.get(rootId)) || versions[0];
529
- }
530
- const rootSessionId = rootVersion.sessionId;
531
-
532
- const activeSessionPath = new Set();
533
- let cursor = byId.get(currentSessionId);
534
- const seenSessions = new Set();
535
- while (cursor && !seenSessions.has(cursor.sessionId)) {
536
- seenSessions.add(cursor.sessionId);
537
- activeSessionPath.add(cursor.sessionId);
538
- cursor = cursor.parentSessionId ? byId.get(cursor.parentSessionId) : null;
539
- }
540
-
541
- const nodes = [];
542
- const rootNodeId = rootSessionId + '#root';
543
- const nodeMap = new Map();
544
-
545
- const rootNode = {
546
- id: rootNodeId,
547
- sessionId: rootSessionId,
548
- turn: 0,
549
- isRoot: true,
550
- time: rootVersion.createdAt || 0,
551
- current: currentSessionId === rootSessionId && (!rootVersion.turns || rootVersion.turns.length === 0),
552
- onCurrentPath: true,
553
- deleted: !!rootVersion.deleted,
554
- archived: !!rootVersion.archived,
555
- };
556
- nodes.push(rootNode);
557
- nodeMap.set(rootNodeId, rootNode);
558
-
559
- function findParentTurnNodeId(v, turn) {
560
- if (!v.parentSessionId) {
561
- if (turn === 1) return rootNodeId;
562
- return v.sessionId + '#t' + (turn - 1);
563
- }
564
- if (turn === v.targetTurn) {
565
- if (v.targetTurn === 1) return rootNodeId;
566
- return v.parentSessionId + '#t' + (v.targetTurn - 1);
567
- }
568
- return v.sessionId + '#t' + (turn - 1);
569
- }
570
-
571
- for (let i = 0; i < versions.length; i++) {
572
- const v = versions[i];
573
- const isCurrentSession = v.sessionId === currentSessionId;
574
- const turns = Array.isArray(v.turns) && v.turns.length > 0 ? v.turns : [];
575
-
576
- if (!v.parentSessionId) {
577
- for (let j = 0; j < turns.length; j++) {
578
- const t = turns[j];
579
- const turnNum = t.turn;
580
- const turnNodeId = v.sessionId + '#t' + turnNum;
581
- const parentId = findParentTurnNodeId(v, turnNum);
582
- const node = {
583
- id: turnNodeId,
584
- sessionId: v.sessionId,
585
- turn: turnNum,
586
- parentId: parentId,
587
- time: t.time || v.createdAt,
588
- text: t.text || '',
589
- current: isCurrentSession,
590
- onCurrentPath: false,
591
- deleted: !!v.deleted,
592
- archived: !!v.archived,
593
- };
594
- nodes.push(node);
595
- nodeMap.set(turnNodeId, node);
596
- }
597
- } else {
598
- const targetTurn = typeof v.targetTurn === 'number' ? v.targetTurn : 1;
599
- const ownTurns = turns.filter(function (t) { return t.turn >= targetTurn; });
600
-
601
- if (ownTurns.length === 0) {
602
- const turnNodeId = v.sessionId + '#t' + targetTurn;
603
- const parentId = findParentTurnNodeId(v, targetTurn);
604
- const node = {
605
- id: turnNodeId,
606
- sessionId: v.sessionId,
607
- turn: targetTurn,
608
- parentId: parentId,
609
- operation: v.operation || 'edit',
610
- text: v.after || v.before || '',
611
- time: v.createdAt || 0,
612
- current: isCurrentSession,
613
- onCurrentPath: false,
614
- deleted: !!v.deleted,
615
- archived: !!v.archived,
616
- };
617
- nodes.push(node);
618
- nodeMap.set(turnNodeId, node);
619
- } else {
620
- for (let j = 0; j < ownTurns.length; j++) {
621
- const t = ownTurns[j];
622
- const turnNum = t.turn;
623
- const turnNodeId = v.sessionId + '#t' + turnNum;
624
- const parentId = findParentTurnNodeId(v, turnNum);
625
- const isForkTurn = turnNum === targetTurn;
626
- const node = {
627
- id: turnNodeId,
628
- sessionId: v.sessionId,
629
- turn: turnNum,
630
- parentId: parentId,
631
- operation: isForkTurn ? v.operation : undefined,
632
- text: t.text || (isForkTurn ? (v.after || v.before || '') : ''),
633
- time: t.time || v.createdAt,
634
- current: isCurrentSession,
635
- onCurrentPath: false,
636
- deleted: !!v.deleted,
637
- archived: !!v.archived,
638
- };
639
- nodes.push(node);
640
- nodeMap.set(turnNodeId, node);
641
- }
642
- }
643
- }
644
- }
645
-
646
- const allIds = new Set(nodes.map(function (n) { return n.id; }));
647
- for (let i = 0; i < nodes.length; i++) {
648
- if (nodes[i].parentId && !allIds.has(nodes[i].parentId)) {
649
- nodes[i].parentId = rootNodeId;
650
- }
651
- }
652
-
653
- const activePathIds = new Set();
654
- let latestNode = null;
655
- for (let i = 0; i < nodes.length; i++) {
656
- const n = nodes[i];
657
- if (n.sessionId === currentSessionId) {
658
- if (!latestNode || (n.turn || 0) >= (latestNode.turn || 0)) {
659
- latestNode = n;
660
- }
661
- }
662
- }
663
- let pathCursor = latestNode || nodes[0];
664
- const seenPath = new Set();
665
- while (pathCursor && !seenPath.has(pathCursor.id)) {
666
- seenPath.add(pathCursor.id);
667
- activePathIds.add(pathCursor.id);
668
- pathCursor = pathCursor.parentId ? nodeMap.get(pathCursor.parentId) : null;
669
- }
670
- activePathIds.add(rootNodeId);
671
-
672
- for (let i = 0; i < nodes.length; i++) {
673
- nodes[i].onCurrentPath = activePathIds.has(nodes[i].id);
674
- }
675
-
676
- return nodes;
677
- }
678
-
679
- /**
680
- * Tidy tree layout for turn nodes: leaves claim successive horizontal slots,
681
- * parents center over their children, siblings ordered by creation time.
682
- */
683
- function layoutTurnTree(nodes) {
684
- const byId = new Map(nodes.map(function (n) { return [n.id, n]; }));
685
- const children = new Map();
686
- const roots = [];
687
- for (let i = 0; i < nodes.length; i++) {
688
- const n = nodes[i];
689
- if (n.parentId && byId.has(n.parentId)) {
690
- if (!children.has(n.parentId)) children.set(n.parentId, []);
691
- children.get(n.parentId).push(n);
692
- } else {
693
- roots.push(n);
694
- }
695
- }
696
- children.forEach(function (list) {
697
- list.sort(function (a, b) { return (a.time || 0) - (b.time || 0) || String(a.id).localeCompare(String(b.id)); });
698
- });
699
- roots.sort(function (a, b) { return (a.time || 0) - (b.time || 0) || String(a.id).localeCompare(String(b.id)); });
700
- const pos = new Map();
701
- let cursor = 0;
702
- function walk(n, depth) {
703
- const kids = children.get(n.id) || [];
704
- if (kids.length === 0) {
705
- pos.set(n.id, { x: cursor * SLOT_X, y: depth * SLOT_Y });
706
- cursor += 1;
707
- return;
708
- }
709
- let lo = Infinity, hi = -Infinity;
710
- for (let i = 0; i < kids.length; i++) {
711
- walk(kids[i], depth + 1);
712
- const p = pos.get(kids[i].id);
713
- if (p.x < lo) lo = p.x;
714
- if (p.x > hi) hi = p.x;
715
- }
716
- pos.set(n.id, { x: (lo + hi) / 2, y: depth * SLOT_Y });
717
- }
718
- for (let i = 0; i < roots.length; i++) walk(roots[i], 0);
719
- const edges = [];
720
- children.forEach(function (kids, parentId) {
721
- for (let i = 0; i < kids.length; i++) {
722
- edges.push({ from: parentId, to: kids[i].id, onPath: !!kids[i].onCurrentPath });
723
- }
724
- });
725
- return { pos: pos, edges: edges, byId: byId, nodes: nodes };
726
- }
727
-
728
- function edgePath(x1, y1, x2, y2) {
729
- const dy = Math.max(26, (y2 - y1) * 0.5);
730
- return 'M' + x1 + ' ' + y1 + ' C' + x1 + ' ' + (y1 + dy) + ', ' + x2 + ' ' + (y2 - dy) + ', ' + x2 + ' ' + y2;
731
- }
732
-
733
- /** Bring the Chat view forward; the first conversation tab is always Chat. */
734
- function showChat() {
735
- const g = realGlobal();
736
- if (!g || !g.document) return;
737
- const tab = g.document.querySelector('[role=tab]');
738
- if (tab && tab.getAttribute('aria-selected') !== 'true') tab.click();
739
- }
740
-
741
- /**
742
- * After a graph click lands in a session, glide the chat to the version's own
743
- * message and flash it. Polls because the session view mounts asynchronously.
744
- */
745
- function flashTurn(sessionId, turn, tries) {
746
- const g = realGlobal();
747
- if (!g || !g.document) return;
748
- const el = g.document.querySelector(
749
- '.mtx-row[data-session="' + sessionId + '"][data-turn="' + String(turn) + '"]');
750
- if (el) {
751
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
752
- el.classList.remove('mtx-flash');
753
- void el.offsetWidth;
754
- el.classList.add('mtx-flash');
755
- return;
756
- }
757
- if (tries > 0) setTimeout(function () { flashTurn(sessionId, turn, tries - 1); }, 160);
758
- }
759
-
760
- /* ------------------------------------------------------------------ css -- */
761
-
762
- const CSS = [
763
- // User bubble replica: right-aligned rounded panel like the host's, with a
764
- // hover-revealed edit control to its left, ChatGPT-style.
765
- '.mtx-row{display:flex;flex-direction:column;align-items:flex-end;gap:6px}',
766
- '.mtx-line{display:flex;align-items:flex-start;gap:8px;max-width:min(85%,720px)}',
767
- '.mtx-edit-btn{flex:none;margin-top:8px;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:0;transition:opacity 120ms ease,background 120ms ease}',
768
- '.mtx-row:hover .mtx-edit-btn{opacity:1}',
769
- '.mtx-edit-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
770
- '.mtx-bubble{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,150,.14));border-radius:16px;padding:10px 16px;font-size:15px;line-height:26px;color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere}',
771
- '.mtx-img{font-size:12px;color:var(--dsw-alias-label-tertiary);margin-top:4px}',
772
-
773
- // Inline editor, ChatGPT-style: the bubble grows into an editing surface
774
- // with Cancel / Send below-right.
775
- '.mtx-editor{width:min(85%,720px);background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,150,.14));border-radius:16px;padding:12px 16px;display:flex;flex-direction:column;gap:10px}',
776
- '.mtx-textarea{width:100%;min-height:72px;resize:vertical;border:0;outline:none;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:15px;line-height:26px}',
777
- '.mtx-editor-actions{display:flex;justify-content:flex-end;gap:8px}',
778
- '.mtx-btn{padding:6px 16px;border-radius:999px;border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.3));background:transparent;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);cursor:pointer}',
779
- '.mtx-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
780
- '.mtx-btn[data-primary]{background:var(--dsw-alias-accent-primary,#4b8dff);border-color:transparent;color:#fff}',
781
- '.mtx-btn[data-primary]:hover{filter:brightness(1.08)}',
782
- '.mtx-btn[disabled]{opacity:.5;cursor:default}',
783
- '.mtx-error{font-size:12px;color:var(--dsw-alias-status-error,#e5484d)}',
784
-
785
- // Version ring, under the bubble: ‹ 2/3 ›.
786
- '.mtx-ring{display:flex;align-items:center;gap:2px;font-size:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
787
- '.mtx-ring button{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;font-size:14px}',
788
- '.mtx-ring button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
789
- '.mtx-ring button[disabled]{opacity:.35;cursor:default}',
790
-
791
- // Versions graph: a pannable canvas with spring-arranged cards and bezier
792
- // edges. Cursor communicates state: grab on canvas, pointer on cards.
793
- '.mtx-graph{position:relative;height:100%;overflow:hidden;cursor:grab;background-image:radial-gradient(color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 22%,transparent) 1px,transparent 1px);background-size:26px 26px;touch-action:none;user-select:none}',
794
- '.mtx-graph[data-panning]{cursor:grabbing}',
795
- '.mtx-world{position:absolute;left:0;top:0;will-change:transform}',
796
- '.mtx-edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}',
797
- '.mtx-edge{fill:none;stroke:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 45%,transparent);stroke-width:1.5}',
798
- '.mtx-edge[data-path]{stroke:var(--dsw-alias-accent-primary,#4b8dff);stroke-width:2}',
799
- '.mtx-card{position:absolute;left:0;top:0;width:176px;box-sizing:border-box;display:flex;align-items:flex-start;gap:8px;padding:10px 12px;border-radius:13px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 30%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 10%,var(--dsw-alias-bg-primary,rgba(30,30,34,.9)));box-shadow:0 2px 10px rgba(0,0,0,.14);cursor:pointer;will-change:transform;transition:box-shadow 180ms ease,border-color 180ms ease}',
800
- '.mtx-card:hover{box-shadow:0 6px 22px rgba(0,0,0,.24);border-color:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 55%,transparent)}',
801
- '.mtx-card[data-current]{border-color:var(--dsw-alias-accent-primary,#4b8dff);box-shadow:0 0 0 1px var(--dsw-alias-accent-primary,#4b8dff),0 6px 24px color-mix(in srgb,var(--dsw-alias-accent-primary,#4b8dff) 30%,transparent)}',
802
- '.mtx-card[data-dragging]{cursor:grabbing;box-shadow:0 14px 34px rgba(0,0,0,.3);z-index:3}',
803
- '.mtx-card[data-deleted]{opacity:.55;border-style:dashed;cursor:default}',
804
- '.mtx-card[data-archived]{opacity:.72}',
805
- '.mtx-card[data-deleted]:hover{box-shadow:0 2px 10px rgba(0,0,0,.14);border-color:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 30%,transparent)}',
806
- '.mtx-card-icon{flex:none;width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:8px;font-size:12px;background:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 18%,transparent);color:var(--dsw-alias-label-secondary,#bbb)}',
807
- '.mtx-card[data-path] .mtx-card-icon{background:color-mix(in srgb,var(--dsw-alias-accent-primary,#4b8dff) 20%,transparent);color:var(--dsw-alias-accent-primary,#4b8dff)}',
808
- '.mtx-card-main{min-width:0;flex:1}',
809
- '.mtx-card-title{font-size:12.5px;font-weight:600;line-height:17px;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
810
- '.mtx-card-sub{font-size:11px;line-height:15px;margin-top:2px;color:var(--dsw-alias-label-tertiary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}',
811
- '.mtx-graph-tools{position:absolute;top:12px;right:14px;display:flex;gap:6px;z-index:4}',
812
- '.mtx-tool{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;border-radius:9px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 30%,transparent);background:var(--dsw-alias-bg-primary,rgba(30,30,34,.85));color:var(--dsw-alias-label-secondary,#bbb);cursor:pointer;font-size:14px}',
813
- '.mtx-tool:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
814
- '.mtx-empty{position:absolute;left:0;right:0;bottom:26px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12.5px;pointer-events:none}',
815
- '.mtx-graph .mtx-link{position:absolute;right:14px;bottom:10px;font-size:12px;color:var(--dsw-alias-label-tertiary);text-decoration:none;z-index:4}',
816
- '.mtx-link:hover{color:var(--dsw-alias-label-primary)}',
817
- '.mtx-error{font-size:12px;color:var(--dsw-alias-status-error,#e5484d)}',
818
- '.mtx-graph .mtx-error{position:absolute;left:14px;top:16px;z-index:4}',
819
-
820
- // Flash highlight when a graph click lands on its message.
821
- '@keyframes mtx-flash-kf{0%,55%{background:color-mix(in srgb,var(--dsw-alias-accent-primary,#4b8dff) 22%,transparent)}100%{background:transparent}}',
822
- '.mtx-flash .mtx-bubble{animation:mtx-flash-kf 1.4s ease-out}',
823
-
824
- /* ---- action row, below the bubble ------------------------------------ */
825
- // All three references put the message controls BELOW the bubble, not
826
- // beside it. What differs is which controls exist and whether they are
827
- // always visible or revealed on hover.
828
- '.mtx-actions{display:flex;align-items:center;gap:2px;margin-top:1px}',
829
- '.mtx-act{width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}',
830
- '.mtx-act:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
831
- '.mtx-act[disabled]{opacity:.4;cursor:default}',
832
- // ChatGPT and Claude reveal the controls on hover; DeepSeek keeps them out,
833
- // which is also how DSH itself behaves.
834
- 'html[data-mtx-style=chatgpt] .mtx-actions,html[data-mtx-style=claude] .mtx-actions{opacity:0;transition:opacity 120ms ease}',
835
- 'html[data-mtx-style=chatgpt] .mtx-row:hover .mtx-actions,html[data-mtx-style=chatgpt] .mtx-row:focus-within .mtx-actions,',
836
- 'html[data-mtx-style=claude] .mtx-row:hover .mtx-actions,html[data-mtx-style=claude] .mtx-row:focus-within .mtx-actions{opacity:1}',
837
- // Only Claude offers a retry control on the user message.
838
- '.mtx-act[data-act=retry]{display:none}',
839
- 'html[data-mtx-style=claude] .mtx-act[data-act=retry]{display:inline-flex}',
840
-
841
- /* ---- editor button placement ----------------------------------------- */
842
- // ChatGPT and DeepSeek keep Cancel/Send INSIDE the editor box. Claude puts
843
- // them OUTSIDE, below it, and names the primary action Save.
844
- '.mtx-editor-outside{display:none;justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;width:min(85%,720px)}',
845
- 'html[data-mtx-style=claude] .mtx-editor-actions{display:none}',
846
- 'html[data-mtx-style=claude] .mtx-editor-outside{display:flex}',
847
-
848
-
849
- /* ---- settings section ------------------------------------------------ */
850
- '.mtx-set{display:flex;flex-direction:column;gap:12px;max-width:560px;font-size:14px;color:var(--dsw-alias-label-primary)}',
851
- '.mtx-set-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
852
- '.mtx-set-label{font-size:13px}',
853
- '.mtx-select{border-radius:9px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 34%,transparent);background:var(--dsw-alias-bg-primary,rgba(30,30,34,.6));color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:6px 10px;outline:none;cursor:pointer}',
854
- '.mtx-set-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
855
- '.mtx-preview{margin-top:2px;padding:18px 16px 16px;border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 7%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 16%,transparent);pointer-events:none}',
856
- '.mtx-preview .mtx-editor{margin-top:12px}',
857
- '.mtx-preview .mtx-textarea{min-height:auto}',
858
- '.mtx-preview .mtx-actions{opacity:1!important}',
859
- '.mtx-set-link{align-self:flex-end;font-size:12px;color:var(--dsw-alias-label-tertiary);text-decoration:none;pointer-events:auto}',
860
- '.mtx-set-link:hover{color:var(--dsw-alias-label-primary)}',
861
- ].join('');
862
-
863
- return {
864
- apply(ctx) {
865
- const slots = ctx.get('slots');
866
- if (slots === undefined) return;
867
- ctx.effect(function () { return styles.insert(CSS); });
868
- // Reflect the chosen edit style onto <html> now and on every change.
869
- ctx.effect(function () { syncStyleAttribute(); return styleStore.subscribe(syncStyleAttribute); });
870
-
871
- let sessions = null;
872
- try { sessions = ctx.get('sessions'); } catch (e) {}
873
-
874
- ctx.effect(function () {
875
- if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
876
- return sessions.list.subscribe(function () { treeStore.invalidate(); });
877
- }
878
- });
879
-
880
- // Session-list state straight from the service, so this works no matter
881
- // what props the host chooses to pass slot components.
882
- function useSessionList() {
883
- const [, force] = React.useReducer(function (x) { return x + 1; }, 0);
884
- React.useEffect(function () {
885
- if (!sessions || !sessions.list || typeof sessions.list.subscribe !== 'function') return undefined;
886
- return sessions.list.subscribe(force);
887
- }, []);
888
- return sessions && sessions.list && typeof sessions.list.getSnapshot === 'function'
889
- ? sessions.list.getSnapshot()
890
- : { byId: {} };
891
- }
892
-
893
- const I18N_NS = 'dsh-plugin-message-tree';
894
- const I18N = {
895
- en: {
896
- view: 'Versions',
897
- edit: 'Edit message',
898
- cancel: 'Cancel',
899
- send: 'Send',
900
- save: 'Save',
901
- copy: 'Copy',
902
- copied: 'Copied',
903
- retry: 'Retry this turn',
904
- regen: 'Regenerate from here',
905
- original: 'Original conversation',
906
- turn: 'Turn {turn}',
907
- edited: 'Edited turn {turn}',
908
- retried: 'Regenerated turn {turn}',
909
- branch: 'Branch',
910
- refresh: 'Refresh',
911
- fit: 'Center view',
912
- empty: 'No versions yet — edit any of your messages to branch this conversation. Drag to pan, scroll to zoom.',
913
- images: '{count} image(s) kept as-is',
914
- nav: 'Message Edit',
915
- styleLabel: 'Edit interface style',
916
- styleHint: 'Where the message controls sit and which ones appear. Changes apply live.',
917
- style_chatgpt: 'ChatGPT',
918
- style_deepseek: 'DeepSeek',
919
- style_claude: 'Claude',
920
- styleDesc_chatgpt: 'Copy and edit under the bubble, revealed on hover. Cancel and Send sit inside the editor.',
921
- styleDesc_deepseek: 'Copy and edit under the bubble, always visible — closest to DSH itself. Cancel and Send sit inside the editor.',
922
- styleDesc_claude: 'Retry, edit and copy under the bubble, revealed on hover. Cancel and Save sit below the editor.',
923
- deletedVersion: 'Deleted version',
924
- archivedTag: 'Archived',
925
- rememberPathLabel: 'Remember the version I was viewing',
926
- rememberPathHint: 'Reopening a conversation returns to the branch you last had open instead of the original. Off means it always opens the first version.',
927
- stopOnEditLabel: 'Stop the running reply when I edit',
928
- stopOnEditHint: 'Editing or retrying cancels every reply still being generated in this conversation before branching, including other versions, so no superseded answer keeps spending tokens. This also lets you edit mid-reply. Off leaves them running.',
929
- previewUser: 'Rewrite this paragraph to be more concise.',
930
- },
931
- zh: {
932
- view: '版本',
933
- edit: '编辑消息',
934
- cancel: '取消',
935
- send: '发送',
936
- save: '保存',
937
- copy: '复制',
938
- copied: '已复制',
939
- retry: '重试本轮',
940
- regen: '从这里重新生成',
941
- original: '原始对话',
942
- turn: ' {turn} ',
943
- edited: '编辑了第 {turn} ',
944
- retried: '重新生成第 {turn} ',
945
- branch: '分支',
946
- refresh: '刷新',
947
- fit: '居中显示',
948
- empty: '还没有版本——编辑任意一条你的消息即可创建分支。拖动平移,滚轮缩放。',
949
- images: '{count} 张图片将原样保留',
950
- nav: '消息编辑',
951
- styleLabel: '编辑界面风格',
952
- styleHint: '消息操作按钮的位置与种类。修改即时生效。',
953
- style_chatgpt: 'ChatGPT',
954
- style_deepseek: 'DeepSeek',
955
- style_claude: 'Claude',
956
- styleDesc_chatgpt: '气泡下方为复制与编辑,悬停时显示;「取消 / 发送」位于编辑框内部。',
957
- styleDesc_deepseek: '气泡下方为复制与编辑,始终显示——最接近 DSH 原生;「取消 / 发送」位于编辑框内部。',
958
- styleDesc_claude: '气泡下方为重试、编辑与复制,悬停时显示;「取消 / 保存」位于编辑框下方。',
959
- deletedVersion: '已删除的版本',
960
- archivedTag: '已归档',
961
- rememberPathLabel: '记住我正在查看的版本',
962
- rememberPathHint: '重新打开会话时回到上次查看的分支,而不是最初那条。关闭后始终打开第一个版本。',
963
- stopOnEditLabel: '编辑时中止正在生成的回复',
964
- stopOnEditHint: '编辑或重试时,先取消该会话中所有仍在生成的回复(包括其它版本)再分支,避免被取代的回答继续消耗额度;同时允许在回复过程中直接编辑。关闭后它们会继续跑完。',
965
- previewUser: '把这段话改写得更简洁一些。',
966
- },
967
- };
968
- let t = function (key, params) {
969
- let out = I18N.en[key] || key;
970
- if (params) for (const k in params) out = out.replace('{' + k + '}', String(params[k]));
971
- return out;
972
- };
973
- try {
974
- const locale = ctx.get('locale');
975
- if (locale && typeof locale.register === 'function' && typeof locale.bind === 'function') {
976
- ctx.effect(function () { return locale.register(I18N_NS, I18N); });
977
- t = locale.bind(I18N_NS);
978
- }
979
- } catch (e) {}
980
-
981
- function PencilIcon() {
982
- return React.createElement('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
983
- React.createElement('path', {
984
- d: 'M11.1 2.4a1.6 1.6 0 012.3 2.3l-7.2 7.2-3 .8.8-3 7.1-7.3z',
985
- stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round',
986
- }));
987
- }
988
-
989
- function CopyIcon() {
990
- return React.createElement('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
991
- React.createElement('rect', {
992
- x: 5.4, y: 5.4, width: 8.2, height: 8.2, rx: 2,
993
- stroke: 'currentColor', strokeWidth: 1.3,
994
- }),
995
- React.createElement('path', {
996
- d: 'M10.6 5.2V4.2a1.8 1.8 0 00-1.8-1.8H4.2a1.8 1.8 0 00-1.8 1.8v4.6a1.8 1.8 0 001.8 1.8h1',
997
- stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round',
998
- }));
999
- }
1000
-
1001
- function RetryIcon() {
1002
- return React.createElement('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
1003
- React.createElement('path', {
1004
- d: 'M13.2 8a5.2 5.2 0 11-1.6-3.75',
1005
- stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round',
1006
- }),
1007
- React.createElement('path', {
1008
- d: 'M13.4 2.3v3.1h-3.1',
1009
- stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round',
1010
- }));
1011
- }
1012
-
1013
- /** Ring beneath a bubble: ‹ i/m › switching whole version sessions. */
1014
- function VersionRing(props) {
1015
- const ring = props.ring;
1016
- if (!ring) return null;
1017
- const go = function (delta) {
1018
- const next = ring.alternatives[ring.index + delta];
1019
- if (next) openVersionTarget(sessions, next);
1020
- };
1021
- return React.createElement('div', { className: 'mtx-ring' },
1022
- React.createElement('button', {
1023
- type: 'button', disabled: ring.index <= 0,
1024
- onClick: function () { go(-1); },
1025
- }, '‹'),
1026
- React.createElement('span', null, (ring.index + 1) + '/' + ring.alternatives.length),
1027
- React.createElement('button', {
1028
- type: 'button', disabled: ring.index >= ring.alternatives.length - 1,
1029
- onClick: function () { go(1); },
1030
- }, '›')
1031
- );
1032
- }
1033
-
1034
- function UserMessageView(props) {
1035
- const node = props.node;
1036
- const data = node.data || {};
1037
- const text = contentText(data.content);
1038
- const images = imageCount(data.content);
1039
- const sessionId = props.sessionId !== undefined ? props.sessionId : (node.sessionId);
1040
- // location.turn is a turn-group object ({turn, start, end, steps}); the
1041
- // turn number lives one level down.
1042
- const rawTurn = node.location ? node.location.turn : undefined;
1043
- const turn = typeof rawTurn === 'number' ? rawTurn
1044
- : (rawTurn && typeof rawTurn.turn === 'number' ? rawTurn.turn : undefined);
1045
- const list = useSessionList();
1046
- const summary = sessionId !== undefined ? list.byId[sessionId] : undefined;
1047
- const running = !!(summary && summary.running);
1048
- const tree = useTree(sessionId);
1049
- const ring = typeof turn === 'number' ? ringFor(tree && tree.versions, sessionId, turn) : null;
1050
-
1051
- // Remember which branch of this family is open, and restore it when we
1052
- // land back on the family root. Runs per bubble, so every step is either
1053
- // idempotent or guarded — see activePathStore.
1054
- const versions = tree && tree.versions;
1055
- const prefs = usePrefs();
1056
- React.useEffect(function () {
1057
- if (!prefs.rememberPath) return;
1058
- if (!versions || sessionId === undefined) return;
1059
- const root = rootOf(versions, sessionId);
1060
- if (!root) return;
1061
- if (sessionId !== root) {
1062
- // Arrived at a branch: that is now the remembered view, and any
1063
- // restore we kicked off has landed.
1064
- pendingRestore.delete(root);
1065
- activePathStore.set(root, sessionId);
1066
- return;
1067
- }
1068
- // On the root. Don't record while a restore we triggered is still in
1069
- // flight, or we would overwrite the target with the root we are leaving.
1070
- if (pendingRestore.has(root)) return;
1071
- const remembered = activePathStore.get(root);
1072
- if (!remembered || remembered === root) return;
1073
- if (restoredFamilies.has(root)) {
1074
- // Already restored once this page load and the user walked back to
1075
- // the root deliberately — honour that as the new selection.
1076
- activePathStore.set(root, root);
1077
- return;
1078
- }
1079
- // Never chase a branch that no longer exists (a deleted branch may
1080
- // still appear here as a non-openable ghost).
1081
- const target = versions.find(function (v) { return v.sessionId === remembered; });
1082
- if (!target || target.deleted) return;
1083
- restoredFamilies.add(root);
1084
- pendingRestore.add(root);
1085
- openVersionTarget(sessions, target);
1086
- }, [versions, sessionId, sessions, prefs.rememberPath]);
1087
-
1088
- const [editing, setEditing] = React.useState(false);
1089
- const [draft, setDraft] = React.useState('');
1090
- const [busy, setBusy] = React.useState(false);
1091
- const [error, setError] = React.useState(null);
1092
- const [copied, setCopied] = React.useState(false);
1093
-
1094
- // Editing used to require an idle session, because forking calls
1095
- // `runMaintenance`, which throws while a turn is live. With stopOnEdit the
1096
- // host cancels that turn first, so editing mid-answer is allowed — and is
1097
- // the point: it stops the superseded turn instead of leaving it streaming.
1098
- const canEdit = (!running || prefs.stopOnEdit)
1099
- && sessionId !== undefined && typeof turn === 'number' && text !== '' && !editing;
1100
-
1101
- function beginEdit() {
1102
- setDraft(text);
1103
- setError(null);
1104
- setEditing(true);
1105
- }
1106
-
1107
- async function submit() {
1108
- const blockIndex = firstTextBlockIndex(data.content);
1109
- // An unchanged draft is still a resend: it branches and regenerates.
1110
- if (blockIndex === -1 || draft.trim() === '') { setEditing(false); return; }
1111
- setBusy(true);
1112
- setError(null);
1113
- try {
1114
- const result = await mutate({
1115
- action: 'edit',
1116
- sessionId: sessionId,
1117
- eventSeq: data.seq,
1118
- blockIndex: blockIndex,
1119
- text: draft,
1120
- stopPrevious: prefs.stopOnEdit,
1121
- });
1122
- const currentTree = treeStore.get(sessionId);
1123
- if (currentTree && Array.isArray(currentTree.versions)) {
1124
- const newV = {
1125
- sessionId: result.sessionId,
1126
- parentSessionId: sessionId,
1127
- targetTurn: turn,
1128
- operation: 'edit',
1129
- createdAt: Date.now(),
1130
- current: true,
1131
- onCurrentPath: true,
1132
- after: draft,
1133
- turns: [{ turn: turn, text: draft, time: Date.now() }],
1134
- };
1135
- treeStore.setTree(result.sessionId, currentTree.versions.concat([newV]));
1136
- }
1137
- treeStore.load(result.sessionId);
1138
- setEditing(false);
1139
- if (sessions) openWhenListed(sessions, result.sessionId);
1140
- } catch (e) {
1141
- setError(String(e && e.message || e));
1142
- }
1143
- setBusy(false);
1144
- }
1145
-
1146
- async function retry() {
1147
- if (typeof turn !== 'number') return;
1148
- setBusy(true);
1149
- setError(null);
1150
- try {
1151
- const result = await mutate({
1152
- action: 'retry', sessionId: sessionId, turn: turn, stopPrevious: prefs.stopOnEdit,
1153
- });
1154
- const currentTree = treeStore.get(sessionId);
1155
- if (currentTree && Array.isArray(currentTree.versions)) {
1156
- const newV = {
1157
- sessionId: result.sessionId,
1158
- parentSessionId: sessionId,
1159
- targetTurn: turn,
1160
- operation: 'retry',
1161
- createdAt: Date.now(),
1162
- current: true,
1163
- onCurrentPath: true,
1164
- before: text,
1165
- turns: [{ turn: turn, text: text, time: Date.now() }],
1166
- };
1167
- treeStore.setTree(result.sessionId, currentTree.versions.concat([newV]));
1168
- }
1169
- treeStore.load(result.sessionId);
1170
- if (sessions) openWhenListed(sessions, result.sessionId);
1171
- } catch (e) {
1172
- setError(String(e && e.message || e));
1173
- }
1174
- setBusy(false);
1175
- }
1176
-
1177
- function copy() {
1178
- const g = realGlobal();
1179
- try {
1180
- if (g && g.navigator && g.navigator.clipboard) g.navigator.clipboard.writeText(text);
1181
- } catch (e) {}
1182
- setCopied(true);
1183
- setTimeout(function () { setCopied(false); }, 1200);
1184
- }
1185
-
1186
- if (editing) {
1187
- // Both action rows are rendered; CSS shows the one this preset wants —
1188
- // inside the box (ChatGPT, DeepSeek) or below it (Claude).
1189
- const cancelButton = function (key) {
1190
- return React.createElement('button', {
1191
- key: key, type: 'button', className: 'mtx-btn', disabled: busy,
1192
- onClick: function () { setEditing(false); },
1193
- }, t('cancel'));
1194
- };
1195
- const confirmButton = function (key, label) {
1196
- return React.createElement('button', {
1197
- key: key, type: 'button', className: 'mtx-btn', 'data-primary': '',
1198
- disabled: busy || draft.trim() === '',
1199
- onClick: submit,
1200
- }, label);
1201
- };
1202
- return React.createElement('div', { className: 'mtx-row' },
1203
- React.createElement('div', { className: 'mtx-editor' },
1204
- React.createElement('textarea', {
1205
- className: 'mtx-textarea',
1206
- value: draft,
1207
- autoFocus: true,
1208
- onChange: function (e) { setDraft(e.target.value); },
1209
- onKeyDown: function (e) {
1210
- if (e.key === 'Escape') setEditing(false);
1211
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit();
1212
- },
1213
- }),
1214
- images > 0 ? React.createElement('div', { className: 'mtx-img' }, t('images', { count: images })) : null,
1215
- error ? React.createElement('div', { className: 'mtx-error' }, error) : null,
1216
- React.createElement('div', { className: 'mtx-editor-actions' },
1217
- cancelButton('c-in'), confirmButton('s-in', t('send'))
1218
- )
1219
- ),
1220
- React.createElement('div', { className: 'mtx-editor-outside' },
1221
- cancelButton('c-out'), confirmButton('s-out', t('save'))
1222
- )
1223
- );
1224
- }
1225
-
1226
- return React.createElement('div', { className: 'mtx-row', 'data-turn': turn, 'data-session': sessionId },
1227
- React.createElement('div', { className: 'mtx-line' },
1228
- React.createElement('div', { className: 'mtx-bubble' },
1229
- text,
1230
- images > 0 ? React.createElement('div', { className: 'mtx-img' }, t('images', { count: images })) : null
1231
- )
1232
- ),
1233
- // The controls sit under the bubble in all three references. Which
1234
- // ones exist, and whether they wait for hover, is what differs.
1235
- React.createElement('div', { className: 'mtx-actions' },
1236
- React.createElement(VersionRing, { ring: ring }),
1237
- React.createElement('button', {
1238
- type: 'button', className: 'mtx-act', 'data-act': 'retry',
1239
- title: t('retry'), disabled: !canEdit || busy, onClick: retry,
1240
- }, RetryIcon()),
1241
- React.createElement('button', {
1242
- type: 'button', className: 'mtx-act', 'data-act': 'edit',
1243
- title: t('edit'), disabled: !canEdit, onClick: beginEdit,
1244
- }, PencilIcon()),
1245
- React.createElement('button', {
1246
- type: 'button', className: 'mtx-act', 'data-act': 'copy',
1247
- title: copied ? t('copied') : t('copy'), onClick: copy,
1248
- }, CopyIcon())
1249
- ),
1250
- error ? React.createElement('div', { className: 'mtx-error' }, error) : null
1251
- );
1252
- }
1253
-
1254
- /**
1255
- * The Versions view: a live graph. Cards spring into a tidy tree, edges
1256
- * follow every frame, the canvas pans and zooms, and clicking a card
1257
- * jumps straight to that version's message.
1258
- */
1259
- function VersionsView(props) {
1260
- const sessionId = props.sessionId;
1261
- const tree = useTree(sessionId);
1262
- const titles = useSessionList().byId;
1263
- const versions = (tree && tree.versions) || [];
1264
-
1265
- const graphRef = React.useRef(null);
1266
- const worldRef = React.useRef(null);
1267
- const cardEls = React.useRef(new Map());
1268
- const edgeEls = React.useRef(new Map());
1269
- const springs = React.useRef(new Map());
1270
- const layoutRef = React.useRef(null);
1271
- const viewRef = React.useRef({ x: 60, y: 42, scale: 1 });
1272
- const dragRef = React.useRef(null);
1273
- const rafRef = React.useRef(0);
1274
- const fittedRef = React.useRef(false);
1275
-
1276
- const turnNodes = React.useMemo(function () {
1277
- return buildTurnTree(versions, sessionId);
1278
- }, [versions, sessionId]);
1279
-
1280
- const layoutKey = turnNodes.map(function (n) {
1281
- return n.id + ':' + (n.parentId || '') + ':' + (n.onCurrentPath ? 1 : 0);
1282
- }).join('|');
1283
- const layout = React.useMemo(function () { return layoutTurnTree(turnNodes); }, [layoutKey]);
1284
- layoutRef.current = layout;
1285
-
1286
- function applyView() {
1287
- const el = worldRef.current;
1288
- const view = viewRef.current;
1289
- if (el) el.style.transform = 'translate(' + view.x + 'px,' + view.y + 'px) scale(' + view.scale + ')';
1290
- }
1291
-
1292
- function renderFrame() {
1293
- springs.current.forEach(function (s, id) {
1294
- const el = cardEls.current.get(id);
1295
- if (el) el.style.transform = 'translate(' + (s.x - CARD_W / 2) + 'px,' + s.y + 'px)';
1296
- });
1297
- const lay = layoutRef.current;
1298
- if (!lay) return;
1299
- for (let i = 0; i < lay.edges.length; i++) {
1300
- const e = lay.edges[i];
1301
- const el = edgeEls.current.get(e.from + '>' + e.to);
1302
- const a = springs.current.get(e.from);
1303
- const b = springs.current.get(e.to);
1304
- if (!el || !a || !b) continue;
1305
- const fromEl = cardEls.current.get(e.from);
1306
- const h = fromEl ? fromEl.offsetHeight : 58;
1307
- el.setAttribute('d', edgePath(a.x, a.y + h, b.x, b.y));
1308
- }
1309
- }
1310
-
1311
- function kick() {
1312
- if (rafRef.current) return;
1313
- let last = 0;
1314
- const step = function (now) {
1315
- rafRef.current = 0;
1316
- const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
1317
- last = now;
1318
- let alive = false;
1319
- springs.current.forEach(function (s, id) {
1320
- const d = dragRef.current;
1321
- if (d && d.kind === 'node' && d.id === id) { alive = true; return; }
1322
- const k = 190, c = 24;
1323
- s.vx += ((s.tx - s.x) * k - s.vx * c) * dt;
1324
- s.vy += ((s.ty - s.y) * k - s.vy * c) * dt;
1325
- s.x += s.vx * dt;
1326
- s.y += s.vy * dt;
1327
- if (Math.abs(s.vx) + Math.abs(s.vy) + Math.abs(s.tx - s.x) + Math.abs(s.ty - s.y) > 0.5) alive = true;
1328
- else { s.x = s.tx; s.y = s.ty; s.vx = 0; s.vy = 0; }
1329
- });
1330
- renderFrame();
1331
- if (alive) rafRef.current = requestAnimationFrame(step);
1332
- };
1333
- rafRef.current = requestAnimationFrame(step);
1334
- }
1335
-
1336
- function fitView() {
1337
- const el = graphRef.current;
1338
- const lay = layoutRef.current;
1339
- if (!el || !lay) return;
1340
- let lo = Infinity, hi = -Infinity, bot = 100;
1341
- lay.pos.forEach(function (p) {
1342
- lo = Math.min(lo, p.x - CARD_W / 2);
1343
- hi = Math.max(hi, p.x + CARD_W / 2);
1344
- bot = Math.max(bot, p.y + 90);
1345
- });
1346
- if (lo === Infinity) { lo = 0; hi = CARD_W; }
1347
- const w = el.clientWidth || 600;
1348
- const h = el.clientHeight || 400;
1349
- const scale = Math.min(1, (w - 70) / Math.max(1, hi - lo), (h - 70) / bot);
1350
- viewRef.current = {
1351
- x: (w - (hi - lo) * scale) / 2 - lo * scale,
1352
- y: Math.max(30, (h - bot * scale) / 2),
1353
- scale: scale,
1354
- };
1355
- applyView();
1356
- }
1357
-
1358
- // Retarget springs on every layout change; new cards are born at their
1359
- // parent's position so they visibly grow out of it.
1360
- React.useEffect(function () {
1361
- const lay = layout;
1362
- const alive = new Set();
1363
- lay.pos.forEach(function (p, id) {
1364
- alive.add(id);
1365
- let s = springs.current.get(id);
1366
- if (!s) {
1367
- const n = lay.byId.get(id);
1368
- const pp = n && n.parentId ? lay.pos.get(n.parentId) : null;
1369
- const born = pp || p;
1370
- springs.current.set(id, { x: born.x, y: born.y, vx: 0, vy: 0, tx: p.x, ty: p.y });
1371
- } else {
1372
- s.tx = p.x;
1373
- s.ty = p.y;
1374
- }
1375
- });
1376
- springs.current.forEach(function (_, id) { if (!alive.has(id)) springs.current.delete(id); });
1377
- if (!fittedRef.current && lay.pos.size > 0) {
1378
- fittedRef.current = true;
1379
- fitView();
1380
- }
1381
- applyView();
1382
- kick();
1383
- return function () {
1384
- if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = 0; }
1385
- };
1386
- }, [layout]);
1387
-
1388
- // Wheel zoom around the pointer (non-passive so we may preventDefault).
1389
- React.useEffect(function () {
1390
- const el = graphRef.current;
1391
- if (!el) return undefined;
1392
- const onWheel = function (ev) {
1393
- ev.preventDefault();
1394
- const view = viewRef.current;
1395
- const rect = el.getBoundingClientRect();
1396
- const mx = ev.clientX - rect.left;
1397
- const my = ev.clientY - rect.top;
1398
- const next = Math.min(1.8, Math.max(0.3, view.scale * Math.exp(-ev.deltaY * 0.0013)));
1399
- const f = next / view.scale;
1400
- view.x = mx - (mx - view.x) * f;
1401
- view.y = my - (my - view.y) * f;
1402
- view.scale = next;
1403
- applyView();
1404
- };
1405
- el.addEventListener('wheel', onWheel, { passive: false });
1406
- return function () { el.removeEventListener('wheel', onWheel); };
1407
- }, []);
1408
-
1409
- function openVersion(id) {
1410
- const lay = layoutRef.current;
1411
- const node = lay && lay.byId.get(id);
1412
- if (!node || node.deleted || !sessions) return;
1413
- const v = versions.find(function (item) { return item.sessionId === node.sessionId; });
1414
- if (!v) return;
1415
- openVersionTarget(sessions, v);
1416
- showChat();
1417
- if (typeof node.turn === 'number' && node.turn > 0) flashTurn(node.sessionId, node.turn, 45);
1418
- }
1419
-
1420
- function onPointerDown(ev) {
1421
- if (ev.button !== 0) return;
1422
- const cardEl = ev.target.closest ? ev.target.closest('.mtx-card') : null;
1423
- if (ev.target.closest && ev.target.closest('.mtx-tool,.mtx-link')) return;
1424
- if (cardEl) {
1425
- const id = cardEl.getAttribute('data-id');
1426
- const s = springs.current.get(id);
1427
- if (!s) return;
1428
- dragRef.current = { kind: 'node', id: id, moved: false, sx: ev.clientX, sy: ev.clientY, ox: s.x, oy: s.y, el: cardEl };
1429
- } else {
1430
- const view = viewRef.current;
1431
- dragRef.current = { kind: 'pan', moved: false, sx: ev.clientX, sy: ev.clientY, ox: view.x, oy: view.y };
1432
- graphRef.current.setAttribute('data-panning', '');
1433
- }
1434
- try { ev.currentTarget.setPointerCapture(ev.pointerId); } catch (e) {}
1435
- }
1436
-
1437
- function onPointerMove(ev) {
1438
- const d = dragRef.current;
1439
- if (!d) return;
1440
- const dx = ev.clientX - d.sx;
1441
- const dy = ev.clientY - d.sy;
1442
- if (!d.moved && Math.abs(dx) + Math.abs(dy) > 5) {
1443
- d.moved = true;
1444
- if (d.kind === 'node') d.el.setAttribute('data-dragging', '');
1445
- }
1446
- if (!d.moved) return;
1447
- if (d.kind === 'pan') {
1448
- viewRef.current.x = d.ox + dx;
1449
- viewRef.current.y = d.oy + dy;
1450
- applyView();
1451
- } else {
1452
- const s = springs.current.get(d.id);
1453
- const sc = viewRef.current.scale;
1454
- if (s) { s.x = d.ox + dx / sc; s.y = d.oy + dy / sc; s.vx = 0; s.vy = 0; renderFrame(); }
1455
- }
1456
- }
1457
-
1458
- function onPointerUp() {
1459
- const d = dragRef.current;
1460
- dragRef.current = null;
1461
- if (graphRef.current) graphRef.current.removeAttribute('data-panning');
1462
- if (!d) return;
1463
- if (d.kind === 'node') {
1464
- d.el.removeAttribute('data-dragging');
1465
- if (d.moved) kick();
1466
- else openVersion(d.id);
1467
- }
1468
- }
1469
-
1470
- function cardTitle(n) {
1471
- if (n.deleted) return t('deletedVersion');
1472
- if (n.isRoot) return t('original');
1473
- if (n.operation === 'edit') return t('edited', { turn: n.turn });
1474
- if (n.operation === 'retry') return t('retried', { turn: n.turn });
1475
- return t('turn', { turn: n.turn });
1476
- }
1477
-
1478
- return React.createElement('div', {
1479
- className: 'mtx-graph',
1480
- ref: graphRef,
1481
- onPointerDown: onPointerDown,
1482
- onPointerMove: onPointerMove,
1483
- onPointerUp: onPointerUp,
1484
- onPointerCancel: onPointerUp,
1485
- },
1486
- React.createElement('div', { className: 'mtx-world', ref: worldRef },
1487
- React.createElement('svg', { className: 'mtx-edges' },
1488
- layout.edges.map(function (e) {
1489
- const key = e.from + '>' + e.to;
1490
- const a = springs.current.get(e.from) || layout.pos.get(e.from);
1491
- const b = springs.current.get(e.to) || layout.pos.get(e.to);
1492
- return React.createElement('path', {
1493
- key: key,
1494
- className: 'mtx-edge',
1495
- 'data-path': e.onPath || undefined,
1496
- d: a && b ? edgePath(a.x, a.y + 58, b.x, b.y) : undefined,
1497
- ref: function (el) { if (el) edgeEls.current.set(key, el); else edgeEls.current.delete(key); },
1498
- });
1499
- })
1500
- ),
1501
- layout.nodes.map(function (n) {
1502
- const s = springs.current.get(n.id) || layout.pos.get(n.id) || { x: 0, y: 0 };
1503
- const summary = titles[n.sessionId];
1504
- const sub = (n.archived ? t('archivedTag') + ' · ' : '')
1505
- + (n.text ? '“' + clip(n.text, 44) + '” · ' : '')
1506
- + (n.isRoot && !n.text && summary && summary.displayTitle ? clip(summary.displayTitle, 24) + ' · ' : '')
1507
- + timeLabel(n.time);
1508
- return React.createElement('div', {
1509
- key: n.id,
1510
- className: 'mtx-card',
1511
- 'data-id': n.id,
1512
- 'data-current': n.current || undefined,
1513
- 'data-path': n.onCurrentPath || undefined,
1514
- 'data-deleted': n.deleted || undefined,
1515
- 'data-archived': n.archived || undefined,
1516
- style: { transform: 'translate(' + (s.x - CARD_W / 2) + 'px,' + s.y + 'px)' },
1517
- ref: function (el) { if (el) cardEls.current.set(n.id, el); else cardEls.current.delete(n.id); },
1518
- },
1519
- React.createElement('span', { className: 'mtx-card-icon' },
1520
- n.deleted ? '' : n.isRoot ? '●' : (n.operation === 'retry' ? '↻' : (n.operation === 'edit' ? '✎' : '💬'))),
1521
- React.createElement('span', { className: 'mtx-card-main' },
1522
- React.createElement('span', { className: 'mtx-card-title' }, cardTitle(n)),
1523
- React.createElement('span', { className: 'mtx-card-sub' }, sub)
1524
- )
1525
- );
1526
- })
1527
- ),
1528
- React.createElement('div', { className: 'mtx-graph-tools' },
1529
- React.createElement('button', {
1530
- type: 'button', className: 'mtx-tool', title: t('fit'),
1531
- onClick: function () { fitView(); },
1532
- }, '⌖'),
1533
- React.createElement('button', {
1534
- type: 'button', className: 'mtx-tool', title: t('refresh'),
1535
- onClick: function () { treeStore.load(sessionId); },
1536
- }, '')
1537
- ),
1538
- tree && tree.error ? React.createElement('div', { className: 'mtx-error' }, tree.error) : null,
1539
- turnNodes.length <= 1 ? React.createElement('div', { className: 'mtx-empty' }, t('empty')) : null,
1540
- React.createElement('a', {
1541
- className: 'mtx-link',
1542
- href: 'https://github.com/SpookySandwich/dsh-plugin-message-edit',
1543
- target: '_blank', rel: 'noreferrer',
1544
- }, 'GitHub ↗')
1545
- );
1546
- }
1547
-
1548
- // Settings: pick the edit-interface style, with a live preview that
1549
- // renders in the currently-selected look.
1550
- function Toggle(props) {
1551
- return React.createElement(React.Fragment, null,
1552
- React.createElement('div', { className: 'mtx-set-row' },
1553
- React.createElement('span', { className: 'mtx-set-label' }, props.label),
1554
- React.createElement('input', {
1555
- type: 'checkbox', checked: props.checked, onChange: props.onChange,
1556
- })
1557
- ),
1558
- React.createElement('div', { className: 'mtx-set-hint' }, props.hint)
1559
- );
1560
- }
1561
-
1562
- function StyleSettings() {
1563
- const style = useStyle();
1564
- const prefs = usePrefs();
1565
- return React.createElement('div', { className: 'mtx-set' },
1566
- React.createElement('div', { className: 'mtx-set-row' },
1567
- React.createElement('span', { className: 'mtx-set-label' }, t('styleLabel')),
1568
- React.createElement('select', {
1569
- className: 'mtx-select', value: style,
1570
- onChange: function (e) { styleStore.set(e.target.value); },
1571
- },
1572
- STYLES.map(function (s) {
1573
- return React.createElement('option', { key: s, value: s }, t('style_' + s));
1574
- })
1575
- )
1576
- ),
1577
- React.createElement('div', { className: 'mtx-set-hint' }, t('styleDesc_' + style)),
1578
- React.createElement(Toggle, {
1579
- label: t('rememberPathLabel'),
1580
- hint: t('rememberPathHint'),
1581
- checked: prefs.rememberPath,
1582
- onChange: function (e) { prefsStore.set({ rememberPath: e.target.checked }); },
1583
- }),
1584
- React.createElement(Toggle, {
1585
- label: t('stopOnEditLabel'),
1586
- hint: t('stopOnEditHint'),
1587
- checked: prefs.stopOnEdit,
1588
- onChange: function (e) { prefsStore.set({ stopOnEdit: e.target.checked }); },
1589
- }),
1590
- React.createElement('div', { className: 'mtx-preview' },
1591
- React.createElement('div', { className: 'mtx-row' },
1592
- React.createElement('div', { className: 'mtx-line' },
1593
- React.createElement('div', { className: 'mtx-bubble' }, t('previewUser'))
1594
- ),
1595
- React.createElement('div', { className: 'mtx-actions' },
1596
- React.createElement('div', { className: 'mtx-ring' },
1597
- React.createElement('button', { type: 'button', disabled: true }, '‹'),
1598
- React.createElement('span', null, '2/3'),
1599
- React.createElement('button', { type: 'button', disabled: true }, '›')
1600
- ),
1601
- React.createElement('span', { className: 'mtx-act', 'data-act': 'retry' }, RetryIcon()),
1602
- React.createElement('span', { className: 'mtx-act', 'data-act': 'edit' }, PencilIcon()),
1603
- React.createElement('span', { className: 'mtx-act', 'data-act': 'copy' }, CopyIcon())
1604
- )
1605
- ),
1606
- React.createElement('div', { className: 'mtx-editor' },
1607
- React.createElement('div', { className: 'mtx-textarea' }, t('previewUser')),
1608
- React.createElement('div', { className: 'mtx-editor-actions' },
1609
- React.createElement('span', { className: 'mtx-btn' }, t('cancel')),
1610
- React.createElement('span', { className: 'mtx-btn', 'data-primary': '' }, t('send'))
1611
- )
1612
- ),
1613
- React.createElement('div', { className: 'mtx-editor-outside' },
1614
- React.createElement('span', { className: 'mtx-btn' }, t('cancel')),
1615
- React.createElement('span', { className: 'mtx-btn', 'data-primary': '' }, t('save'))
1616
- )
1617
- ),
1618
- React.createElement('a', {
1619
- className: 'mtx-set-link',
1620
- href: 'https://github.com/SpookySandwich/dsh-plugin-message-edit',
1621
- target: '_blank', rel: 'noreferrer',
1622
- }, 'GitHub ')
1623
- );
1624
- }
1625
-
1626
- try {
1627
- slots.inject('settings.section', function () {
1628
- return slots.register(
1629
- { name: 'settings.section', id: 'message-tree', order: 210, label: function () { return t('nav'); } },
1630
- StyleSettings
1631
- );
1632
- });
1633
- } catch (e) {}
1634
-
1635
- // Shadow only the plain user bubble; steering and context rows keep the
1636
- // host renderer. A collision with another user-bubble plugin degrades to
1637
- // "they win" rather than failing this plugin's other registrations.
1638
- slots.inject('conversation.chat.node', function () {
1639
- try {
1640
- return slots.register(
1641
- { name: 'conversation.chat.node', key: 'user', priority: -1 },
1642
- UserMessageView
1643
- );
1644
- } catch (e) {
1645
- return function () {};
1646
- }
1647
- });
1648
-
1649
- slots.inject('conversation.view', function () {
1650
- return slots.register(
1651
- {
1652
- name: 'conversation.view',
1653
- id: 'message-tree',
1654
- order: VIEW_ORDER,
1655
- label: function () { return t('view'); },
1656
- inject: function (sessionId) { return { sessionId: sessionId }; },
1657
- },
1658
- VersionsView
1659
- );
1660
- });
1661
- }
1662
- };
21
+ // dsh-plugin-message-tree — client half.
22
+ //
23
+ // Mimics ChatGPT's edit-message behavior: hover a past prompt to edit it,
24
+ // sending branches the conversation from that point (the host half performs
25
+ // the true rewind); ‹ 2/3 › switches between versions of the same message;
26
+ // a Versions view draws the whole tree.
27
+
28
+ // Route, CSS prefix and storage keys keep the `message-tree` spelling even
29
+ // though the package is dsh-plugin-message-edit: Moeblack's dsh-message-edit
30
+ // owns the `message-edit` names, and colliding would break both plugins when
31
+ // installed together. See lib/index.js for the full note.
32
+ const ROUTE = '/message-tree';
33
+ const VIEW_ORDER = 16;
34
+
35
+ function realGlobal() {
36
+ try { if (typeof window !== 'undefined' && window) return window; } catch (e) {}
37
+ try { if (typeof globalThis !== 'undefined' && globalThis) return globalThis; } catch (e) {}
38
+ return null;
39
+ }
40
+
41
+ /* ------------------------------------------------------------- edit style -- */
42
+
43
+ // Which provider's message-edit LAYOUT to follow. All three put the controls
44
+ // below the bubble; what differs is which controls exist (only Claude offers
45
+ // retry), whether they wait for hover (ChatGPT and Claude) or stay visible
46
+ // (DeepSeek, like DSH itself), and whether the editor's Cancel/confirm sit
47
+ // inside the box or below it. Colours stay native in every preset. The choice
48
+ // is one attribute on <html>, so the stylesheet keys off it and switching
49
+ // takes effect live.
50
+ const STYLE_KEY = 'dsh-plugin-message-tree:style';
51
+ const STYLES = ['chatgpt', 'deepseek', 'claude'];
52
+ const DEFAULT_STYLE = 'chatgpt';
53
+
54
+ const styleStore = {
55
+ value: null,
56
+ listeners: [],
57
+ get() {
58
+ if (this.value === null) {
59
+ const g = realGlobal();
60
+ let stored = null;
61
+ try { stored = g && g.localStorage && g.localStorage.getItem(STYLE_KEY); } catch (e) {}
62
+ this.value = STYLES.indexOf(stored) !== -1 ? stored : DEFAULT_STYLE;
63
+ }
64
+ return this.value;
65
+ },
66
+ set(next) {
67
+ this.value = STYLES.indexOf(next) !== -1 ? next : DEFAULT_STYLE;
68
+ const g = realGlobal();
69
+ try { if (g && g.localStorage) g.localStorage.setItem(STYLE_KEY, this.value); } catch (e) {}
70
+ syncStyleAttribute();
71
+ for (let i = 0; i < this.listeners.length; i++) {
72
+ try { this.listeners[i](); } catch (e) {}
73
+ }
74
+ },
75
+ subscribe(fn) {
76
+ const listeners = this.listeners;
77
+ listeners.push(fn);
78
+ return function () {
79
+ const at = listeners.indexOf(fn);
80
+ if (at !== -1) listeners.splice(at, 1);
81
+ };
82
+ },
83
+ };
84
+
85
+ function syncStyleAttribute() {
86
+ const g = realGlobal();
87
+ const root = g && g.document && g.document.documentElement;
88
+ if (root) root.setAttribute('data-mtx-style', styleStore.get());
89
+ }
90
+
91
+ /* ----------------------------------------------------------- active path -- */
92
+
93
+ // A version IS a whole session, so "which version am I looking at" is just
94
+ // "which session is open". Reopening a conversation lands on whichever session
95
+ // the sidebar points at — normally the family root — so a branch you had
96
+ // selected is silently dropped and the ring snaps back to 1/N.
97
+ //
98
+ // Remember the last session viewed for each family, keyed by the family's root,
99
+ // and restore it when you land back on that root. Recording happens for every
100
+ // family member you view, so walking the ring back to the root records the root
101
+ // and the restore then correctly does nothing (no ping-pong).
102
+ const PATH_KEY = 'dsh-plugin-message-tree:active-path';
103
+ const PATH_LIMIT = 200;
104
+
105
+ const activePathStore = {
106
+ map: null,
107
+ read() {
108
+ if (this.map === null) {
109
+ let parsed = null;
110
+ try {
111
+ const g = realGlobal();
112
+ const raw = g && g.localStorage && g.localStorage.getItem(PATH_KEY);
113
+ parsed = raw ? JSON.parse(raw) : null;
114
+ } catch (e) {}
115
+ this.map = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
116
+ }
117
+ return this.map;
118
+ },
119
+ get(rootId) {
120
+ if (!rootId) return undefined;
121
+ const v = this.read()[rootId];
122
+ return typeof v === 'string' ? v : undefined;
123
+ },
124
+ set(rootId, sessionId) {
125
+ if (!rootId || !sessionId) return;
126
+ const map = this.read();
127
+ if (map[rootId] === sessionId) return;
128
+ map[rootId] = sessionId;
129
+ // Bound the map so a long-lived profile cannot grow it without limit.
130
+ // Object key order is insertion order for string keys, so the oldest
131
+ // entries are at the front.
132
+ const keys = Object.keys(map);
133
+ if (keys.length > PATH_LIMIT) {
134
+ for (let i = 0; i < keys.length - PATH_LIMIT; i++) delete map[keys[i]];
135
+ }
136
+ try {
137
+ const g = realGlobal();
138
+ if (g && g.localStorage) g.localStorage.setItem(PATH_KEY, JSON.stringify(map));
139
+ } catch (e) {}
140
+ },
141
+ };
142
+
143
+ /** The family root for `sessionId`: walk parents until one has none. */
144
+ function rootOf(versions, sessionId) {
145
+ if (!versions || sessionId === undefined) return undefined;
146
+ const byId = new Map(versions.map(function (v) { return [v.sessionId, v]; }));
147
+ let cursor = byId.get(sessionId);
148
+ if (!cursor) return undefined;
149
+ const seen = new Set();
150
+ while (cursor.parentSessionId && !seen.has(cursor.sessionId)) {
151
+ seen.add(cursor.sessionId);
152
+ const parent = byId.get(cursor.parentSessionId);
153
+ if (!parent) break;
154
+ cursor = parent;
155
+ }
156
+ return cursor.sessionId;
157
+ }
158
+
159
+ // Families already restored in this page load. Without this the restore would
160
+ // re-fire on every re-render and fight a deliberate walk back to the root.
161
+ const restoredFamilies = new Set();
162
+ // Restores that have been triggered but whose navigation has not landed yet.
163
+ // While a root is in here we must not record it as the selection.
164
+ const pendingRestore = new Set();
165
+
166
+ /* --------------------------------------------------------------- prefs -- */
167
+
168
+ // Behaviour toggles, persisted next to the style choice. Both default to the
169
+ // behaviour the user asked for rather than the old one.
170
+ const PREFS_KEY = 'dsh-plugin-message-tree:prefs';
171
+ const PREFS_DEFAULTS = {
172
+ // Restore the last-viewed branch when reopening a conversation.
173
+ rememberPath: true,
174
+ // Cancel a still-running turn before an edit forks the conversation.
175
+ stopOnEdit: true,
176
+ };
177
+
178
+ const prefsStore = {
179
+ value: null,
180
+ listeners: [],
181
+ get() {
182
+ if (this.value === null) {
183
+ let parsed = null;
184
+ try {
185
+ const g = realGlobal();
186
+ const raw = g && g.localStorage && g.localStorage.getItem(PREFS_KEY);
187
+ parsed = raw ? JSON.parse(raw) : null;
188
+ } catch (e) {}
189
+ const out = {};
190
+ for (const k in PREFS_DEFAULTS) {
191
+ out[k] = parsed && typeof parsed[k] === 'boolean' ? parsed[k] : PREFS_DEFAULTS[k];
192
+ }
193
+ this.value = out;
194
+ }
195
+ return this.value;
196
+ },
197
+ set(patch) {
198
+ const next = Object.assign({}, this.get(), patch);
199
+ this.value = next;
200
+ try {
201
+ const g = realGlobal();
202
+ if (g && g.localStorage) g.localStorage.setItem(PREFS_KEY, JSON.stringify(next));
203
+ } catch (e) {}
204
+ for (let i = 0; i < this.listeners.length; i++) {
205
+ try { this.listeners[i](); } catch (e) {}
206
+ }
207
+ },
208
+ subscribe(fn) {
209
+ const listeners = this.listeners;
210
+ listeners.push(fn);
211
+ return function () {
212
+ const at = listeners.indexOf(fn);
213
+ if (at !== -1) listeners.splice(at, 1);
214
+ };
215
+ },
216
+ };
217
+
218
+ function usePrefs() {
219
+ const [, force] = React.useReducer(function (x) { return x + 1; }, 0);
220
+ React.useEffect(function () { return prefsStore.subscribe(force); }, []);
221
+ return prefsStore.get();
222
+ }
223
+
224
+ function useStyle() {
225
+ const [, force] = React.useReducer(function (x) { return x + 1; }, 0);
226
+ React.useEffect(function () { return styleStore.subscribe(force); }, []);
227
+ return styleStore.get();
228
+ }
229
+
230
+ /* ------------------------------------------------------- timeline store -- */
231
+
232
+ const MAX_CACHED_SESSIONS = 500;
233
+ const MAX_CACHED_ROOTS = 50;
234
+
235
+ // High-performance family-aware tree cache with zero-flicker Stale-While-Revalidate.
236
+ const treeStore = {
237
+ bySession: new Map(),
238
+ byRoot: new Map(),
239
+ inflight: new Map(),
240
+ listeners: [],
241
+
242
+ get(sessionId) {
243
+ if (!sessionId) return null;
244
+ return this.bySession.get(sessionId) || null;
245
+ },
246
+
247
+ notify() {
248
+ for (let i = 0; i < this.listeners.length; i++) {
249
+ try { this.listeners[i](); } catch (e) {}
250
+ }
251
+ },
252
+
253
+ subscribe(fn) {
254
+ const listeners = this.listeners;
255
+ listeners.push(fn);
256
+ return function () {
257
+ const at = listeners.indexOf(fn);
258
+ if (at !== -1) listeners.splice(at, 1);
259
+ };
260
+ },
261
+
262
+ _prune() {
263
+ while (this.bySession.size > MAX_CACHED_SESSIONS) {
264
+ const oldestKey = this.bySession.keys().next().value;
265
+ this.bySession.delete(oldestKey);
266
+ }
267
+ while (this.byRoot.size > MAX_CACHED_ROOTS) {
268
+ const oldestKey = this.byRoot.keys().next().value;
269
+ this.byRoot.delete(oldestKey);
270
+ }
271
+ },
272
+
273
+ setTree(sessionId, versions, timestamp) {
274
+ if (!Array.isArray(versions)) versions = [];
275
+ const rootId = rootOf(versions, sessionId) || sessionId;
276
+ const updatedAt = typeof timestamp === 'number' ? timestamp : Date.now();
277
+ const existingRoot = this.byRoot.get(rootId);
278
+ if (existingRoot && (existingRoot.updatedAt || 0) > updatedAt) {
279
+ return;
280
+ }
281
+
282
+ const entry = { versions: versions, rootId: rootId, loading: false, error: null, updatedAt: updatedAt };
283
+ this.byRoot.set(rootId, entry);
284
+
285
+ for (let i = 0; i < versions.length; i++) {
286
+ const v = versions[i];
287
+ if (v && v.sessionId && !v.deleted) {
288
+ this.bySession.set(v.sessionId, entry);
289
+ }
290
+ }
291
+ this.bySession.set(sessionId, entry);
292
+ this._prune();
293
+ this.notify();
294
+ },
295
+
296
+ async load(sessionId) {
297
+ if (!sessionId) return;
298
+ const g = realGlobal();
299
+ if (!g || typeof g.fetch !== 'function') return;
300
+
301
+ if (this.inflight.has(sessionId)) return this.inflight.get(sessionId);
302
+
303
+ const existing = this.bySession.get(sessionId);
304
+ const reqTime = Date.now();
305
+ if (existing) {
306
+ this.bySession.set(sessionId, Object.assign({}, existing, { loading: true }));
307
+ } else {
308
+ this.bySession.set(sessionId, { versions: null, loading: true, error: null, updatedAt: 0 });
309
+ }
310
+
311
+ const self = this;
312
+ const promise = (async function () {
313
+ try {
314
+ const res = await g.fetch(ROUTE + '?sessionId=' + encodeURIComponent(sessionId), { cache: 'no-store' });
315
+ if (!res.ok) throw new Error('HTTP ' + res.status);
316
+ const data = await res.json();
317
+ self.setTree(sessionId, data.versions, reqTime);
318
+ } catch (e) {
319
+ const errStr = String((e && e.message) || e);
320
+ const prev = self.bySession.get(sessionId);
321
+ self.bySession.set(sessionId, {
322
+ versions: prev ? prev.versions : null,
323
+ loading: false,
324
+ error: errStr,
325
+ updatedAt: prev ? prev.updatedAt : 0,
326
+ });
327
+ self.notify();
328
+ } finally {
329
+ self.inflight.delete(sessionId);
330
+ }
331
+ })();
332
+
333
+ this.inflight.set(sessionId, promise);
334
+ return promise;
335
+ },
336
+
337
+ ensure(sessionId) {
338
+ if (!sessionId) return;
339
+ const entry = this.bySession.get(sessionId);
340
+ if (!entry || !entry.versions) {
341
+ this.load(sessionId);
342
+ } else if (Date.now() - (entry.updatedAt || 0) > 8000 && !entry.loading) {
343
+ this.load(sessionId);
344
+ }
345
+ },
346
+
347
+ invalidate(sessionId) {
348
+ if (sessionId) {
349
+ const entry = this.bySession.get(sessionId);
350
+ if (entry && entry.rootId) {
351
+ const rootEntry = this.byRoot.get(entry.rootId);
352
+ if (rootEntry) rootEntry.updatedAt = 0;
353
+ }
354
+ this.load(sessionId);
355
+ } else {
356
+ this.bySession.forEach(function (e) { if (e) e.updatedAt = 0; });
357
+ this.byRoot.forEach(function (e) { if (e) e.updatedAt = 0; });
358
+ this.notify();
359
+ }
360
+ },
361
+ };
362
+
363
+ function useTree(sessionId) {
364
+ const [, force] = React.useReducer(function (x) { return x + 1; }, 0);
365
+ React.useEffect(function () { return treeStore.subscribe(force); }, []);
366
+ React.useEffect(function () { if (sessionId) treeStore.ensure(sessionId); }, [sessionId]);
367
+ return sessionId ? treeStore.get(sessionId) : null;
368
+ }
369
+
370
+ /**
371
+ * The ‹ › ring for the message at `turn` while viewing `sessionId`.
372
+ *
373
+ * Versions are whole sessions: an edit creates a child rewound to before the
374
+ * turn. Walking up from the current session, sessions whose edit targets a
375
+ * LATER turn still inherit this one, so they are skipped; landing on a
376
+ * session that targets exactly this turn means we are viewing one of its
377
+ * alternatives, whose original lives in that session's parent.
378
+ */
379
+ function ringFor(versions, sessionId, turn) {
380
+ if (!versions) return null;
381
+ const byId = new Map(versions.map(function (v) { return [v.sessionId, v]; }));
382
+ let cursor = byId.get(sessionId);
383
+ if (!cursor) return null;
384
+ while (cursor.parentSessionId && typeof cursor.targetTurn === 'number' && cursor.targetTurn > turn) {
385
+ const parent = byId.get(cursor.parentSessionId);
386
+ if (!parent) break;
387
+ cursor = parent;
388
+ }
389
+ let fork = cursor;
390
+ while (fork.parentSessionId && typeof fork.targetTurn === 'number' && fork.targetTurn === turn) {
391
+ const parent = byId.get(fork.parentSessionId);
392
+ if (!parent) break;
393
+ fork = parent;
394
+ }
395
+ function walksToFork(start) {
396
+ let x = start;
397
+ const seen = new Set();
398
+ while (x && !seen.has(x.sessionId)) {
399
+ seen.add(x.sessionId);
400
+ if (x.sessionId === fork.sessionId) return true;
401
+ if (typeof x.targetTurn !== 'number' || x.targetTurn !== turn) return false;
402
+ x = x.parentSessionId ? byId.get(x.parentSessionId) : null;
403
+ }
404
+ return false;
405
+ }
406
+ // A deleted (ghost) version still anchors the fork and still bridges the
407
+ // parent walks above, but it cannot be opened, so it never appears among
408
+ // the alternatives: the ring renumbers over the survivors.
409
+ const alternatives = versions
410
+ .filter(function (v) {
411
+ return !v.deleted && (v.sessionId === fork.sessionId || (v.targetTurn === turn && walksToFork(v)));
412
+ })
413
+ .sort(function (a, b) {
414
+ return a.createdAt - b.createdAt || String(a.sessionId).localeCompare(String(b.sessionId));
415
+ });
416
+ if (alternatives.length < 2) return null;
417
+ let index = alternatives.findIndex(function (v) { return v.sessionId === cursor.sessionId; });
418
+ if (index === -1) index = alternatives.findIndex(function (v) { return v.sessionId === sessionId; });
419
+ if (index === -1) index = 0;
420
+ return { alternatives: alternatives, index: index };
421
+ }
422
+
423
+ /* ------------------------------------------------------------ mutations -- */
424
+
425
+ /**
426
+ * Open a version, unarchiving it first when needed. The app cannot navigate
427
+ * to an archived session (it bounces to the workspace picker), so an archived
428
+ * target is activated through the host route before opening. Ghosts (deleted
429
+ * versions) are never openable.
430
+ */
431
+ async function openVersionTarget(sessions, v) {
432
+ if (!v || v.deleted || !sessions) return;
433
+ if (v.archived) {
434
+ try {
435
+ await mutate({ action: 'activate', sessionId: v.sessionId });
436
+ treeStore.invalidate();
437
+ } catch (e) {}
438
+ }
439
+ openWhenListed(sessions, v.sessionId);
440
+ }
441
+
442
+ function openWhenListed(sessions, sessionId) {
443
+ const list = sessions.list;
444
+ if (!list || typeof list.getSnapshot !== 'function') { sessions.open(sessionId); return; }
445
+ if (list.getSnapshot().byId[sessionId] !== undefined) { sessions.open(sessionId); return; }
446
+ const stop = list.subscribe(function () {
447
+ if (list.getSnapshot().byId[sessionId] !== undefined) {
448
+ stop();
449
+ sessions.open(sessionId);
450
+ }
451
+ });
452
+ }
453
+
454
+ async function mutate(operation) {
455
+ const g = realGlobal();
456
+ const res = await g.fetch(ROUTE, {
457
+ method: 'POST',
458
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
459
+ body: JSON.stringify(operation),
460
+ });
461
+ const body = await res.json().catch(function () { return {}; });
462
+ if (!res.ok) throw new Error(body.error || ('HTTP ' + res.status));
463
+ return body;
464
+ }
465
+
466
+ /* ---------------------------------------------------------------- utils -- */
467
+
468
+ function contentText(content) {
469
+ if (!Array.isArray(content)) return '';
470
+ let out = '';
471
+ for (let i = 0; i < content.length; i++) {
472
+ const block = content[i];
473
+ if (block && block.type === 'text' && typeof block.text === 'string') {
474
+ out += (out ? '\n' : '') + block.text;
475
+ }
476
+ }
477
+ return out;
478
+ }
479
+
480
+ function firstTextBlockIndex(content) {
481
+ if (!Array.isArray(content)) return -1;
482
+ for (let i = 0; i < content.length; i++) {
483
+ if (content[i] && content[i].type === 'text') return i;
484
+ }
485
+ return -1;
486
+ }
487
+
488
+ function imageCount(content) {
489
+ if (!Array.isArray(content)) return 0;
490
+ let n = 0;
491
+ for (let i = 0; i < content.length; i++) {
492
+ if (content[i] && content[i].type === 'image') n += 1;
493
+ }
494
+ return n;
495
+ }
496
+
497
+ function imageParts(content) {
498
+ if (!Array.isArray(content)) return [];
499
+ const out = [];
500
+ for (let i = 0; i < content.length; i++) {
501
+ const block = content[i];
502
+ if (block && block.type === 'image' && block.attachment) out.push({ attachment: block.attachment });
503
+ }
504
+ return out;
505
+ }
506
+
507
+ function clip(text, max) {
508
+ const t = String(text).replace(/\s+/g, ' ').trim();
509
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
510
+ }
511
+
512
+ function timeLabel(ms) {
513
+ try {
514
+ const d = new Date(ms);
515
+ const p = function (n) { return n < 10 ? '0' + n : String(n); };
516
+ return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
517
+ } catch (e) {
518
+ return '';
519
+ }
520
+ }
521
+
522
+ /* ---------------------------------------------------------- graph layout -- */
523
+
524
+ const CARD_W = 176;
525
+ const SLOT_X = 206;
526
+ const SLOT_Y = 132;
527
+
528
+ /**
529
+ * Project conversation family versions into a turn-level branching tree.
530
+ */
531
+ function buildTurnTree(versions, currentSessionId) {
532
+ if (!versions || versions.length === 0) return [];
533
+ const byId = new Map(versions.map(function (v) { return [v.sessionId, v]; }));
534
+
535
+ let rootVersion = versions.find(function (v) { return !v.parentSessionId; });
536
+ if (!rootVersion) {
537
+ const rootId = rootOf(versions, currentSessionId) || (versions[0] && versions[0].sessionId);
538
+ rootVersion = (rootId && byId.get(rootId)) || versions[0];
539
+ }
540
+ const rootSessionId = rootVersion.sessionId;
541
+
542
+ const activeSessionPath = new Set();
543
+ let cursor = byId.get(currentSessionId);
544
+ const seenSessions = new Set();
545
+ while (cursor && !seenSessions.has(cursor.sessionId)) {
546
+ seenSessions.add(cursor.sessionId);
547
+ activeSessionPath.add(cursor.sessionId);
548
+ cursor = cursor.parentSessionId ? byId.get(cursor.parentSessionId) : null;
549
+ }
550
+
551
+ const nodes = [];
552
+ const rootNodeId = rootSessionId + '#root';
553
+ const nodeMap = new Map();
554
+
555
+ const rootNode = {
556
+ id: rootNodeId,
557
+ sessionId: rootSessionId,
558
+ turn: 0,
559
+ isRoot: true,
560
+ time: rootVersion.createdAt || 0,
561
+ current: currentSessionId === rootSessionId && (!rootVersion.turns || rootVersion.turns.length === 0),
562
+ onCurrentPath: true,
563
+ deleted: !!rootVersion.deleted,
564
+ archived: !!rootVersion.archived,
565
+ };
566
+ nodes.push(rootNode);
567
+ nodeMap.set(rootNodeId, rootNode);
568
+
569
+ function findParentTurnNodeId(v, turn) {
570
+ if (!v.parentSessionId) {
571
+ if (turn === 1) return rootNodeId;
572
+ return v.sessionId + '#t' + (turn - 1);
573
+ }
574
+ if (turn === v.targetTurn) {
575
+ if (v.targetTurn === 1) return rootNodeId;
576
+ return v.parentSessionId + '#t' + (v.targetTurn - 1);
577
+ }
578
+ return v.sessionId + '#t' + (turn - 1);
579
+ }
580
+
581
+ for (let i = 0; i < versions.length; i++) {
582
+ const v = versions[i];
583
+ const isCurrentSession = v.sessionId === currentSessionId;
584
+ const turns = Array.isArray(v.turns) && v.turns.length > 0 ? v.turns : [];
585
+
586
+ if (!v.parentSessionId) {
587
+ for (let j = 0; j < turns.length; j++) {
588
+ const t = turns[j];
589
+ const turnNum = t.turn;
590
+ const turnNodeId = v.sessionId + '#t' + turnNum;
591
+ const parentId = findParentTurnNodeId(v, turnNum);
592
+ const node = {
593
+ id: turnNodeId,
594
+ sessionId: v.sessionId,
595
+ turn: turnNum,
596
+ parentId: parentId,
597
+ time: t.time || v.createdAt,
598
+ text: t.text || '',
599
+ current: isCurrentSession,
600
+ onCurrentPath: false,
601
+ deleted: !!v.deleted,
602
+ archived: !!v.archived,
603
+ };
604
+ nodes.push(node);
605
+ nodeMap.set(turnNodeId, node);
606
+ }
607
+ } else {
608
+ const targetTurn = typeof v.targetTurn === 'number' ? v.targetTurn : 1;
609
+ const ownTurns = turns.filter(function (t) { return t.turn >= targetTurn; });
610
+
611
+ if (ownTurns.length === 0) {
612
+ const turnNodeId = v.sessionId + '#t' + targetTurn;
613
+ const parentId = findParentTurnNodeId(v, targetTurn);
614
+ const node = {
615
+ id: turnNodeId,
616
+ sessionId: v.sessionId,
617
+ turn: targetTurn,
618
+ parentId: parentId,
619
+ operation: v.operation || 'edit',
620
+ text: v.after || v.before || '',
621
+ time: v.createdAt || 0,
622
+ current: isCurrentSession,
623
+ onCurrentPath: false,
624
+ deleted: !!v.deleted,
625
+ archived: !!v.archived,
626
+ };
627
+ nodes.push(node);
628
+ nodeMap.set(turnNodeId, node);
629
+ } else {
630
+ for (let j = 0; j < ownTurns.length; j++) {
631
+ const t = ownTurns[j];
632
+ const turnNum = t.turn;
633
+ const turnNodeId = v.sessionId + '#t' + turnNum;
634
+ const parentId = findParentTurnNodeId(v, turnNum);
635
+ const isForkTurn = turnNum === targetTurn;
636
+ const node = {
637
+ id: turnNodeId,
638
+ sessionId: v.sessionId,
639
+ turn: turnNum,
640
+ parentId: parentId,
641
+ operation: isForkTurn ? v.operation : undefined,
642
+ text: t.text || (isForkTurn ? (v.after || v.before || '') : ''),
643
+ time: t.time || v.createdAt,
644
+ current: isCurrentSession,
645
+ onCurrentPath: false,
646
+ deleted: !!v.deleted,
647
+ archived: !!v.archived,
648
+ };
649
+ nodes.push(node);
650
+ nodeMap.set(turnNodeId, node);
651
+ }
652
+ }
653
+ }
654
+ }
655
+
656
+ const allIds = new Set(nodes.map(function (n) { return n.id; }));
657
+ for (let i = 0; i < nodes.length; i++) {
658
+ if (nodes[i].parentId && !allIds.has(nodes[i].parentId)) {
659
+ nodes[i].parentId = rootNodeId;
660
+ }
661
+ }
662
+
663
+ const activePathIds = new Set();
664
+ let latestNode = null;
665
+ for (let i = 0; i < nodes.length; i++) {
666
+ const n = nodes[i];
667
+ if (n.sessionId === currentSessionId) {
668
+ if (!latestNode || (n.turn || 0) >= (latestNode.turn || 0)) {
669
+ latestNode = n;
670
+ }
671
+ }
672
+ }
673
+ let pathCursor = latestNode || nodes[0];
674
+ const seenPath = new Set();
675
+ while (pathCursor && !seenPath.has(pathCursor.id)) {
676
+ seenPath.add(pathCursor.id);
677
+ activePathIds.add(pathCursor.id);
678
+ pathCursor = pathCursor.parentId ? nodeMap.get(pathCursor.parentId) : null;
679
+ }
680
+ activePathIds.add(rootNodeId);
681
+
682
+ for (let i = 0; i < nodes.length; i++) {
683
+ nodes[i].onCurrentPath = activePathIds.has(nodes[i].id);
684
+ }
685
+
686
+ return nodes;
687
+ }
688
+
689
+ /**
690
+ * Tidy tree layout for turn nodes: leaves claim successive horizontal slots,
691
+ * parents center over their children, siblings ordered by creation time.
692
+ */
693
+ function layoutTurnTree(nodes) {
694
+ const byId = new Map(nodes.map(function (n) { return [n.id, n]; }));
695
+ const children = new Map();
696
+ const roots = [];
697
+ for (let i = 0; i < nodes.length; i++) {
698
+ const n = nodes[i];
699
+ if (n.parentId && byId.has(n.parentId)) {
700
+ if (!children.has(n.parentId)) children.set(n.parentId, []);
701
+ children.get(n.parentId).push(n);
702
+ } else {
703
+ roots.push(n);
704
+ }
705
+ }
706
+ children.forEach(function (list) {
707
+ list.sort(function (a, b) { return (a.time || 0) - (b.time || 0) || String(a.id).localeCompare(String(b.id)); });
708
+ });
709
+ roots.sort(function (a, b) { return (a.time || 0) - (b.time || 0) || String(a.id).localeCompare(String(b.id)); });
710
+ const pos = new Map();
711
+ let cursor = 0;
712
+ function walk(n, depth) {
713
+ const kids = children.get(n.id) || [];
714
+ if (kids.length === 0) {
715
+ pos.set(n.id, { x: cursor * SLOT_X, y: depth * SLOT_Y });
716
+ cursor += 1;
717
+ return;
718
+ }
719
+ let lo = Infinity, hi = -Infinity;
720
+ for (let i = 0; i < kids.length; i++) {
721
+ walk(kids[i], depth + 1);
722
+ const p = pos.get(kids[i].id);
723
+ if (p.x < lo) lo = p.x;
724
+ if (p.x > hi) hi = p.x;
725
+ }
726
+ pos.set(n.id, { x: (lo + hi) / 2, y: depth * SLOT_Y });
727
+ }
728
+ for (let i = 0; i < roots.length; i++) walk(roots[i], 0);
729
+ const edges = [];
730
+ children.forEach(function (kids, parentId) {
731
+ for (let i = 0; i < kids.length; i++) {
732
+ edges.push({ from: parentId, to: kids[i].id, onPath: !!kids[i].onCurrentPath });
733
+ }
734
+ });
735
+ return { pos: pos, edges: edges, byId: byId, nodes: nodes };
736
+ }
737
+
738
+ function edgePath(x1, y1, x2, y2) {
739
+ const dy = Math.max(26, (y2 - y1) * 0.5);
740
+ return 'M' + x1 + ' ' + y1 + ' C' + x1 + ' ' + (y1 + dy) + ', ' + x2 + ' ' + (y2 - dy) + ', ' + x2 + ' ' + y2;
741
+ }
742
+
743
+ /** Bring the Chat view forward; the first conversation tab is always Chat. */
744
+ function showChat() {
745
+ const g = realGlobal();
746
+ if (!g || !g.document) return;
747
+ const tab = g.document.querySelector('[role=tab]');
748
+ if (tab && tab.getAttribute('aria-selected') !== 'true') tab.click();
749
+ }
750
+
751
+ /**
752
+ * After a graph click lands in a session, glide the chat to the version's own
753
+ * message and flash it. Polls because the session view mounts asynchronously.
754
+ */
755
+ function flashTurn(sessionId, turn, tries) {
756
+ const g = realGlobal();
757
+ if (!g || !g.document) return;
758
+ const el = g.document.querySelector(
759
+ '.mtx-row[data-session="' + sessionId + '"][data-turn="' + String(turn) + '"]');
760
+ if (el) {
761
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
762
+ el.classList.remove('mtx-flash');
763
+ void el.offsetWidth;
764
+ el.classList.add('mtx-flash');
765
+ return;
766
+ }
767
+ if (tries > 0) setTimeout(function () { flashTurn(sessionId, turn, tries - 1); }, 160);
768
+ }
769
+
770
+ /* ------------------------------------------------------------------ css -- */
771
+
772
+ const CSS = [
773
+ // User bubble replica: right-aligned rounded panel like the host's, with a
774
+ // hover-revealed edit control to its left, ChatGPT-style.
775
+ '.mtx-row{display:flex;flex-direction:column;align-items:flex-end;gap:6px}',
776
+ '.mtx-line{display:flex;align-items:flex-start;gap:8px;max-width:min(85%,720px)}',
777
+ '.mtx-edit-btn{flex:none;margin-top:8px;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:0;transition:opacity 120ms ease,background 120ms ease}',
778
+ '.mtx-row:hover .mtx-edit-btn{opacity:1}',
779
+ '.mtx-edit-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
780
+ '.mtx-bubble{background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,150,.14));border-radius:16px;padding:10px 16px;font-size:15px;line-height:26px;color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere}',
781
+ '.mtx-img{font-size:12px;color:var(--dsw-alias-label-tertiary);margin-top:4px}',
782
+
783
+ // Inline editor, ChatGPT-style: the bubble grows into an editing surface
784
+ // with Cancel / Send below-right.
785
+ '.mtx-editor{width:min(85%,720px);background:var(--dsw-alias-interactive-bg-hover,rgba(140,140,150,.14));border-radius:16px;padding:12px 16px;display:flex;flex-direction:column;gap:10px}',
786
+ '.mtx-textarea{width:100%;min-height:72px;resize:vertical;border:0;outline:none;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:15px;line-height:26px}',
787
+ '.mtx-editor-actions{display:flex;justify-content:flex-end;gap:8px}',
788
+ '.mtx-btn{padding:6px 16px;border-radius:999px;border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.3));background:transparent;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);cursor:pointer}',
789
+ '.mtx-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
790
+ '.mtx-btn[data-primary]{background:var(--dsw-alias-accent-primary,#4b8dff);border-color:transparent;color:#fff}',
791
+ '.mtx-btn[data-primary]:hover{filter:brightness(1.08)}',
792
+ '.mtx-btn[disabled]{opacity:.5;cursor:default}',
793
+ '.mtx-error{font-size:12px;color:var(--dsw-alias-status-error,#e5484d)}',
794
+
795
+ // Version ring, under the bubble: ‹ 2/3 ›.
796
+ '.mtx-ring{display:flex;align-items:center;gap:2px;font-size:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
797
+ '.mtx-ring button{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;font-size:14px}',
798
+ '.mtx-ring button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
799
+ '.mtx-ring button[disabled]{opacity:.35;cursor:default}',
800
+
801
+ // Versions graph: a pannable canvas with spring-arranged cards and bezier
802
+ // edges. Cursor communicates state: grab on canvas, pointer on cards.
803
+ '.mtx-graph{position:relative;height:100%;overflow:hidden;cursor:grab;background-image:radial-gradient(color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 22%,transparent) 1px,transparent 1px);background-size:26px 26px;touch-action:none;user-select:none}',
804
+ '.mtx-graph[data-panning]{cursor:grabbing}',
805
+ '.mtx-world{position:absolute;left:0;top:0;will-change:transform}',
806
+ '.mtx-edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}',
807
+ '.mtx-edge{fill:none;stroke:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 45%,transparent);stroke-width:1.5}',
808
+ '.mtx-edge[data-path]{stroke:var(--dsw-alias-accent-primary,#4b8dff);stroke-width:2}',
809
+ '.mtx-card{position:absolute;left:0;top:0;width:176px;box-sizing:border-box;display:flex;align-items:flex-start;gap:8px;padding:10px 12px;border-radius:13px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 30%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 10%,var(--dsw-alias-bg-primary,rgba(30,30,34,.9)));box-shadow:0 2px 10px rgba(0,0,0,.14);cursor:pointer;will-change:transform;transition:box-shadow 180ms ease,border-color 180ms ease}',
810
+ '.mtx-card:hover{box-shadow:0 6px 22px rgba(0,0,0,.24);border-color:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 55%,transparent)}',
811
+ '.mtx-card[data-current]{border-color:var(--dsw-alias-accent-primary,#4b8dff);box-shadow:0 0 0 1px var(--dsw-alias-accent-primary,#4b8dff),0 6px 24px color-mix(in srgb,var(--dsw-alias-accent-primary,#4b8dff) 30%,transparent)}',
812
+ '.mtx-card[data-dragging]{cursor:grabbing;box-shadow:0 14px 34px rgba(0,0,0,.3);z-index:3}',
813
+ '.mtx-card[data-deleted]{opacity:.55;border-style:dashed;cursor:default}',
814
+ '.mtx-card[data-archived]{opacity:.72}',
815
+ '.mtx-card[data-deleted]:hover{box-shadow:0 2px 10px rgba(0,0,0,.14);border-color:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 30%,transparent)}',
816
+ '.mtx-card-icon{flex:none;width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:8px;font-size:12px;background:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 18%,transparent);color:var(--dsw-alias-label-secondary,#bbb)}',
817
+ '.mtx-card[data-path] .mtx-card-icon{background:color-mix(in srgb,var(--dsw-alias-accent-primary,#4b8dff) 20%,transparent);color:var(--dsw-alias-accent-primary,#4b8dff)}',
818
+ '.mtx-card-main{min-width:0;flex:1}',
819
+ '.mtx-card-title{font-size:12.5px;font-weight:600;line-height:17px;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
820
+ '.mtx-card-sub{font-size:11px;line-height:15px;margin-top:2px;color:var(--dsw-alias-label-tertiary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}',
821
+ '.mtx-graph-tools{position:absolute;top:12px;right:14px;display:flex;gap:6px;z-index:4}',
822
+ '.mtx-tool{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;border-radius:9px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 30%,transparent);background:var(--dsw-alias-bg-primary,rgba(30,30,34,.85));color:var(--dsw-alias-label-secondary,#bbb);cursor:pointer;font-size:14px}',
823
+ '.mtx-tool:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
824
+ '.mtx-empty{position:absolute;left:0;right:0;bottom:26px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12.5px;pointer-events:none}',
825
+ '.mtx-graph .mtx-link{position:absolute;right:14px;bottom:10px;font-size:12px;color:var(--dsw-alias-label-tertiary);text-decoration:none;z-index:4}',
826
+ '.mtx-link:hover{color:var(--dsw-alias-label-primary)}',
827
+ '.mtx-error{font-size:12px;color:var(--dsw-alias-status-error,#e5484d)}',
828
+ '.mtx-graph .mtx-error{position:absolute;left:14px;top:16px;z-index:4}',
829
+
830
+ // Flash highlight when a graph click lands on its message.
831
+ '@keyframes mtx-flash-kf{0%,55%{background:color-mix(in srgb,var(--dsw-alias-accent-primary,#4b8dff) 22%,transparent)}100%{background:transparent}}',
832
+ '.mtx-flash .mtx-bubble{animation:mtx-flash-kf 1.4s ease-out}',
833
+
834
+ /* ---- action row, below the bubble ------------------------------------ */
835
+ // All three references put the message controls BELOW the bubble, not
836
+ // beside it. What differs is which controls exist and whether they are
837
+ // always visible or revealed on hover.
838
+ '.mtx-actions{display:flex;align-items:center;gap:2px;margin-top:1px}',
839
+ '.mtx-act{width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}',
840
+ '.mtx-act:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
841
+ '.mtx-act[disabled]{opacity:.4;cursor:default}',
842
+ // ChatGPT and Claude reveal the controls on hover; DeepSeek keeps them out,
843
+ // which is also how DSH itself behaves.
844
+ 'html[data-mtx-style=chatgpt] .mtx-actions,html[data-mtx-style=claude] .mtx-actions{opacity:0;transition:opacity 120ms ease}',
845
+ 'html[data-mtx-style=chatgpt] .mtx-row:hover .mtx-actions,html[data-mtx-style=chatgpt] .mtx-row:focus-within .mtx-actions,',
846
+ 'html[data-mtx-style=claude] .mtx-row:hover .mtx-actions,html[data-mtx-style=claude] .mtx-row:focus-within .mtx-actions{opacity:1}',
847
+ // Only Claude offers a retry control on the user message.
848
+ '.mtx-act[data-act=retry]{display:none}',
849
+ 'html[data-mtx-style=claude] .mtx-act[data-act=retry]{display:inline-flex}',
850
+
851
+ /* ---- editor button placement ----------------------------------------- */
852
+ // ChatGPT and DeepSeek keep Cancel/Send INSIDE the editor box. Claude puts
853
+ // them OUTSIDE, below it, and names the primary action Save.
854
+ '.mtx-editor-outside{display:none;justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;width:min(85%,720px)}',
855
+ 'html[data-mtx-style=claude] .mtx-editor-actions{display:none}',
856
+ 'html[data-mtx-style=claude] .mtx-editor-outside{display:flex}',
857
+
858
+
859
+ /* ---- settings section ------------------------------------------------ */
860
+ '.mtx-set{display:flex;flex-direction:column;gap:12px;max-width:560px;font-size:14px;color:var(--dsw-alias-label-primary)}',
861
+ '.mtx-set-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
862
+ '.mtx-set-label{font-size:13px}',
863
+ '.mtx-select{border-radius:9px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 34%,transparent);background:var(--dsw-alias-bg-primary,rgba(30,30,34,.6));color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:6px 10px;outline:none;cursor:pointer}',
864
+ '.mtx-set-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
865
+ '.mtx-preview{margin-top:2px;padding:18px 16px 16px;border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 7%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 16%,transparent);pointer-events:none}',
866
+ '.mtx-preview .mtx-editor{margin-top:12px}',
867
+ '.mtx-preview .mtx-textarea{min-height:auto}',
868
+ '.mtx-preview .mtx-actions{opacity:1!important}',
869
+ '.mtx-set-link{align-self:flex-end;font-size:12px;color:var(--dsw-alias-label-tertiary);text-decoration:none;pointer-events:auto}',
870
+ '.mtx-set-link:hover{color:var(--dsw-alias-label-primary)}',
871
+ ].join('');
872
+
873
+ return {
874
+ // Module dependencies load code; Cordis injection waits for its services.
875
+ // The session controller becomes ready asynchronously after connection.
876
+ inject: ['slots', 'sessions', 'locale'],
877
+ apply(ctx) {
878
+ const slots = ctx.get('slots');
879
+ if (slots === undefined) {
880
+ throw new Error('[dsh-plugin-message-edit] Missing DSH slots service. Check dsh.client.inject and restart DSH.');
881
+ }
882
+ ctx.effect(function () { return styles.insert(CSS); });
883
+ // Reflect the chosen edit style onto <html> now and on every change.
884
+ ctx.effect(function () { syncStyleAttribute(); return styleStore.subscribe(syncStyleAttribute); });
885
+
886
+ const sessions = ctx.get('sessions');
887
+ if (!sessions || typeof sessions.open !== 'function') {
888
+ throw new Error('[dsh-plugin-message-edit] Missing DSH session navigation service. Check client dependencies and restart DSH.');
889
+ }
890
+
891
+ ctx.effect(function () {
892
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
893
+ return sessions.list.subscribe(function () { treeStore.invalidate(); });
894
+ }
895
+ });
896
+
897
+ // Session-list state straight from the service, so this works no matter
898
+ // what props the host chooses to pass slot components.
899
+ function useSessionList() {
900
+ const [, force] = React.useReducer(function (x) { return x + 1; }, 0);
901
+ React.useEffect(function () {
902
+ if (!sessions || !sessions.list || typeof sessions.list.subscribe !== 'function') return undefined;
903
+ return sessions.list.subscribe(force);
904
+ }, []);
905
+ return sessions && sessions.list && typeof sessions.list.getSnapshot === 'function'
906
+ ? sessions.list.getSnapshot()
907
+ : { byId: {} };
908
+ }
909
+
910
+ const I18N_NS = 'dsh-plugin-message-tree';
911
+ const I18N = {
912
+ en: {
913
+ view: 'Versions',
914
+ edit: 'Edit message',
915
+ cancel: 'Cancel',
916
+ send: 'Send',
917
+ save: 'Save',
918
+ copy: 'Copy',
919
+ copied: 'Copied',
920
+ retry: 'Retry this turn',
921
+ regen: 'Regenerate from here',
922
+ original: 'Original conversation',
923
+ turn: 'Turn {turn}',
924
+ edited: 'Edited turn {turn}',
925
+ retried: 'Regenerated turn {turn}',
926
+ branch: 'Branch',
927
+ refresh: 'Refresh',
928
+ fit: 'Center view',
929
+ empty: 'No versions yet — edit any of your messages to branch this conversation. Drag to pan, scroll to zoom.',
930
+ images: '{count} image(s) kept as-is',
931
+ nav: 'Message Edit',
932
+ styleLabel: 'Edit interface style',
933
+ styleHint: 'Where the message controls sit and which ones appear. Changes apply live.',
934
+ style_chatgpt: 'ChatGPT',
935
+ style_deepseek: 'DeepSeek',
936
+ style_claude: 'Claude',
937
+ styleDesc_chatgpt: 'Copy and edit under the bubble, revealed on hover. Cancel and Send sit inside the editor.',
938
+ styleDesc_deepseek: 'Copy and edit under the bubble, always visible — closest to DSH itself. Cancel and Send sit inside the editor.',
939
+ styleDesc_claude: 'Retry, edit and copy under the bubble, revealed on hover. Cancel and Save sit below the editor.',
940
+ deletedVersion: 'Deleted version',
941
+ archivedTag: 'Archived',
942
+ rememberPathLabel: 'Remember the version I was viewing',
943
+ rememberPathHint: 'Reopening a conversation returns to the branch you last had open instead of the original. Off means it always opens the first version.',
944
+ stopOnEditLabel: 'Stop the running reply when I edit',
945
+ stopOnEditHint: 'Editing or retrying cancels every reply still being generated in this conversation before branching, including other versions, so no superseded answer keeps spending tokens. This also lets you edit mid-reply. Off leaves them running.',
946
+ previewUser: 'Rewrite this paragraph to be more concise.',
947
+ },
948
+ zh: {
949
+ view: '版本',
950
+ edit: '编辑消息',
951
+ cancel: '取消',
952
+ send: '发送',
953
+ save: '保存',
954
+ copy: '复制',
955
+ copied: '已复制',
956
+ retry: '重试本轮',
957
+ regen: '从这里重新生成',
958
+ original: '原始对话',
959
+ turn: '第 {turn} 轮',
960
+ edited: '编辑了第 {turn} 轮',
961
+ retried: '重新生成第 {turn} 轮',
962
+ branch: '分支',
963
+ refresh: '刷新',
964
+ fit: '居中显示',
965
+ empty: '还没有版本——编辑任意一条你的消息即可创建分支。拖动平移,滚轮缩放。',
966
+ images: '{count} 张图片将原样保留',
967
+ nav: '消息编辑',
968
+ styleLabel: '编辑界面风格',
969
+ styleHint: '消息操作按钮的位置与种类。修改即时生效。',
970
+ style_chatgpt: 'ChatGPT',
971
+ style_deepseek: 'DeepSeek',
972
+ style_claude: 'Claude',
973
+ styleDesc_chatgpt: '气泡下方为复制与编辑,悬停时显示;「取消 / 发送」位于编辑框内部。',
974
+ styleDesc_deepseek: '气泡下方为复制与编辑,始终显示——最接近 DSH 原生;「取消 / 发送」位于编辑框内部。',
975
+ styleDesc_claude: '气泡下方为重试、编辑与复制,悬停时显示;「取消 / 保存」位于编辑框下方。',
976
+ deletedVersion: '已删除的版本',
977
+ archivedTag: '已归档',
978
+ rememberPathLabel: '记住我正在查看的版本',
979
+ rememberPathHint: '重新打开会话时回到上次查看的分支,而不是最初那条。关闭后始终打开第一个版本。',
980
+ stopOnEditLabel: '编辑时中止正在生成的回复',
981
+ stopOnEditHint: '编辑或重试时,先取消该会话中所有仍在生成的回复(包括其它版本)再分支,避免被取代的回答继续消耗额度;同时允许在回复过程中直接编辑。关闭后它们会继续跑完。',
982
+ previewUser: '把这段话改写得更简洁一些。',
983
+ },
984
+ };
985
+ let t = function (key, params) {
986
+ let out = I18N.en[key] || key;
987
+ if (params) for (const k in params) out = out.replace('{' + k + '}', String(params[k]));
988
+ return out;
989
+ };
990
+ try {
991
+ const locale = ctx.get('locale');
992
+ if (locale && typeof locale.register === 'function' && typeof locale.bind === 'function') {
993
+ ctx.effect(function () { return locale.register(I18N_NS, I18N); });
994
+ t = locale.bind(I18N_NS);
995
+ }
996
+ } catch (e) {
997
+ console.warn('[dsh-plugin-message-edit] Failed to register translations; using English.', e);
998
+ }
999
+
1000
+ function PencilIcon() {
1001
+ return React.createElement('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
1002
+ React.createElement('path', {
1003
+ d: 'M11.1 2.4a1.6 1.6 0 012.3 2.3l-7.2 7.2-3 .8.8-3 7.1-7.3z',
1004
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round',
1005
+ }));
1006
+ }
1007
+
1008
+ function CopyIcon() {
1009
+ return React.createElement('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
1010
+ React.createElement('rect', {
1011
+ x: 5.4, y: 5.4, width: 8.2, height: 8.2, rx: 2,
1012
+ stroke: 'currentColor', strokeWidth: 1.3,
1013
+ }),
1014
+ React.createElement('path', {
1015
+ d: 'M10.6 5.2V4.2a1.8 1.8 0 00-1.8-1.8H4.2a1.8 1.8 0 00-1.8 1.8v4.6a1.8 1.8 0 001.8 1.8h1',
1016
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round',
1017
+ }));
1018
+ }
1019
+
1020
+ function RetryIcon() {
1021
+ return React.createElement('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
1022
+ React.createElement('path', {
1023
+ d: 'M13.2 8a5.2 5.2 0 11-1.6-3.75',
1024
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round',
1025
+ }),
1026
+ React.createElement('path', {
1027
+ d: 'M13.4 2.3v3.1h-3.1',
1028
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round',
1029
+ }));
1030
+ }
1031
+
1032
+ /** Ring beneath a bubble: ‹ i/m › switching whole version sessions. */
1033
+ function VersionRing(props) {
1034
+ const ring = props.ring;
1035
+ if (!ring) return null;
1036
+ const go = function (delta) {
1037
+ const next = ring.alternatives[ring.index + delta];
1038
+ if (next) openVersionTarget(sessions, next);
1039
+ };
1040
+ return React.createElement('div', { className: 'mtx-ring' },
1041
+ React.createElement('button', {
1042
+ type: 'button', disabled: ring.index <= 0,
1043
+ onClick: function () { go(-1); },
1044
+ }, ''),
1045
+ React.createElement('span', null, (ring.index + 1) + '/' + ring.alternatives.length),
1046
+ React.createElement('button', {
1047
+ type: 'button', disabled: ring.index >= ring.alternatives.length - 1,
1048
+ onClick: function () { go(1); },
1049
+ }, '')
1050
+ );
1051
+ }
1052
+
1053
+ function UserMessageView(props) {
1054
+ const node = props.node;
1055
+ const data = node.data || {};
1056
+ const text = contentText(data.content);
1057
+ const images = imageCount(data.content);
1058
+ const messageImages = imageParts(data.content);
1059
+ const sessionId = props.sessionId !== undefined ? props.sessionId : (node.sessionId);
1060
+ // location.turn is a turn-group object ({turn, start, end, steps}); the
1061
+ // turn number lives one level down.
1062
+ const rawTurn = node.location ? node.location.turn : undefined;
1063
+ const turn = typeof rawTurn === 'number' ? rawTurn
1064
+ : (rawTurn && typeof rawTurn.turn === 'number' ? rawTurn.turn : undefined);
1065
+ const list = useSessionList();
1066
+ const summary = sessionId !== undefined ? list.byId[sessionId] : undefined;
1067
+ const running = !!(summary && summary.running);
1068
+ const tree = useTree(sessionId);
1069
+ const ring = typeof turn === 'number' ? ringFor(tree && tree.versions, sessionId, turn) : null;
1070
+
1071
+ // Remember which branch of this family is open, and restore it when we
1072
+ // land back on the family root. Runs per bubble, so every step is either
1073
+ // idempotent or guarded — see activePathStore.
1074
+ const versions = tree && tree.versions;
1075
+ const prefs = usePrefs();
1076
+ React.useEffect(function () {
1077
+ if (!prefs.rememberPath) return;
1078
+ if (!versions || sessionId === undefined) return;
1079
+ const root = rootOf(versions, sessionId);
1080
+ if (!root) return;
1081
+ if (sessionId !== root) {
1082
+ // Arrived at a branch: that is now the remembered view, and any
1083
+ // restore we kicked off has landed.
1084
+ pendingRestore.delete(root);
1085
+ activePathStore.set(root, sessionId);
1086
+ return;
1087
+ }
1088
+ // On the root. Don't record while a restore we triggered is still in
1089
+ // flight, or we would overwrite the target with the root we are leaving.
1090
+ if (pendingRestore.has(root)) return;
1091
+ const remembered = activePathStore.get(root);
1092
+ if (!remembered || remembered === root) return;
1093
+ if (restoredFamilies.has(root)) {
1094
+ // Already restored once this page load and the user walked back to
1095
+ // the root deliberately honour that as the new selection.
1096
+ activePathStore.set(root, root);
1097
+ return;
1098
+ }
1099
+ // Never chase a branch that no longer exists (a deleted branch may
1100
+ // still appear here as a non-openable ghost).
1101
+ const target = versions.find(function (v) { return v.sessionId === remembered; });
1102
+ if (!target || target.deleted) return;
1103
+ restoredFamilies.add(root);
1104
+ pendingRestore.add(root);
1105
+ openVersionTarget(sessions, target);
1106
+ }, [versions, sessionId, sessions, prefs.rememberPath]);
1107
+
1108
+ const [editing, setEditing] = React.useState(false);
1109
+ const [draft, setDraft] = React.useState('');
1110
+ const [busy, setBusy] = React.useState(false);
1111
+ const [error, setError] = React.useState(null);
1112
+ const [copied, setCopied] = React.useState(false);
1113
+
1114
+ // Editing used to require an idle session, because forking calls
1115
+ // `runMaintenance`, which throws while a turn is live. With stopOnEdit the
1116
+ // host cancels that turn first, so editing mid-answer is allowed — and is
1117
+ // the point: it stops the superseded turn instead of leaving it streaming.
1118
+ const canEdit = (!running || prefs.stopOnEdit)
1119
+ && sessionId !== undefined && typeof turn === 'number' && text !== '' && !editing;
1120
+
1121
+ function beginEdit() {
1122
+ setDraft(text);
1123
+ setError(null);
1124
+ setEditing(true);
1125
+ }
1126
+
1127
+ async function submit() {
1128
+ const blockIndex = firstTextBlockIndex(data.content);
1129
+ // An unchanged draft is still a resend: it branches and regenerates.
1130
+ if (blockIndex === -1 || draft.trim() === '') { setEditing(false); return; }
1131
+ setBusy(true);
1132
+ setError(null);
1133
+ try {
1134
+ const result = await mutate({
1135
+ action: 'edit',
1136
+ sessionId: sessionId,
1137
+ eventSeq: data.seq,
1138
+ blockIndex: blockIndex,
1139
+ text: draft,
1140
+ stopPrevious: prefs.stopOnEdit,
1141
+ });
1142
+ const currentTree = treeStore.get(sessionId);
1143
+ if (currentTree && Array.isArray(currentTree.versions)) {
1144
+ const newV = {
1145
+ sessionId: result.sessionId,
1146
+ parentSessionId: sessionId,
1147
+ targetTurn: turn,
1148
+ operation: 'edit',
1149
+ createdAt: Date.now(),
1150
+ current: true,
1151
+ onCurrentPath: true,
1152
+ after: draft,
1153
+ turns: [{ turn: turn, text: draft, time: Date.now() }],
1154
+ };
1155
+ treeStore.setTree(result.sessionId, currentTree.versions.concat([newV]));
1156
+ }
1157
+ treeStore.load(result.sessionId);
1158
+ setEditing(false);
1159
+ if (sessions) openWhenListed(sessions, result.sessionId);
1160
+ } catch (e) {
1161
+ setError(String(e && e.message || e));
1162
+ }
1163
+ setBusy(false);
1164
+ }
1165
+
1166
+ async function retry() {
1167
+ if (typeof turn !== 'number') return;
1168
+ setBusy(true);
1169
+ setError(null);
1170
+ try {
1171
+ const result = await mutate({
1172
+ action: 'retry', sessionId: sessionId, turn: turn, stopPrevious: prefs.stopOnEdit,
1173
+ });
1174
+ const currentTree = treeStore.get(sessionId);
1175
+ if (currentTree && Array.isArray(currentTree.versions)) {
1176
+ const newV = {
1177
+ sessionId: result.sessionId,
1178
+ parentSessionId: sessionId,
1179
+ targetTurn: turn,
1180
+ operation: 'retry',
1181
+ createdAt: Date.now(),
1182
+ current: true,
1183
+ onCurrentPath: true,
1184
+ before: text,
1185
+ turns: [{ turn: turn, text: text, time: Date.now() }],
1186
+ };
1187
+ treeStore.setTree(result.sessionId, currentTree.versions.concat([newV]));
1188
+ }
1189
+ treeStore.load(result.sessionId);
1190
+ if (sessions) openWhenListed(sessions, result.sessionId);
1191
+ } catch (e) {
1192
+ setError(String(e && e.message || e));
1193
+ }
1194
+ setBusy(false);
1195
+ }
1196
+
1197
+ function copy() {
1198
+ const g = realGlobal();
1199
+ try {
1200
+ if (g && g.navigator && g.navigator.clipboard) g.navigator.clipboard.writeText(text);
1201
+ } catch (e) {}
1202
+ setCopied(true);
1203
+ setTimeout(function () { setCopied(false); }, 1200);
1204
+ }
1205
+
1206
+ if (editing) {
1207
+ // Both action rows are rendered; CSS shows the one this preset wants —
1208
+ // inside the box (ChatGPT, DeepSeek) or below it (Claude).
1209
+ const cancelButton = function (key) {
1210
+ return React.createElement('button', {
1211
+ key: key, type: 'button', className: 'mtx-btn', disabled: busy,
1212
+ onClick: function () { setEditing(false); },
1213
+ }, t('cancel'));
1214
+ };
1215
+ const confirmButton = function (key, label) {
1216
+ return React.createElement('button', {
1217
+ key: key, type: 'button', className: 'mtx-btn', 'data-primary': '',
1218
+ disabled: busy || draft.trim() === '',
1219
+ onClick: submit,
1220
+ }, label);
1221
+ };
1222
+ return React.createElement('div', { className: 'mtx-row' },
1223
+ React.createElement('div', { className: 'mtx-editor' },
1224
+ React.createElement('textarea', {
1225
+ className: 'mtx-textarea',
1226
+ value: draft,
1227
+ autoFocus: true,
1228
+ onChange: function (e) { setDraft(e.target.value); },
1229
+ onKeyDown: function (e) {
1230
+ if (e.key === 'Escape') setEditing(false);
1231
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit();
1232
+ },
1233
+ }),
1234
+ images > 0 ? React.createElement('div', { className: 'mtx-img' }, t('images', { count: images })) : null,
1235
+ error ? React.createElement('div', { className: 'mtx-error' }, error) : null,
1236
+ React.createElement('div', { className: 'mtx-editor-actions' },
1237
+ cancelButton('c-in'), confirmButton('s-in', t('send'))
1238
+ )
1239
+ ),
1240
+ React.createElement('div', { className: 'mtx-editor-outside' },
1241
+ cancelButton('c-out'), confirmButton('s-out', t('save'))
1242
+ )
1243
+ );
1244
+ }
1245
+
1246
+ return React.createElement('div', { className: 'mtx-row', 'data-turn': turn, 'data-session': sessionId },
1247
+ React.createElement('div', { className: 'mtx-line' },
1248
+ React.createElement('div', { className: 'mtx-bubble' },
1249
+ text,
1250
+ // The host renders attachments through its images slot (native
1251
+ // gallery plus lightbox); keep the placeholder only when the slot
1252
+ // owner props do not carry the callback.
1253
+ messageImages.length > 0 && typeof props.renderMessageImages === 'function'
1254
+ ? props.renderMessageImages({ images: messageImages, align: 'end' })
1255
+ : (images > 0 ? React.createElement('div', { className: 'mtx-img' }, t('images', { count: images })) : null)
1256
+ )
1257
+ ),
1258
+ // The controls sit under the bubble in all three references. Which
1259
+ // ones exist, and whether they wait for hover, is what differs.
1260
+ React.createElement('div', { className: 'mtx-actions' },
1261
+ React.createElement(VersionRing, { ring: ring }),
1262
+ React.createElement('button', {
1263
+ type: 'button', className: 'mtx-act', 'data-act': 'retry',
1264
+ title: t('retry'), disabled: !canEdit || busy, onClick: retry,
1265
+ }, RetryIcon()),
1266
+ React.createElement('button', {
1267
+ type: 'button', className: 'mtx-act', 'data-act': 'edit',
1268
+ title: t('edit'), disabled: !canEdit, onClick: beginEdit,
1269
+ }, PencilIcon()),
1270
+ React.createElement('button', {
1271
+ type: 'button', className: 'mtx-act', 'data-act': 'copy',
1272
+ title: copied ? t('copied') : t('copy'), onClick: copy,
1273
+ }, CopyIcon())
1274
+ ),
1275
+ error ? React.createElement('div', { className: 'mtx-error' }, error) : null
1276
+ );
1277
+ }
1278
+
1279
+ /**
1280
+ * The Versions view: a live graph. Cards spring into a tidy tree, edges
1281
+ * follow every frame, the canvas pans and zooms, and clicking a card
1282
+ * jumps straight to that version's message.
1283
+ */
1284
+ function VersionsView(props) {
1285
+ const sessionId = props.sessionId;
1286
+ const tree = useTree(sessionId);
1287
+ const titles = useSessionList().byId;
1288
+ const versions = (tree && tree.versions) || [];
1289
+
1290
+ const graphRef = React.useRef(null);
1291
+ const worldRef = React.useRef(null);
1292
+ const cardEls = React.useRef(new Map());
1293
+ const edgeEls = React.useRef(new Map());
1294
+ const springs = React.useRef(new Map());
1295
+ const layoutRef = React.useRef(null);
1296
+ const viewRef = React.useRef({ x: 60, y: 42, scale: 1 });
1297
+ const dragRef = React.useRef(null);
1298
+ const rafRef = React.useRef(0);
1299
+ const fittedRef = React.useRef(false);
1300
+
1301
+ const turnNodes = React.useMemo(function () {
1302
+ return buildTurnTree(versions, sessionId);
1303
+ }, [versions, sessionId]);
1304
+
1305
+ const layoutKey = turnNodes.map(function (n) {
1306
+ return n.id + ':' + (n.parentId || '') + ':' + (n.onCurrentPath ? 1 : 0);
1307
+ }).join('|');
1308
+ const layout = React.useMemo(function () { return layoutTurnTree(turnNodes); }, [layoutKey]);
1309
+ layoutRef.current = layout;
1310
+
1311
+ function applyView() {
1312
+ const el = worldRef.current;
1313
+ const view = viewRef.current;
1314
+ if (el) el.style.transform = 'translate(' + view.x + 'px,' + view.y + 'px) scale(' + view.scale + ')';
1315
+ }
1316
+
1317
+ function renderFrame() {
1318
+ springs.current.forEach(function (s, id) {
1319
+ const el = cardEls.current.get(id);
1320
+ if (el) el.style.transform = 'translate(' + (s.x - CARD_W / 2) + 'px,' + s.y + 'px)';
1321
+ });
1322
+ const lay = layoutRef.current;
1323
+ if (!lay) return;
1324
+ for (let i = 0; i < lay.edges.length; i++) {
1325
+ const e = lay.edges[i];
1326
+ const el = edgeEls.current.get(e.from + '>' + e.to);
1327
+ const a = springs.current.get(e.from);
1328
+ const b = springs.current.get(e.to);
1329
+ if (!el || !a || !b) continue;
1330
+ const fromEl = cardEls.current.get(e.from);
1331
+ const h = fromEl ? fromEl.offsetHeight : 58;
1332
+ el.setAttribute('d', edgePath(a.x, a.y + h, b.x, b.y));
1333
+ }
1334
+ }
1335
+
1336
+ function kick() {
1337
+ if (rafRef.current) return;
1338
+ let last = 0;
1339
+ const step = function (now) {
1340
+ rafRef.current = 0;
1341
+ const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
1342
+ last = now;
1343
+ let alive = false;
1344
+ springs.current.forEach(function (s, id) {
1345
+ const d = dragRef.current;
1346
+ if (d && d.kind === 'node' && d.id === id) { alive = true; return; }
1347
+ const k = 190, c = 24;
1348
+ s.vx += ((s.tx - s.x) * k - s.vx * c) * dt;
1349
+ s.vy += ((s.ty - s.y) * k - s.vy * c) * dt;
1350
+ s.x += s.vx * dt;
1351
+ s.y += s.vy * dt;
1352
+ if (Math.abs(s.vx) + Math.abs(s.vy) + Math.abs(s.tx - s.x) + Math.abs(s.ty - s.y) > 0.5) alive = true;
1353
+ else { s.x = s.tx; s.y = s.ty; s.vx = 0; s.vy = 0; }
1354
+ });
1355
+ renderFrame();
1356
+ if (alive) rafRef.current = requestAnimationFrame(step);
1357
+ };
1358
+ rafRef.current = requestAnimationFrame(step);
1359
+ }
1360
+
1361
+ function fitView() {
1362
+ const el = graphRef.current;
1363
+ const lay = layoutRef.current;
1364
+ if (!el || !lay) return;
1365
+ let lo = Infinity, hi = -Infinity, bot = 100;
1366
+ lay.pos.forEach(function (p) {
1367
+ lo = Math.min(lo, p.x - CARD_W / 2);
1368
+ hi = Math.max(hi, p.x + CARD_W / 2);
1369
+ bot = Math.max(bot, p.y + 90);
1370
+ });
1371
+ if (lo === Infinity) { lo = 0; hi = CARD_W; }
1372
+ const w = el.clientWidth || 600;
1373
+ const h = el.clientHeight || 400;
1374
+ const scale = Math.min(1, (w - 70) / Math.max(1, hi - lo), (h - 70) / bot);
1375
+ viewRef.current = {
1376
+ x: (w - (hi - lo) * scale) / 2 - lo * scale,
1377
+ y: Math.max(30, (h - bot * scale) / 2),
1378
+ scale: scale,
1379
+ };
1380
+ applyView();
1381
+ }
1382
+
1383
+ // Retarget springs on every layout change; new cards are born at their
1384
+ // parent's position so they visibly grow out of it.
1385
+ React.useEffect(function () {
1386
+ const lay = layout;
1387
+ const alive = new Set();
1388
+ lay.pos.forEach(function (p, id) {
1389
+ alive.add(id);
1390
+ let s = springs.current.get(id);
1391
+ if (!s) {
1392
+ const n = lay.byId.get(id);
1393
+ const pp = n && n.parentId ? lay.pos.get(n.parentId) : null;
1394
+ const born = pp || p;
1395
+ springs.current.set(id, { x: born.x, y: born.y, vx: 0, vy: 0, tx: p.x, ty: p.y });
1396
+ } else {
1397
+ s.tx = p.x;
1398
+ s.ty = p.y;
1399
+ }
1400
+ });
1401
+ springs.current.forEach(function (_, id) { if (!alive.has(id)) springs.current.delete(id); });
1402
+ if (!fittedRef.current && lay.pos.size > 0) {
1403
+ fittedRef.current = true;
1404
+ fitView();
1405
+ }
1406
+ applyView();
1407
+ kick();
1408
+ return function () {
1409
+ if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = 0; }
1410
+ };
1411
+ }, [layout]);
1412
+
1413
+ // Wheel zoom around the pointer (non-passive so we may preventDefault).
1414
+ React.useEffect(function () {
1415
+ const el = graphRef.current;
1416
+ if (!el) return undefined;
1417
+ const onWheel = function (ev) {
1418
+ ev.preventDefault();
1419
+ const view = viewRef.current;
1420
+ const rect = el.getBoundingClientRect();
1421
+ const mx = ev.clientX - rect.left;
1422
+ const my = ev.clientY - rect.top;
1423
+ const next = Math.min(1.8, Math.max(0.3, view.scale * Math.exp(-ev.deltaY * 0.0013)));
1424
+ const f = next / view.scale;
1425
+ view.x = mx - (mx - view.x) * f;
1426
+ view.y = my - (my - view.y) * f;
1427
+ view.scale = next;
1428
+ applyView();
1429
+ };
1430
+ el.addEventListener('wheel', onWheel, { passive: false });
1431
+ return function () { el.removeEventListener('wheel', onWheel); };
1432
+ }, []);
1433
+
1434
+ function openVersion(id) {
1435
+ const lay = layoutRef.current;
1436
+ const node = lay && lay.byId.get(id);
1437
+ if (!node || node.deleted || !sessions) return;
1438
+ const v = versions.find(function (item) { return item.sessionId === node.sessionId; });
1439
+ if (!v) return;
1440
+ openVersionTarget(sessions, v);
1441
+ showChat();
1442
+ if (typeof node.turn === 'number' && node.turn > 0) flashTurn(node.sessionId, node.turn, 45);
1443
+ }
1444
+
1445
+ function onPointerDown(ev) {
1446
+ if (ev.button !== 0) return;
1447
+ const cardEl = ev.target.closest ? ev.target.closest('.mtx-card') : null;
1448
+ if (ev.target.closest && ev.target.closest('.mtx-tool,.mtx-link')) return;
1449
+ if (cardEl) {
1450
+ const id = cardEl.getAttribute('data-id');
1451
+ const s = springs.current.get(id);
1452
+ if (!s) return;
1453
+ dragRef.current = { kind: 'node', id: id, moved: false, sx: ev.clientX, sy: ev.clientY, ox: s.x, oy: s.y, el: cardEl };
1454
+ } else {
1455
+ const view = viewRef.current;
1456
+ dragRef.current = { kind: 'pan', moved: false, sx: ev.clientX, sy: ev.clientY, ox: view.x, oy: view.y };
1457
+ graphRef.current.setAttribute('data-panning', '');
1458
+ }
1459
+ try { ev.currentTarget.setPointerCapture(ev.pointerId); } catch (e) {}
1460
+ }
1461
+
1462
+ function onPointerMove(ev) {
1463
+ const d = dragRef.current;
1464
+ if (!d) return;
1465
+ const dx = ev.clientX - d.sx;
1466
+ const dy = ev.clientY - d.sy;
1467
+ if (!d.moved && Math.abs(dx) + Math.abs(dy) > 5) {
1468
+ d.moved = true;
1469
+ if (d.kind === 'node') d.el.setAttribute('data-dragging', '');
1470
+ }
1471
+ if (!d.moved) return;
1472
+ if (d.kind === 'pan') {
1473
+ viewRef.current.x = d.ox + dx;
1474
+ viewRef.current.y = d.oy + dy;
1475
+ applyView();
1476
+ } else {
1477
+ const s = springs.current.get(d.id);
1478
+ const sc = viewRef.current.scale;
1479
+ if (s) { s.x = d.ox + dx / sc; s.y = d.oy + dy / sc; s.vx = 0; s.vy = 0; renderFrame(); }
1480
+ }
1481
+ }
1482
+
1483
+ function onPointerUp() {
1484
+ const d = dragRef.current;
1485
+ dragRef.current = null;
1486
+ if (graphRef.current) graphRef.current.removeAttribute('data-panning');
1487
+ if (!d) return;
1488
+ if (d.kind === 'node') {
1489
+ d.el.removeAttribute('data-dragging');
1490
+ if (d.moved) kick();
1491
+ else openVersion(d.id);
1492
+ }
1493
+ }
1494
+
1495
+ function cardTitle(n) {
1496
+ if (n.deleted) return t('deletedVersion');
1497
+ if (n.isRoot) return t('original');
1498
+ if (n.operation === 'edit') return t('edited', { turn: n.turn });
1499
+ if (n.operation === 'retry') return t('retried', { turn: n.turn });
1500
+ return t('turn', { turn: n.turn });
1501
+ }
1502
+
1503
+ return React.createElement('div', {
1504
+ className: 'mtx-graph',
1505
+ ref: graphRef,
1506
+ onPointerDown: onPointerDown,
1507
+ onPointerMove: onPointerMove,
1508
+ onPointerUp: onPointerUp,
1509
+ onPointerCancel: onPointerUp,
1510
+ },
1511
+ React.createElement('div', { className: 'mtx-world', ref: worldRef },
1512
+ React.createElement('svg', { className: 'mtx-edges' },
1513
+ layout.edges.map(function (e) {
1514
+ const key = e.from + '>' + e.to;
1515
+ const a = springs.current.get(e.from) || layout.pos.get(e.from);
1516
+ const b = springs.current.get(e.to) || layout.pos.get(e.to);
1517
+ return React.createElement('path', {
1518
+ key: key,
1519
+ className: 'mtx-edge',
1520
+ 'data-path': e.onPath || undefined,
1521
+ d: a && b ? edgePath(a.x, a.y + 58, b.x, b.y) : undefined,
1522
+ ref: function (el) { if (el) edgeEls.current.set(key, el); else edgeEls.current.delete(key); },
1523
+ });
1524
+ })
1525
+ ),
1526
+ layout.nodes.map(function (n) {
1527
+ const s = springs.current.get(n.id) || layout.pos.get(n.id) || { x: 0, y: 0 };
1528
+ const summary = titles[n.sessionId];
1529
+ const sub = (n.archived ? t('archivedTag') + ' · ' : '')
1530
+ + (n.text ? '' + clip(n.text, 44) + '” · ' : '')
1531
+ + (n.isRoot && !n.text && summary && summary.displayTitle ? clip(summary.displayTitle, 24) + ' · ' : '')
1532
+ + timeLabel(n.time);
1533
+ return React.createElement('div', {
1534
+ key: n.id,
1535
+ className: 'mtx-card',
1536
+ 'data-id': n.id,
1537
+ 'data-current': n.current || undefined,
1538
+ 'data-path': n.onCurrentPath || undefined,
1539
+ 'data-deleted': n.deleted || undefined,
1540
+ 'data-archived': n.archived || undefined,
1541
+ style: { transform: 'translate(' + (s.x - CARD_W / 2) + 'px,' + s.y + 'px)' },
1542
+ ref: function (el) { if (el) cardEls.current.set(n.id, el); else cardEls.current.delete(n.id); },
1543
+ },
1544
+ React.createElement('span', { className: 'mtx-card-icon' },
1545
+ n.deleted ? '∅' : n.isRoot ? '●' : (n.operation === 'retry' ? '↻' : (n.operation === 'edit' ? '✎' : '💬'))),
1546
+ React.createElement('span', { className: 'mtx-card-main' },
1547
+ React.createElement('span', { className: 'mtx-card-title' }, cardTitle(n)),
1548
+ React.createElement('span', { className: 'mtx-card-sub' }, sub)
1549
+ )
1550
+ );
1551
+ })
1552
+ ),
1553
+ React.createElement('div', { className: 'mtx-graph-tools' },
1554
+ React.createElement('button', {
1555
+ type: 'button', className: 'mtx-tool', title: t('fit'),
1556
+ onClick: function () { fitView(); },
1557
+ }, '⌖'),
1558
+ React.createElement('button', {
1559
+ type: 'button', className: 'mtx-tool', title: t('refresh'),
1560
+ onClick: function () { treeStore.load(sessionId); },
1561
+ }, '↻')
1562
+ ),
1563
+ tree && tree.error ? React.createElement('div', { className: 'mtx-error' }, tree.error) : null,
1564
+ turnNodes.length <= 1 ? React.createElement('div', { className: 'mtx-empty' }, t('empty')) : null,
1565
+ React.createElement('a', {
1566
+ className: 'mtx-link',
1567
+ href: 'https://github.com/SpookySandwich/dsh-plugin-message-edit',
1568
+ target: '_blank', rel: 'noreferrer',
1569
+ }, 'GitHub ↗')
1570
+ );
1571
+ }
1572
+
1573
+ // Settings: pick the edit-interface style, with a live preview that
1574
+ // renders in the currently-selected look.
1575
+ function Toggle(props) {
1576
+ return React.createElement(React.Fragment, null,
1577
+ React.createElement('div', { className: 'mtx-set-row' },
1578
+ React.createElement('span', { className: 'mtx-set-label' }, props.label),
1579
+ React.createElement('input', {
1580
+ type: 'checkbox', checked: props.checked, onChange: props.onChange,
1581
+ })
1582
+ ),
1583
+ React.createElement('div', { className: 'mtx-set-hint' }, props.hint)
1584
+ );
1585
+ }
1586
+
1587
+ function StyleSettings() {
1588
+ const style = useStyle();
1589
+ const prefs = usePrefs();
1590
+ return React.createElement('div', { className: 'mtx-set' },
1591
+ React.createElement('div', { className: 'mtx-set-row' },
1592
+ React.createElement('span', { className: 'mtx-set-label' }, t('styleLabel')),
1593
+ React.createElement('select', {
1594
+ className: 'mtx-select', value: style,
1595
+ onChange: function (e) { styleStore.set(e.target.value); },
1596
+ },
1597
+ STYLES.map(function (s) {
1598
+ return React.createElement('option', { key: s, value: s }, t('style_' + s));
1599
+ })
1600
+ )
1601
+ ),
1602
+ React.createElement('div', { className: 'mtx-set-hint' }, t('styleDesc_' + style)),
1603
+ React.createElement(Toggle, {
1604
+ label: t('rememberPathLabel'),
1605
+ hint: t('rememberPathHint'),
1606
+ checked: prefs.rememberPath,
1607
+ onChange: function (e) { prefsStore.set({ rememberPath: e.target.checked }); },
1608
+ }),
1609
+ React.createElement(Toggle, {
1610
+ label: t('stopOnEditLabel'),
1611
+ hint: t('stopOnEditHint'),
1612
+ checked: prefs.stopOnEdit,
1613
+ onChange: function (e) { prefsStore.set({ stopOnEdit: e.target.checked }); },
1614
+ }),
1615
+ React.createElement('div', { className: 'mtx-preview' },
1616
+ React.createElement('div', { className: 'mtx-row' },
1617
+ React.createElement('div', { className: 'mtx-line' },
1618
+ React.createElement('div', { className: 'mtx-bubble' }, t('previewUser'))
1619
+ ),
1620
+ React.createElement('div', { className: 'mtx-actions' },
1621
+ React.createElement('div', { className: 'mtx-ring' },
1622
+ React.createElement('button', { type: 'button', disabled: true }, '‹'),
1623
+ React.createElement('span', null, '2/3'),
1624
+ React.createElement('button', { type: 'button', disabled: true }, '›')
1625
+ ),
1626
+ React.createElement('span', { className: 'mtx-act', 'data-act': 'retry' }, RetryIcon()),
1627
+ React.createElement('span', { className: 'mtx-act', 'data-act': 'edit' }, PencilIcon()),
1628
+ React.createElement('span', { className: 'mtx-act', 'data-act': 'copy' }, CopyIcon())
1629
+ )
1630
+ ),
1631
+ React.createElement('div', { className: 'mtx-editor' },
1632
+ React.createElement('div', { className: 'mtx-textarea' }, t('previewUser')),
1633
+ React.createElement('div', { className: 'mtx-editor-actions' },
1634
+ React.createElement('span', { className: 'mtx-btn' }, t('cancel')),
1635
+ React.createElement('span', { className: 'mtx-btn', 'data-primary': '' }, t('send'))
1636
+ )
1637
+ ),
1638
+ React.createElement('div', { className: 'mtx-editor-outside' },
1639
+ React.createElement('span', { className: 'mtx-btn' }, t('cancel')),
1640
+ React.createElement('span', { className: 'mtx-btn', 'data-primary': '' }, t('save'))
1641
+ )
1642
+ ),
1643
+ React.createElement('a', {
1644
+ className: 'mtx-set-link',
1645
+ href: 'https://github.com/SpookySandwich/dsh-plugin-message-edit',
1646
+ target: '_blank', rel: 'noreferrer',
1647
+ }, 'GitHub ↗')
1648
+ );
1649
+ }
1650
+
1651
+ slots.inject('settings.section', function () {
1652
+ return slots.register(
1653
+ { name: 'settings.section', id: 'message-tree', order: 210, label: function () { return t('nav'); } },
1654
+ StyleSettings
1655
+ );
1656
+ });
1657
+
1658
+ // Shadow only the plain user bubble; steering and context rows keep the
1659
+ // host renderer. A collision with another user-bubble plugin degrades to
1660
+ // "they win" rather than failing this plugin's other registrations.
1661
+ slots.inject('conversation.chat.node', function () {
1662
+ try {
1663
+ return slots.register(
1664
+ { name: 'conversation.chat.node', key: 'user', priority: -1 },
1665
+ UserMessageView
1666
+ );
1667
+ } catch (e) {
1668
+ console.warn('[dsh-plugin-message-edit] Failed to register the user-message view; editing is unavailable.', e);
1669
+ return function () {};
1670
+ }
1671
+ });
1672
+
1673
+ slots.inject('conversation.view', function () {
1674
+ return slots.register(
1675
+ {
1676
+ name: 'conversation.view',
1677
+ id: 'message-tree',
1678
+ order: VIEW_ORDER,
1679
+ label: function () { return t('view'); },
1680
+ inject: function (sessionId) { return { sessionId: sessionId }; },
1681
+ },
1682
+ VersionsView
1683
+ );
1684
+ });
1685
+ }
1686
+ };
1663
1687
 
1664
1688
  })();
1665
1689
  }