@mzzsfy/dsh-session-manager 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -29
- package/package.json +1 -1
- package/src/client.js +805 -1368
- package/src/core.mjs +262 -344
- package/src/index.js +851 -1166
- package/src/trash.mjs +40 -2
- package/test/client-delete.test.mjs +46 -0
- package/test/client-help.test.mjs +36 -0
- package/test/client-id.test.mjs +8 -0
- package/test/core.test.mjs +485 -595
- package/test/index.test.mjs +1730 -1968
- package/test/parity.test.mjs +0 -11
- package/test/trash.test.mjs +73 -2
- package/src/history-cache.mjs +0 -127
- package/test/client-steer.test.mjs +0 -266
- package/test/history-cache.test.mjs +0 -84
package/src/client.js
CHANGED
|
@@ -1,1368 +1,805 @@
|
|
|
1
|
-
// dsh-session-manager Client 半区:settings.section 归档面板 +
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'.sm-
|
|
34
|
-
'.sm-
|
|
35
|
-
'.sm-
|
|
36
|
-
'.sm-
|
|
37
|
-
'.sm-
|
|
38
|
-
'
|
|
39
|
-
'.sm-
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'.sm-row::before
|
|
43
|
-
'
|
|
44
|
-
'.sm-row
|
|
45
|
-
'.sm-row
|
|
46
|
-
'.sm-
|
|
47
|
-
'.sm-
|
|
48
|
-
'.sm-
|
|
49
|
-
'.sm-
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
'.sm-row__ws {
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'.sm-
|
|
56
|
-
'
|
|
57
|
-
'.sm-
|
|
58
|
-
'.sm-
|
|
59
|
-
'
|
|
60
|
-
'.sm-
|
|
61
|
-
'
|
|
62
|
-
'.sm-
|
|
63
|
-
'.sm-btn {
|
|
64
|
-
'
|
|
65
|
-
'.sm-btn
|
|
66
|
-
'.sm-btn--
|
|
67
|
-
'.sm-btn
|
|
68
|
-
'.sm-btn--confirm { color:var(--dsw-alias-state-error-primary);
|
|
69
|
-
'.sm-btn
|
|
70
|
-
|
|
71
|
-
'.sm-
|
|
72
|
-
'
|
|
73
|
-
'.sm-
|
|
74
|
-
'
|
|
75
|
-
|
|
76
|
-
'.sm-
|
|
77
|
-
'
|
|
78
|
-
'.sm-
|
|
79
|
-
'
|
|
80
|
-
'.sm-
|
|
81
|
-
'.sm-
|
|
82
|
-
'
|
|
83
|
-
'.sm-
|
|
84
|
-
'
|
|
85
|
-
'.sm-
|
|
86
|
-
'.sm-
|
|
87
|
-
'.sm-
|
|
88
|
-
'.sm-
|
|
89
|
-
|
|
90
|
-
'.sm-
|
|
91
|
-
'.sm-
|
|
92
|
-
'.sm-
|
|
93
|
-
'
|
|
94
|
-
'.sm-
|
|
95
|
-
'.sm-
|
|
96
|
-
'
|
|
97
|
-
'.sm-
|
|
98
|
-
'
|
|
99
|
-
'.sm-
|
|
100
|
-
'.sm-
|
|
101
|
-
'
|
|
102
|
-
'.sm-
|
|
103
|
-
'
|
|
104
|
-
'.sm-
|
|
105
|
-
'.sm-
|
|
106
|
-
'.sm-
|
|
107
|
-
'
|
|
108
|
-
'
|
|
109
|
-
'
|
|
110
|
-
|
|
111
|
-
'.sm-
|
|
112
|
-
|
|
113
|
-
'.sm-
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
'.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// (
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return h('div', {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
)
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
h('
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
function
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
.then(refreshDeleted)
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
//
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
className: 'sm-
|
|
594
|
-
'
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
h('
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
)
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
})
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const HISTORY_SCOPE_DEFAULT = HISTORY_SCOPES.indexOf('session')
|
|
807
|
-
// 对齐未就绪时的静默重拉间隔与上限(对齐通常秒级完成,上限防死循环)
|
|
808
|
-
const HISTORY_REPULL_MS = 3 * 1000
|
|
809
|
-
const PROMPTS_TOGGLE_URL = '/api/session-manager/prompts/toggle'
|
|
810
|
-
const HISTORY_ENABLED_URL = '/api/session-manager/history-enabled'
|
|
811
|
-
const STEER_ENABLED_URL = '/api/session-manager/steer-recall-enabled'
|
|
812
|
-
|
|
813
|
-
function HistoryDock({ session, inputActions }) {
|
|
814
|
-
const [open, setOpen] = useState(false)
|
|
815
|
-
const [items, setItems] = useState(null)
|
|
816
|
-
const [cursor, setCursor] = useState(-1)
|
|
817
|
-
const [scopeIndex, setScopeIndex] = useState(HISTORY_SCOPE_DEFAULT)
|
|
818
|
-
const [aligning, setAligning] = useState(false)
|
|
819
|
-
const [loadError, setLoadError] = useState(false)
|
|
820
|
-
// 收藏集合:浮层打开时并行拉一次,行悬停星标据此显示实/空心;
|
|
821
|
-
// editing 仅常用范围可进入(行内改/删 + 底部新增)
|
|
822
|
-
const [collected, setCollected] = useState(() => new Set())
|
|
823
|
-
const [editing, setEditing] = useState(false)
|
|
824
|
-
const rootRef = React.useRef(null)
|
|
825
|
-
// 键盘层权威状态:监听器挂载一次(空依赖),读写全走 ref,规避 effect 重挂
|
|
826
|
-
// 时序造成的闭包陈旧;state 仅驱动渲染,变更处双写
|
|
827
|
-
const viewRef = React.useRef({ open: false, items: null, cursor: -1, scopeIndex: 0, aligning: false })
|
|
828
|
-
const sessionRef = React.useRef(session)
|
|
829
|
-
const inputActionsRef = React.useRef(inputActions)
|
|
830
|
-
// 历史浮层启停(设置开关):挂载拉取一次,停用即整体不渲染、键盘放行
|
|
831
|
-
const enabledRef = React.useRef(true)
|
|
832
|
-
const [historyEnabled, setHistoryEnabled] = useState(true)
|
|
833
|
-
sessionRef.current = session
|
|
834
|
-
inputActionsRef.current = inputActions
|
|
835
|
-
useEffect(() => {
|
|
836
|
-
api(HISTORY_ENABLED_URL)
|
|
837
|
-
.then((payload) => {
|
|
838
|
-
const on = payload ? payload.enabled !== false : true
|
|
839
|
-
enabledRef.current = on
|
|
840
|
-
setHistoryEnabled(on)
|
|
841
|
-
})
|
|
842
|
-
.catch(() => {})
|
|
843
|
-
}, [])
|
|
844
|
-
|
|
845
|
-
function syncView(patch) {
|
|
846
|
-
viewRef.current = { ...viewRef.current, ...patch }
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// host 响应信封 { inputs, aligned };解包并防御形态漂移,消费侧恒为数组。
|
|
850
|
-
// aligned=false 表示工作区缓存尚未建立(首次),host 已在后台对齐
|
|
851
|
-
function fetchInputs(scope) {
|
|
852
|
-
return api(INPUTS_URL + '?sessionId=' + encodeURIComponent(sessionRef.current.sessionId) + '&scope=' + scope)
|
|
853
|
-
.then((payload) => ({
|
|
854
|
-
inputs: (payload && Array.isArray(payload.inputs)) ? payload.inputs : [],
|
|
855
|
-
aligned: Boolean(payload && payload.aligned),
|
|
856
|
-
}))
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// 收藏集合与浮层列表共用 inputs 路由(scope=prompts),毫秒级缓存读
|
|
860
|
-
function refreshCollected() {
|
|
861
|
-
return api(INPUTS_URL + '?sessionId=' + encodeURIComponent(sessionRef.current.sessionId) + '&scope=prompts')
|
|
862
|
-
.then((payload) => {
|
|
863
|
-
const list = payload && Array.isArray(payload.inputs) ? payload.inputs : []
|
|
864
|
-
setCollected(new Set(list.map((item) => item.text)))
|
|
865
|
-
})
|
|
866
|
-
.catch(() => {})
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// 收藏切换:行悬停星标与编辑态删除共用;成功后同步星标集,
|
|
870
|
-
// 当前正处于常用范围时立即重拉列表(不等轮询)
|
|
871
|
-
function togglePrompt(text) {
|
|
872
|
-
return api(PROMPTS_TOGGLE_URL, { method: 'POST', body: JSON.stringify({ text }) })
|
|
873
|
-
.then((payload) => {
|
|
874
|
-
const on = Boolean(payload && payload.collected)
|
|
875
|
-
toast(on ? '已收藏到常用' : '已取消收藏')
|
|
876
|
-
setCollected((prev) => {
|
|
877
|
-
const next = new Set(prev)
|
|
878
|
-
if (on) next.add(text)
|
|
879
|
-
else next.delete(text)
|
|
880
|
-
return next
|
|
881
|
-
})
|
|
882
|
-
if (viewRef.current.open && viewRef.current.scopeIndex === HISTORY_SCOPES.indexOf('prompts')) requestScope(viewRef.current.scopeIndex)
|
|
883
|
-
return on
|
|
884
|
-
})
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// 编辑态改文本 = 移除旧文本 + 收藏新文本(两次 toggle 原子性由"同文本去重"保证:
|
|
888
|
-
// 中断最坏留下旧文本,重改一次即自愈)
|
|
889
|
-
function renamePrompt(from, to) {
|
|
890
|
-
return togglePrompt(from)
|
|
891
|
-
.then(() => (to === '' ? Promise.resolve() : togglePrompt(to)))
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// 应用一次响应:仅当范围未变时生效,防止快速切范围后迟到响应覆盖新范围。
|
|
895
|
-
// items 深度不变的重复响应不重置光标(轮询期间保持用户选位)
|
|
896
|
-
function applyResult(scopeIdx, result) {
|
|
897
|
-
if (viewRef.current.scopeIndex !== scopeIdx) return
|
|
898
|
-
const prev = viewRef.current.items
|
|
899
|
-
if (prev !== null && JSON.stringify(prev) === JSON.stringify(result.inputs)) {
|
|
900
|
-
syncView({ aligning: !result.aligned })
|
|
901
|
-
setAligning(!result.aligned)
|
|
902
|
-
return
|
|
903
|
-
}
|
|
904
|
-
syncView({ items: result.inputs, cursor: -1, aligning: !result.aligned })
|
|
905
|
-
setItems(result.inputs)
|
|
906
|
-
setCursor(-1)
|
|
907
|
-
setAligning(!result.aligned)
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// 浮层打开期间固定节奏轮询当前范围:host 侧对齐/焦点对齐是异步的,
|
|
911
|
-
// 数据可能晚于首次响应到达,一次性重拉预算耗尽后迟到的数据将永不出现
|
|
912
|
-
// (实机验证实测:API 已就绪而浮层停在旧列表)。轮询开销为毫秒级缓存读;
|
|
913
|
-
// 请求失败(含超时)也保持轮询,host 恢复后列表自愈,不假死
|
|
914
|
-
function requestScope(scopeIdx) {
|
|
915
|
-
const scope = HISTORY_SCOPES[scopeIdx]
|
|
916
|
-
const scheduleNext = () => {
|
|
917
|
-
if (!viewRef.current.open || viewRef.current.scopeIndex !== scopeIdx) return
|
|
918
|
-
setTimeout(() => {
|
|
919
|
-
if (viewRef.current.open && viewRef.current.scopeIndex === scopeIdx) requestScope(scopeIdx)
|
|
920
|
-
}, HISTORY_REPULL_MS)
|
|
921
|
-
}
|
|
922
|
-
fetchInputs(scope)
|
|
923
|
-
.then((result) => {
|
|
924
|
-
applyResult(scopeIdx, result)
|
|
925
|
-
scheduleNext()
|
|
926
|
-
})
|
|
927
|
-
.catch((error) => {
|
|
928
|
-
if (viewRef.current.scopeIndex !== scopeIdx) return
|
|
929
|
-
syncView({ items: [] })
|
|
930
|
-
setLoadError(true)
|
|
931
|
-
setItems([])
|
|
932
|
-
toast(error && error.message ? error.message : String(error), { kind: 'error' })
|
|
933
|
-
scheduleNext()
|
|
934
|
-
})
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function fill(text) {
|
|
938
|
-
inputActionsRef.current.setDraft(text)
|
|
939
|
-
syncView({ open: false })
|
|
940
|
-
setOpen(false)
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
function openPopup() {
|
|
944
|
-
syncView({ open: true, items: null, cursor: -1, scopeIndex: HISTORY_SCOPE_DEFAULT, aligning: false })
|
|
945
|
-
setOpen(true)
|
|
946
|
-
setScopeIndex(HISTORY_SCOPE_DEFAULT)
|
|
947
|
-
setCursor(-1)
|
|
948
|
-
setAligning(false)
|
|
949
|
-
setLoadError(false)
|
|
950
|
-
setItems(null)
|
|
951
|
-
setEditing(false)
|
|
952
|
-
refreshCollected()
|
|
953
|
-
requestScope(HISTORY_SCOPE_DEFAULT)
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// 切换范围:→ 向大(工作区/全局),← 返回收藏;边界停住;缓存直接返回(毫秒级),后台保持对齐
|
|
957
|
-
function switchScope(delta) {
|
|
958
|
-
const next = viewRef.current.scopeIndex + delta
|
|
959
|
-
if (next < 0 || next >= HISTORY_SCOPE_LABELS.length) return
|
|
960
|
-
syncView({ scopeIndex: next, items: null, cursor: -1, aligning: false })
|
|
961
|
-
setScopeIndex(next)
|
|
962
|
-
setCursor(-1)
|
|
963
|
-
setAligning(false)
|
|
964
|
-
setLoadError(false)
|
|
965
|
-
setItems(null)
|
|
966
|
-
setEditing(false)
|
|
967
|
-
requestScope(next)
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// Alt+↑ 唤起浮层;浮层开 = 菜单模态,捕获阶段拦截导航键,先于 Lexical 光标移动。
|
|
971
|
-
// 设置开关停用时监听器直接放行(读 ref,开关值挂载拉取后即时反映)
|
|
972
|
-
useEffect(() => {
|
|
973
|
-
function onKeyDown(event) {
|
|
974
|
-
if (enabledRef.current === false) return
|
|
975
|
-
const view = viewRef.current
|
|
976
|
-
if (event.isComposing) return
|
|
977
|
-
if (!view.open) {
|
|
978
|
-
if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && event.key === 'ArrowUp') {
|
|
979
|
-
event.preventDefault()
|
|
980
|
-
event.stopPropagation()
|
|
981
|
-
openPopup()
|
|
982
|
-
}
|
|
983
|
-
return
|
|
984
|
-
}
|
|
985
|
-
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
986
|
-
event.preventDefault()
|
|
987
|
-
event.stopPropagation()
|
|
988
|
-
if (view.items === null || view.items.length === 0) return
|
|
989
|
-
const delta = event.key === 'ArrowUp' ? -1 : 1
|
|
990
|
-
const next = Math.min(Math.max(view.cursor + delta, 0), view.items.length - 1)
|
|
991
|
-
syncView({ cursor: next })
|
|
992
|
-
setCursor(next)
|
|
993
|
-
return
|
|
994
|
-
}
|
|
995
|
-
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
996
|
-
event.preventDefault()
|
|
997
|
-
event.stopPropagation()
|
|
998
|
-
switchScope(event.key === 'ArrowLeft' ? -1 : 1)
|
|
999
|
-
return
|
|
1000
|
-
}
|
|
1001
|
-
if (event.key === 'Enter') {
|
|
1002
|
-
// 未选中条目时放行:用户可能只是在草稿里按 Enter 发消息,吞掉即是「假死锁定」
|
|
1003
|
-
if (!(view.items !== null && view.cursor >= 0 && view.cursor < view.items.length)) return
|
|
1004
|
-
event.preventDefault()
|
|
1005
|
-
event.stopPropagation()
|
|
1006
|
-
fill(view.items[view.cursor].text)
|
|
1007
|
-
return
|
|
1008
|
-
}
|
|
1009
|
-
if (event.key === 'Escape') {
|
|
1010
|
-
event.preventDefault()
|
|
1011
|
-
event.stopPropagation()
|
|
1012
|
-
syncView({ open: false })
|
|
1013
|
-
setOpen(false)
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
document.addEventListener('keydown', onKeyDown, true)
|
|
1017
|
-
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
1018
|
-
}, [])
|
|
1019
|
-
|
|
1020
|
-
// 选中行滚入可视区:键盘移动后高亮行保持在视野内
|
|
1021
|
-
useEffect(() => {
|
|
1022
|
-
if (!open || cursor < 0) return
|
|
1023
|
-
const row = rootRef.current && rootRef.current.querySelector('.sm-hist__row--on')
|
|
1024
|
-
if (row) row.scrollIntoView({ block: 'nearest' })
|
|
1025
|
-
}, [open, cursor, items])
|
|
1026
|
-
|
|
1027
|
-
// 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)
|
|
1028
|
-
useEffect(() => {
|
|
1029
|
-
if (!open) return undefined
|
|
1030
|
-
function onPointerDown(event) {
|
|
1031
|
-
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
|
1032
|
-
syncView({ open: false })
|
|
1033
|
-
setOpen(false)
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
document.addEventListener('mousedown', onPointerDown, true)
|
|
1037
|
-
return () => document.removeEventListener('mousedown', onPointerDown, true)
|
|
1038
|
-
}, [open])
|
|
1039
|
-
|
|
1040
|
-
// 零高度锚点:平时不占任何界面空间,仅浮层打开时渲染;设置停用整体不渲染
|
|
1041
|
-
if (session === undefined || inputActions === undefined || historyEnabled === false) return null
|
|
1042
|
-
const promptsIdx = HISTORY_SCOPES.indexOf('prompts')
|
|
1043
|
-
const inPrompts = scopeIndex === promptsIdx
|
|
1044
|
-
// 行悬停星标:非常用范围显示,已收藏实星(点击取消),未收藏空心(点击收藏);
|
|
1045
|
-
// stopPropagation 防止触发行回填
|
|
1046
|
-
const starButton = (item) => h('button', {
|
|
1047
|
-
className: 'sm-hist__star' + (collected.has(item.text) ? ' sm-hist__star--on' : ''),
|
|
1048
|
-
title: collected.has(item.text) ? '取消收藏' : '收藏',
|
|
1049
|
-
onClick: (event) => {
|
|
1050
|
-
event.stopPropagation()
|
|
1051
|
-
togglePrompt(item.text)
|
|
1052
|
-
},
|
|
1053
|
-
}, collected.has(item.text) ? '★' : '☆')
|
|
1054
|
-
const rowButton = (item, index) => h('button', {
|
|
1055
|
-
key: index + ':' + item.at,
|
|
1056
|
-
className: 'sm-hist__row' + (index === cursor ? ' sm-hist__row--on' : ''),
|
|
1057
|
-
onClick: () => fill(item.text),
|
|
1058
|
-
},
|
|
1059
|
-
h('span', { className: 'sm-hist__text', title: item.text }, item.text),
|
|
1060
|
-
h('span', { className: 'sm-hist__time' }, fmtTime(item.at)),
|
|
1061
|
-
!inPrompts ? starButton(item) : null,
|
|
1062
|
-
)
|
|
1063
|
-
// 编辑态行:文本可改(行内 input,回车/失焦保存)+ 删除
|
|
1064
|
-
const editRow = (item, index) => h('div', {
|
|
1065
|
-
key: index + ':' + item.at,
|
|
1066
|
-
className: 'sm-hist__editrow',
|
|
1067
|
-
},
|
|
1068
|
-
h('input', {
|
|
1069
|
-
className: 'sm-hist__editinput',
|
|
1070
|
-
defaultValue: item.text,
|
|
1071
|
-
title: item.text,
|
|
1072
|
-
onKeyDown: (event) => {
|
|
1073
|
-
if (event.key === 'Enter') {
|
|
1074
|
-
event.preventDefault()
|
|
1075
|
-
renamePrompt(item.text, event.target.value.trim())
|
|
1076
|
-
}
|
|
1077
|
-
},
|
|
1078
|
-
onBlur: (event) => {
|
|
1079
|
-
const next = event.target.value.trim()
|
|
1080
|
-
if (next !== item.text) renamePrompt(item.text, next)
|
|
1081
|
-
},
|
|
1082
|
-
}),
|
|
1083
|
-
h('button', {
|
|
1084
|
-
className: 'sm-hist__del',
|
|
1085
|
-
title: '删除',
|
|
1086
|
-
onClick: () => togglePrompt(item.text),
|
|
1087
|
-
}, '×'),
|
|
1088
|
-
)
|
|
1089
|
-
return h('div', { className: 'sm-hist', ref: rootRef },
|
|
1090
|
-
open && h('div', { className: 'sm-hist__pop' },
|
|
1091
|
-
h('div', { className: 'sm-hist__hint' },
|
|
1092
|
-
h('span', { className: 'sm-hist__scope' }, HISTORY_SCOPE_LABELS[scopeIndex]),
|
|
1093
|
-
inPrompts
|
|
1094
|
-
? h('button', {
|
|
1095
|
-
className: 'sm-hist__editbtn',
|
|
1096
|
-
onClick: () => setEditing(!editing),
|
|
1097
|
-
}, editing ? '完成' : '编辑')
|
|
1098
|
-
: null,
|
|
1099
|
-
h('span', null, '↑/↓ 选择 · ←/→ 切换范围 · Enter 填入 · Esc 关闭')),
|
|
1100
|
-
aligning && h('div', { className: 'sm-hist__banner' }, '首次对齐历史中,可能需要稍等'),
|
|
1101
|
-
items === null
|
|
1102
|
-
? h('div', { className: 'sm-hist__empty' }, '正在读取历史输入…')
|
|
1103
|
-
: editing && inPrompts
|
|
1104
|
-
? h('div', { className: 'sm-hist__editwrap' },
|
|
1105
|
-
items.length === 0
|
|
1106
|
-
? h('div', { className: 'sm-hist__empty' }, '还没有常用提示词,可从历史行悬停收藏,或在下方添加')
|
|
1107
|
-
: h('div', { className: 'sm-hist__list' }, items.map(editRow)),
|
|
1108
|
-
h('div', { className: 'sm-hist__addrow' },
|
|
1109
|
-
h('input', {
|
|
1110
|
-
className: 'sm-hist__editinput',
|
|
1111
|
-
placeholder: '输入常用提示词,Enter 添加',
|
|
1112
|
-
ref: (el) => { if (el) el.dataset.addinput = '1' },
|
|
1113
|
-
onKeyDown: (event) => {
|
|
1114
|
-
if (event.key === 'Enter') {
|
|
1115
|
-
event.preventDefault()
|
|
1116
|
-
const text = event.target.value.trim()
|
|
1117
|
-
if (text !== '') {
|
|
1118
|
-
togglePrompt(text)
|
|
1119
|
-
event.target.value = ''
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
},
|
|
1123
|
-
}),
|
|
1124
|
-
),
|
|
1125
|
-
)
|
|
1126
|
-
: items.length === 0
|
|
1127
|
-
? h('div', { className: 'sm-hist__empty' }, loadError ? '历史输入加载失败,可关闭后重试' : (inPrompts ? '还没有常用提示词' : '该范围内还没有历史输入'))
|
|
1128
|
-
: h('div', { className: 'sm-hist__list' }, items.map(rowButton)),
|
|
1129
|
-
),
|
|
1130
|
-
)
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// 插话撤回投影:仅用户插话(宿主队列 placement=steering,已发出但未被 step 边界
|
|
1134
|
-
// claim 应用);queued 由官方队列面板呈现,context 为注入上下文非用户消息
|
|
1135
|
-
function steerRowsOf(queue) {
|
|
1136
|
-
const rows = Array.isArray(queue) ? queue : []
|
|
1137
|
-
return rows.filter((row) => row && row.placement === 'steering')
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// 撤回失败文案:queue-item-not-found = 消息已被 step 边界 claim(应用),无法撤回;
|
|
1141
|
-
// 其余透传宿主错误消息
|
|
1142
|
-
function withdrawFailureText(result) {
|
|
1143
|
-
const error = result && result.error
|
|
1144
|
-
if (error && error.code === 'session/queue-item-not-found') return '插话已被应用,无法撤回'
|
|
1145
|
-
return '撤回失败: ' + ((error && error.message) || '未知错误')
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// 撤回动作:先宿主移除(inbox next-step 摘除,未应用即消失),成功才回填草稿,
|
|
1149
|
-
// 顺序保证「移除失败绝不覆盖输入框」;actions 注入便于测试与组件解耦
|
|
1150
|
-
async function withdrawSteer(row, actions) {
|
|
1151
|
-
let result
|
|
1152
|
-
try {
|
|
1153
|
-
result = await actions.updateQueue(row.id, { kind: 'remove' })
|
|
1154
|
-
} catch (error) {
|
|
1155
|
-
actions.notify('撤回失败: ' + String(error && error.message || error), { kind: 'error' })
|
|
1156
|
-
return false
|
|
1157
|
-
}
|
|
1158
|
-
if (!(result && result.ok)) {
|
|
1159
|
-
actions.notify(withdrawFailureText(result), { kind: 'error' })
|
|
1160
|
-
return false
|
|
1161
|
-
}
|
|
1162
|
-
try {
|
|
1163
|
-
actions.setDraft(row.text)
|
|
1164
|
-
} catch (error) {
|
|
1165
|
-
// 摘除已成功,草稿回填失败(会话切换等竞态)时文本必须可达:通知兜底展示原文
|
|
1166
|
-
actions.notify('草稿回填失败,原文: ' + row.text, { kind: 'error' })
|
|
1167
|
-
return false
|
|
1168
|
-
}
|
|
1169
|
-
actions.notify('已撤回到输入框')
|
|
1170
|
-
return true
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
// 撤回图标 svg(undo 弯箭头,stroke 继承 currentColor,尺寸由所克隆的官方按钮类控制)
|
|
1174
|
-
function steerRecallSvg() {
|
|
1175
|
-
const namespace = 'http://www.w3.org/2000/svg'
|
|
1176
|
-
const svg = document.createElementNS(namespace, 'svg')
|
|
1177
|
-
svg.setAttribute('viewBox', '0 0 16 16')
|
|
1178
|
-
svg.setAttribute('fill', 'none')
|
|
1179
|
-
svg.setAttribute('stroke', 'currentColor')
|
|
1180
|
-
svg.setAttribute('stroke-width', '1.5')
|
|
1181
|
-
svg.setAttribute('stroke-linecap', 'round')
|
|
1182
|
-
svg.setAttribute('stroke-linejoin', 'round')
|
|
1183
|
-
svg.setAttribute('aria-hidden', 'true')
|
|
1184
|
-
const paths = ['M3 6.5h6.5a3.5 3.5 0 0 1 0 7H6', 'M6 3.5 3 6.5l3 3']
|
|
1185
|
-
for (const d of paths) {
|
|
1186
|
-
const path = document.createElementNS(namespace, 'path')
|
|
1187
|
-
path.setAttribute('d', d)
|
|
1188
|
-
svg.appendChild(path)
|
|
1189
|
-
}
|
|
1190
|
-
return svg
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
// 注入按钮标记:识别自家节点与官方按钮,防止重复注入与误删官方节点
|
|
1194
|
-
const STEER_BTN_FLAG = 'data-sm-steer-recall'
|
|
1195
|
-
// 官方 pending steering 气泡的语义标记(官方同构:UserStyleBubble data 属性)与
|
|
1196
|
-
// 其操作图标排的 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定)
|
|
1197
|
-
const STEER_BUBBLE_SELECTOR = '[data-pending-steering]'
|
|
1198
|
-
const STEER_ACTIONS_SUFFIX = '[class$="_actions"]'
|
|
1199
|
-
|
|
1200
|
-
// 插话撤回:注入原生 pending steering 气泡的操作图标排(复制图标旁),样式经克隆
|
|
1201
|
-
// 官方按钮 className 完全原生;气泡被应用后整棵卸载,注入按钮随之消失。
|
|
1202
|
-
// dock 本体 display:none 零占位,仅承载观察与注入;官方 DOM 结构漂移时注入静默
|
|
1203
|
-
// 跳过(不报错),干净禁用精神
|
|
1204
|
-
function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
|
|
1205
|
-
const queue = useSession((state) => state.queue)
|
|
1206
|
-
const queueMutable = useSession((state) => state.subagent === null || state.subagent.address.mode === 'continuable')
|
|
1207
|
-
const [busy, setBusy] = useState(false)
|
|
1208
|
-
// 启停开关(设置页):挂载拉取一次,停用即不注入并移除已注入按钮;失败按启用兜底
|
|
1209
|
-
const [enabled, setEnabled] = useState(true)
|
|
1210
|
-
useEffect(() => {
|
|
1211
|
-
api(STEER_ENABLED_URL)
|
|
1212
|
-
.then((payload) => setEnabled(payload ? payload.enabled !== false : true))
|
|
1213
|
-
.catch(() => {})
|
|
1214
|
-
}, [])
|
|
1215
|
-
// 观察回调经由 ref 读最新状态,观察器只挂一次不随渲染重挂
|
|
1216
|
-
const rowsRef = useRef([])
|
|
1217
|
-
const mutableRef = useRef(true)
|
|
1218
|
-
const busyRef = useRef(false)
|
|
1219
|
-
const actionsRef = useRef(null)
|
|
1220
|
-
rowsRef.current = steerRowsOf(queue)
|
|
1221
|
-
mutableRef.current = queueMutable
|
|
1222
|
-
busyRef.current = busy
|
|
1223
|
-
actionsRef.current = { updateQueue, setDraft: inputActions ? inputActions.setDraft : undefined }
|
|
1224
|
-
const scanRef = useRef(null)
|
|
1225
|
-
useEffect(() => {
|
|
1226
|
-
function withdraw(row) {
|
|
1227
|
-
const actions = actionsRef.current
|
|
1228
|
-
if (busyRef.current || !actions || typeof actions.updateQueue !== 'function') return
|
|
1229
|
-
setBusy(true)
|
|
1230
|
-
void withdrawSteer(row, { updateQueue: actions.updateQueue, setDraft: actions.setDraft, notify: toast })
|
|
1231
|
-
.finally(() => setBusy(false))
|
|
1232
|
-
}
|
|
1233
|
-
function syncButton(button, row) {
|
|
1234
|
-
const textOnly = row.text !== null
|
|
1235
|
-
button.disabled = busyRef.current || !textOnly
|
|
1236
|
-
button.title = textOnly ? '撤回到输入框: ' + row.preview : '含附件的插话不支持撤回'
|
|
1237
|
-
}
|
|
1238
|
-
function scan() {
|
|
1239
|
-
const bubbles = document.querySelectorAll(STEER_BUBBLE_SELECTOR)
|
|
1240
|
-
const rows = mutableRef.current ? rowsRef.current : []
|
|
1241
|
-
bubbles.forEach((bubble, index) => {
|
|
1242
|
-
const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
|
|
1243
|
-
if (!actionsRow) return
|
|
1244
|
-
let button = actionsRow.querySelector('[' + STEER_BTN_FLAG + ']')
|
|
1245
|
-
const row = rows[index]
|
|
1246
|
-
if (!row) {
|
|
1247
|
-
// 气泡尚存但行已被应用/撤回(帧差瞬态):按钮随气泡卸载,先禁用防误触
|
|
1248
|
-
if (button) button.disabled = true
|
|
1249
|
-
return
|
|
1250
|
-
}
|
|
1251
|
-
if (!button) {
|
|
1252
|
-
const official = actionsRow.querySelector('button:not([' + STEER_BTN_FLAG + '])')
|
|
1253
|
-
if (!official) return
|
|
1254
|
-
button = document.createElement('button')
|
|
1255
|
-
button.type = 'button'
|
|
1256
|
-
button.setAttribute(STEER_BTN_FLAG, '')
|
|
1257
|
-
// 克隆官方按钮类名:尺寸/hover/悬停显隐(reveal)全部原生
|
|
1258
|
-
button.className = official.className
|
|
1259
|
-
button.addEventListener('click', () => withdraw(row))
|
|
1260
|
-
button.appendChild(steerRecallSvg())
|
|
1261
|
-
actionsRow.appendChild(button)
|
|
1262
|
-
}
|
|
1263
|
-
syncButton(button, row)
|
|
1264
|
-
})
|
|
1265
|
-
}
|
|
1266
|
-
scanRef.current = scan
|
|
1267
|
-
const observer = new MutationObserver(scan)
|
|
1268
|
-
observer.observe(document.documentElement, { childList: true, subtree: true })
|
|
1269
|
-
scan()
|
|
1270
|
-
return () => {
|
|
1271
|
-
scanRef.current = null
|
|
1272
|
-
observer.disconnect()
|
|
1273
|
-
document.querySelectorAll('[' + STEER_BTN_FLAG + ']').forEach((button) => button.remove())
|
|
1274
|
-
}
|
|
1275
|
-
}, [])
|
|
1276
|
-
// 行集/开关/忙碌变化即时重扫(观察器只覆盖 DOM 变更,状态变化需主动对账)
|
|
1277
|
-
useEffect(() => {
|
|
1278
|
-
if (scanRef.current) scanRef.current()
|
|
1279
|
-
}, [queue, enabled, busy, queueMutable])
|
|
1280
|
-
if (session === undefined || inputActions === undefined || updateQueue === undefined) return null
|
|
1281
|
-
if (!enabled || !queueMutable) return null
|
|
1282
|
-
return h('div', { className: 'sm-steer-host' })
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
return {
|
|
1286
|
-
inject: ['slots', 'sessions', 'workspaces'],
|
|
1287
|
-
apply(ctx) {
|
|
1288
|
-
const sessions = ctx.get('sessions')
|
|
1289
|
-
const workspaces = ctx.get('workspaces')
|
|
1290
|
-
|
|
1291
|
-
// 样式挂载在宿主文档级:设置页未打开时面板不存在,
|
|
1292
|
-
// 样式若随面板注入则面板裸样式渲染
|
|
1293
|
-
ctx.effect(() => {
|
|
1294
|
-
const style = document.createElement('style')
|
|
1295
|
-
style.textContent = CSS
|
|
1296
|
-
document.head.appendChild(style)
|
|
1297
|
-
return () => style.remove()
|
|
1298
|
-
}, 'session-manager styles')
|
|
1299
|
-
|
|
1300
|
-
// 归档快照差分:新增驱动通知,文案携带会话标题
|
|
1301
|
-
let previous
|
|
1302
|
-
const unsubscribe = workspaces.list.subscribe(() => {
|
|
1303
|
-
const step = archiveToastStep(previous, workspaces.list.getSnapshot())
|
|
1304
|
-
previous = step.state
|
|
1305
|
-
if (step.added.length > 0) {
|
|
1306
|
-
// 会话行快照与面板投影同源取 byId,标题映射为通知文案行数据
|
|
1307
|
-
const listState = sessions.list.getSnapshot()
|
|
1308
|
-
const byId = (listState && listState.byId) || {}
|
|
1309
|
-
const rows = Object.keys(byId).map((id) => ({ id, title: byId[id].displayTitle }))
|
|
1310
|
-
toast(archiveToastText(step.added, rows))
|
|
1311
|
-
}
|
|
1312
|
-
})
|
|
1313
|
-
|
|
1314
|
-
ctx.slots.inject('settings.section', () =>
|
|
1315
|
-
ctx.slots.register(
|
|
1316
|
-
{ name: 'settings.section', id: 'session-manager', order: 46, label: '会话归档' },
|
|
1317
|
-
() => React.createElement(SessionManagerPanel, { sessions, workspaces }),
|
|
1318
|
-
))
|
|
1319
|
-
|
|
1320
|
-
// 历史输入回溯入口:官方 conversation.input.dock 插槽(dsh-client-ui-goal 同构)。
|
|
1321
|
-
// 宿主缺该插槽时注册抛错即禁用本功能,不阻塞其余能力
|
|
1322
|
-
try {
|
|
1323
|
-
ctx.slots.inject('conversation.input.dock', () =>
|
|
1324
|
-
ctx.slots.register(
|
|
1325
|
-
{ name: 'conversation.input.dock', id: 'session-manager-history', order: 90 },
|
|
1326
|
-
HistoryDock,
|
|
1327
|
-
))
|
|
1328
|
-
} catch (error) {
|
|
1329
|
-
console.warn('[session-manager] 历史输入入口未注册(宿主无 conversation.input.dock 插槽)', error)
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
// 插话撤回入口:未应用的插话撤回到输入框重新编辑。RPC 走会话绑定面
|
|
1333
|
-
// binding.session.updateQueue(RemoteResult 不抛错,与官方 conversation 服务
|
|
1334
|
-
// 无耦合);会话面缺失或无 updateQueue(旧宿主)返回空 props,组件不渲染
|
|
1335
|
-
try {
|
|
1336
|
-
ctx.slots.inject('conversation.input.dock', () =>
|
|
1337
|
-
ctx.slots.register(
|
|
1338
|
-
{
|
|
1339
|
-
name: 'conversation.input.dock',
|
|
1340
|
-
id: 'session-manager-steer',
|
|
1341
|
-
order: 25,
|
|
1342
|
-
inject: (sessionId) => {
|
|
1343
|
-
const binding = sessions.binding(sessionId)
|
|
1344
|
-
const updateQueue = binding && binding.session && binding.session.updateQueue
|
|
1345
|
-
if (typeof updateQueue !== 'function') return {}
|
|
1346
|
-
return { updateQueue: (itemId, action) => binding.session.updateQueue(itemId, action) }
|
|
1347
|
-
},
|
|
1348
|
-
},
|
|
1349
|
-
SteerRecallDock,
|
|
1350
|
-
))
|
|
1351
|
-
} catch (error) {
|
|
1352
|
-
console.warn('[session-manager] 插话撤回入口未注册(宿主无 conversation.input.dock 插槽)', error)
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
ctx.effect(() => unsubscribe, 'session-manager archived diff')
|
|
1356
|
-
|
|
1357
|
-
function SessionManagerPanel({ sessions: sessionSvc, workspaces: workspaceSvc }) {
|
|
1358
|
-
const listState = useSnapshot(sessionSvc.list)
|
|
1359
|
-
const workspaceState = useSnapshot(workspaceSvc.list)
|
|
1360
|
-
return React.createElement(SessionManagerApp, {
|
|
1361
|
-
rows: projectRows(listState, (workspaceState && workspaceState.archivedSessionIds) || [], workspaceState),
|
|
1362
|
-
listState,
|
|
1363
|
-
})
|
|
1364
|
-
}
|
|
1365
|
-
},
|
|
1366
|
-
}
|
|
1367
|
-
},
|
|
1368
|
-
})
|
|
1
|
+
// dsh-session-manager Client 半区:settings.section 归档面板 + 归档/操作反馈通知。
|
|
2
|
+
// 归档快照来自官方 workspace.follow 客户端模型(ctx.get('workspaces')),会话行来自
|
|
3
|
+
// ctx.get('sessions');面板数据 = 会话行 ∩ 归档集合(纯投影),通知由 archived
|
|
4
|
+
// 增量帧的集合差分驱动,经公共依赖 @mzzsfy/dsh-toast 展示。
|
|
5
|
+
// 浏览器半区经 webServer 路由('/api/session-manager/*')访问 Host。打包为单文件自包含格式,无法跨文件
|
|
6
|
+
// require;与 src/core.mjs 镜像的纯函数(projectRows / archiveToastStep /
|
|
7
|
+
// archiveToastText / projectDeletedRows / pageArchiveRows /
|
|
8
|
+
// groupArchiveRowsByWorkspace / filterArchiveRows)修改需两处同步。
|
|
9
|
+
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
id: '@mzzsfy/dsh-session-manager',
|
|
12
|
+
factory(require) {
|
|
13
|
+
const React = require('react')
|
|
14
|
+
const { useState, useEffect, useRef, useSyncExternalStore } = React
|
|
15
|
+
|
|
16
|
+
// 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
|
|
17
|
+
// 短名(发现页收录显示形态);该插件未就绪时入队,由其启动时排空
|
|
18
|
+
const NAV_ICON = { '会话归档': 'archive', 'dsh-session-manager': 'archive' }
|
|
19
|
+
if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
|
|
20
|
+
else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
|
|
21
|
+
else window.__navicIconQueue = [NAV_ICON]
|
|
22
|
+
|
|
23
|
+
// 通知出口:公共依赖 @mzzsfy/dsh-toast(external require,宿主占位条目由
|
|
24
|
+
// 本插件 cordis.patch.yml 代挂)
|
|
25
|
+
const { show: toast } = require('@mzzsfy/dsh-toast/client')
|
|
26
|
+
|
|
27
|
+
// 视觉方案:归档台账——全部取宿主 alias 令牌(双主题自适应,不造色),数据列
|
|
28
|
+
// 用宿主代码字体(会话即文件的档案词汇),签名元素为托盘内的归档轨
|
|
29
|
+
const CSS = [
|
|
30
|
+
'.sm-panel { display:flex; flex-direction:column; gap:8px; min-width:0; color:var(--dsw-alias-label-primary); font:var(--dsw-font-s-14); }',
|
|
31
|
+
'.sm-head { display:flex; align-items:baseline; gap:8px; }',
|
|
32
|
+
'.sm-head__title { font:var(--dsw-font-m-18); }',
|
|
33
|
+
'.sm-head__count { font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
|
|
34
|
+
'.sm-head__hint { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption); }',
|
|
35
|
+
'.sm-tray { position:relative; background:var(--dsw-alias-bg-module-platform); border-radius:10px; padding:4px 0; }',
|
|
36
|
+
'.sm-tray::before { content:""; position:absolute; left:15px; top:12px; bottom:12px; width:1px; background:var(--dsw-alias-border-l3); }',
|
|
37
|
+
'.sm-row { position:relative; display:grid; grid-template-columns:88px minmax(0, 1fr) auto; align-items:center; gap:8px;',
|
|
38
|
+
' padding:7px 10px 7px 28px; border-radius:8px; }',
|
|
39
|
+
'.sm-row::before { content:""; position:absolute; left:13px; top:50%; width:5px; height:5px; margin:-2.5px 0 0;',
|
|
40
|
+
' border-radius:50%; background:var(--dsw-alias-border-l4); }',
|
|
41
|
+
'.sm-row:hover, .sm-row:focus-within { background:var(--dsw-alias-interactive-bg-hover); }',
|
|
42
|
+
'.sm-row:hover::before, .sm-row:focus-within::before { background:var(--dsw-alias-state-business-primary); }',
|
|
43
|
+
'.sm-row--armed { background:var(--dsw-alias-interactive-bg-hover-danger); }',
|
|
44
|
+
'.sm-row--armed::before { background:var(--dsw-alias-state-error-primary); }',
|
|
45
|
+
'.sm-row--busy { opacity:.45; pointer-events:none; }',
|
|
46
|
+
'.sm-row__time, .sm-row__size { font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; }',
|
|
47
|
+
'.sm-row__size { color:var(--dsw-alias-state-error-primary); }',
|
|
48
|
+
'.sm-row__title-wrap { display:flex; align-items:center; gap:6px; min-width:0; }',
|
|
49
|
+
'.sm-row__ws { flex:none; max-width:40%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;',
|
|
50
|
+
' padding:0 6px; border-radius:999px; font:11px/18px var(--ds-font-family-code, monospace);',
|
|
51
|
+
' color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }',
|
|
52
|
+
'.sm-row__ws--none { background:transparent; box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l3); }',
|
|
53
|
+
'.sm-row__title { font:var(--dsw-font-s-strong-14); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
54
|
+
'.sm-row__error { grid-column:2 / -1; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-state-error-primary); }',
|
|
55
|
+
'.sm-row__path { grid-column:2 / -1; font:12px/16px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption);',
|
|
56
|
+
' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
57
|
+
'.sm-deleted { display:flex; flex-direction:column; gap:8px; min-width:0; }',
|
|
58
|
+
'.sm-row__actions { display:flex; gap:2px; justify-content:flex-end; opacity:0; transition:opacity 0.12s ease; }',
|
|
59
|
+
'.sm-row:hover .sm-row__actions, .sm-row:focus-within .sm-row__actions, .sm-row--armed .sm-row__actions { opacity:1; }',
|
|
60
|
+
'.sm-btn { border:0; background:transparent; cursor:pointer; padding:2px 8px; border-radius:6px;',
|
|
61
|
+
' font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-tertiary); }',
|
|
62
|
+
'.sm-btn:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
|
|
63
|
+
'.sm-btn--restore:hover { color:var(--dsw-alias-state-business-primary); }',
|
|
64
|
+
'.sm-btn--danger:hover { color:var(--dsw-alias-state-error-primary); }',
|
|
65
|
+
'.sm-btn--confirm { color:var(--dsw-alias-state-error-primary); border:1px solid var(--dsw-alias-state-error-primary); }',
|
|
66
|
+
'.sm-btn--confirm:hover { background:var(--dsw-alias-interactive-bg-hover-danger); }',
|
|
67
|
+
'.sm-btn:disabled { opacity:.45; cursor:default; background:transparent; color:var(--dsw-alias-label-tertiary); }',
|
|
68
|
+
'.sm-btn--confirm:disabled { color:var(--dsw-alias-state-error-primary); }',
|
|
69
|
+
'.sm-btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
|
|
70
|
+
// 删除确认弹窗:遮罩 + 居中卡片;高 z-index 压过设置面板层,点击遮罩不关闭(强制显式选择)
|
|
71
|
+
'.sm-dialog { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center;',
|
|
72
|
+
' background:rgba(0,0,0,.45); }',
|
|
73
|
+
'.sm-dialog__card { width:min(420px, calc(100vw - 48px)); border-radius:12px; padding:20px;',
|
|
74
|
+
' background:var(--dsw-alias-surface-primary, light-dark(#fff, #1f2126)); color:inherit;',
|
|
75
|
+
' box-shadow:0 12px 40px rgba(0,0,0,.3); }',
|
|
76
|
+
'.sm-dialog__title { font:var(--dsw-font-m-strong-16, 600 16px/24px sans-serif);',
|
|
77
|
+
' color:var(--dsw-alias-state-error-primary); margin:0 0 10px; }',
|
|
78
|
+
'.sm-dialog__body { font:var(--dsw-font-s-14, 14px/22px sans-serif); color:var(--dsw-alias-label-secondary); margin:0 0 8px; }',
|
|
79
|
+
'.sm-dialog__warn { font:var(--dsw-font-s-14, 14px/22px sans-serif); color:var(--dsw-alias-state-error-primary); margin:0 0 16px; }',
|
|
80
|
+
'.sm-dialog__actions { display:flex; justify-content:flex-end; gap:8px; }',
|
|
81
|
+
'.sm-dialog__btn { border:1px solid var(--dsw-alias-border-l3); background:transparent; cursor:pointer;',
|
|
82
|
+
' padding:6px 16px; border-radius:8px; font:var(--dsw-font-s-strong-14); color:var(--dsw-alias-label-secondary); }',
|
|
83
|
+
'.sm-dialog__btn:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
|
|
84
|
+
'.sm-dialog__btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
|
|
85
|
+
'.sm-dialog__btn--confirm { border-color:var(--dsw-alias-state-error-primary); color:var(--dsw-alias-state-error-primary); }',
|
|
86
|
+
'.sm-dialog__btn--confirm:hover { background:var(--dsw-alias-interactive-bg-hover-danger); color:var(--dsw-alias-state-error-primary); }',
|
|
87
|
+
'.sm-empty { padding:24px 12px; text-align:center; color:var(--dsw-alias-label-caption); }',
|
|
88
|
+
'.sm-empty__hint { font:var(--dsw-font-xxs-12); margin-top:2px; }',
|
|
89
|
+
// 归档库二级视图:工具行(搜索)与工作区分组、分页展开按钮
|
|
90
|
+
'.sm-lib { display:flex; flex-direction:column; gap:8px; }',
|
|
91
|
+
'.sm-libbar { display:flex; align-items:center; gap:8px; }',
|
|
92
|
+
'.sm-libbar__search { flex:1; min-width:0; padding:4px 10px; border-radius:8px; color-scheme:light dark;',
|
|
93
|
+
' border:1px solid var(--dsw-alias-border-l3); background:transparent; color:inherit; font:var(--dsw-font-s-14); }',
|
|
94
|
+
'.sm-libbar__search:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
|
|
95
|
+
'.sm-libbar__count { flex:none; font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
|
|
96
|
+
'.sm-group { display:flex; flex-direction:column; }',
|
|
97
|
+
'.sm-group__head { display:flex; align-items:center; gap:8px; width:100%; border:0; background:transparent; cursor:pointer;',
|
|
98
|
+
' padding:8px 10px; border-radius:8px; text-align:left; color:inherit; font:var(--dsw-font-s-strong-14); }',
|
|
99
|
+
'.sm-group__head:hover { background:var(--dsw-alias-interactive-bg-hover); }',
|
|
100
|
+
'.sm-group__head:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
|
|
101
|
+
'.sm-group__chev { flex:none; color:var(--dsw-alias-label-caption); transition:transform .15s ease; }',
|
|
102
|
+
'.sm-group__head--on .sm-group__chev { transform:rotate(90deg); }',
|
|
103
|
+
'.sm-group__name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
|
|
104
|
+
'.sm-group__count { margin-left:auto; flex:none; font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
|
|
105
|
+
'.sm-group__body { padding-bottom:4px; }',
|
|
106
|
+
'.sm-more { display:block; width:100%; border:0; background:transparent; cursor:pointer; padding:8px;',
|
|
107
|
+
' text-align:center; border-radius:8px; font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-caption); }',
|
|
108
|
+
'.sm-more:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
|
|
109
|
+
'.sm-more:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
|
|
110
|
+
'@media (prefers-reduced-motion: reduce) { .sm-group__chev { transition:none; } }',
|
|
111
|
+
'@media (prefers-reduced-motion: reduce) { .sm-row__actions { transition:none; } }',
|
|
112
|
+
// 自动归档配置行:数值输入内联呈现,窄面板可换行
|
|
113
|
+
'.sm-cfg { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top:10px;',
|
|
114
|
+
' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
|
|
115
|
+
'.sm-cfg__field { display:inline-flex; align-items:center; gap:4px; }',
|
|
116
|
+
'.sm-cfg__field input { width:56px; padding:2px 6px; border-radius:6px; color-scheme:light dark;',
|
|
117
|
+
' border:1px solid light-dark(rgba(15,17,21,.18), rgba(255,255,255,.22));',
|
|
118
|
+
' background:transparent; color:inherit; font:inherit; }',
|
|
119
|
+
'.sm-cfg__field input:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary, #1677ff); outline-offset:0; }',
|
|
120
|
+
].join('\n')
|
|
121
|
+
|
|
122
|
+
const UNARCHIVE_URL = '/api/session-manager/unarchive'
|
|
123
|
+
const DELETE_URL = '/api/session-manager/delete'
|
|
124
|
+
const INFO_URL = '/api/session-manager/info'
|
|
125
|
+
const DELETED_URL = '/api/session-manager/deleted'
|
|
126
|
+
const REMOUNT_URL = '/api/session-manager/remount'
|
|
127
|
+
const FORGET_URL = '/api/session-manager/forget'
|
|
128
|
+
const STATUS_URL = '/api/session-manager/status'
|
|
129
|
+
|
|
130
|
+
// 删除确认链文案:两击行内确认后弹窗强提示,弹窗内确认才发请求。
|
|
131
|
+
// 删除产物不可再生,其他插件(用量统计等)依赖会话产物,删后其会话级数据一并消失
|
|
132
|
+
const DELETE_BUTTON_TITLE = '删除会话产物会影响其他插件的工作(用量统计等依赖会话产物);若无特殊需求,请不要删除。删除需再次行内确认与弹窗确认。'
|
|
133
|
+
const DELETE_DIALOG_TITLE = '确认删除该会话?'
|
|
134
|
+
const DELETE_DIALOG_BODY_PREFIX = '即将删除会话「'
|
|
135
|
+
const DELETE_DIALOG_BODY_SUFFIX = '」。'
|
|
136
|
+
const DELETE_DIALOG_WARN = '删除会影响其他插件的工作,若无特殊需求,请不要删除;删除后请到「已删除」区查看找回方式。'
|
|
137
|
+
const DELETE_TOAST_OS = '已移入系统回收站;还原后可在「已删除」区重新挂载'
|
|
138
|
+
const DELETE_TOAST_QUARANTINE = '环境无系统回收站,已移入插件回收区;可在「已删除」区直接重新挂载找回'
|
|
139
|
+
|
|
140
|
+
// 请求默认超时:host 被批量解压等同步任务阻塞时路由会迟滞数秒,
|
|
141
|
+
// 无超时则浮层停在「正在读取…」假死;超时按错误抛出,由调用方兜底,
|
|
142
|
+
// 轮询链继续,host 恢复后自动跟上
|
|
143
|
+
const API_TIMEOUT_MS = 8 * 1000
|
|
144
|
+
|
|
145
|
+
async function api(url, options) {
|
|
146
|
+
const controller = new AbortController()
|
|
147
|
+
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(url, {
|
|
150
|
+
headers: { 'content-type': 'application/json' },
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
...options,
|
|
153
|
+
})
|
|
154
|
+
const payload = await response.json().catch(() => ({}))
|
|
155
|
+
if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
|
|
156
|
+
return payload
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error && error.name === 'AbortError') throw new Error('响应超时(' + Math.round(API_TIMEOUT_MS / 1000) + 's),宿主繁忙中')
|
|
159
|
+
throw error
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function h(type, props) {
|
|
166
|
+
const children = Array.prototype.slice.call(arguments, 2)
|
|
167
|
+
return React.createElement.apply(React, [type, props || null].concat(children))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fmtTime(ms) {
|
|
171
|
+
const t = Number(ms)
|
|
172
|
+
if (t !== t || !t) return '—'
|
|
173
|
+
const d = new Date(t)
|
|
174
|
+
const pad = (n) => (n < 10 ? '0' : '') + n
|
|
175
|
+
const date = d.getFullYear() === new Date().getFullYear()
|
|
176
|
+
? pad(d.getMonth() + 1) + '/' + pad(d.getDate())
|
|
177
|
+
: d.getFullYear() + '/' + pad(d.getMonth() + 1) + '/' + pad(d.getDate())
|
|
178
|
+
return date + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function fmtSize(bytes) {
|
|
182
|
+
const n = Number(bytes)
|
|
183
|
+
if (n !== n || n < 0) return '—'
|
|
184
|
+
const UNIT_STEP = 1024
|
|
185
|
+
if (n < UNIT_STEP) return n + ' B'
|
|
186
|
+
if (n < UNIT_STEP * UNIT_STEP) return (n / UNIT_STEP).toFixed(1) + ' KB'
|
|
187
|
+
return (n / (UNIT_STEP * UNIT_STEP)).toFixed(1) + ' MB'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function useSnapshot(source) {
|
|
191
|
+
return useSyncExternalStore(
|
|
192
|
+
(listener) => source.subscribe(listener),
|
|
193
|
+
() => source.getSnapshot(),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 工作区路径末段(官方 workspaceTitleOf 同构,镜像 core.mjs 同名函数)
|
|
198
|
+
function workspaceTitleOf(path) {
|
|
199
|
+
const trimmed = String(path).replace(/[/\\]+$/, '')
|
|
200
|
+
const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
201
|
+
return trimmed.slice(separator + 1)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 会话所属工作区标题:workspace 账本(sessionIds)一级映射与官方
|
|
205
|
+
// dsh-client-ui-workspace workspaceBySession 同构;cwd 回退为本插件收紧决策
|
|
206
|
+
// (官方回退 cwd basename 无需命中工作区,此处要求命中登记路径,防未登记目录名
|
|
207
|
+
// 冒充工作区),未命中 null(未分组)(镜像 core.mjs workspaceTitleForSession)
|
|
208
|
+
function workspaceTitleForSession(workspaces, sessionId, cwd) {
|
|
209
|
+
const items = Array.isArray(workspaces) ? workspaces : []
|
|
210
|
+
for (const workspace of items) {
|
|
211
|
+
if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
|
|
212
|
+
return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
|
|
216
|
+
if (!normalizedCwd) return null
|
|
217
|
+
for (const workspace of items) {
|
|
218
|
+
if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
|
|
219
|
+
return workspace.title || workspaceTitleOf(workspace.path)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 已归档会话面板行:官方会话行 ∩ 归档集合,按更新时间倒序;workspace 为所属
|
|
226
|
+
// 工作区标题或 null(镜像 core.mjs projectArchiveRows)
|
|
227
|
+
function projectRows(listState, archivedIds, workspaceState) {
|
|
228
|
+
const archived = new Set(archivedIds)
|
|
229
|
+
const byId = (listState && listState.byId) || {}
|
|
230
|
+
const workspaces = (workspaceState && workspaceState.items) || []
|
|
231
|
+
return Object.keys(byId)
|
|
232
|
+
.filter((id) => archived.has(id))
|
|
233
|
+
.map((id) => ({
|
|
234
|
+
id,
|
|
235
|
+
title: byId[id].displayTitle || id,
|
|
236
|
+
updatedAt: byId[id].updatedAt,
|
|
237
|
+
workspace: workspaceTitleForSession(workspaces, id, byId[id].cwd),
|
|
238
|
+
}))
|
|
239
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 已删除面板行:标题回退会话 id,按删除时间倒序(镜像 core.mjs projectDeletedRows)
|
|
243
|
+
function projectDeletedRows(deleted, listState) {
|
|
244
|
+
const byId = (listState && listState.byId) || {}
|
|
245
|
+
return [...(deleted || [])]
|
|
246
|
+
.sort((left, right) => right.deletedAt - left.deletedAt)
|
|
247
|
+
.map((item) => ({
|
|
248
|
+
sessionId: item.sessionId,
|
|
249
|
+
path: item.path,
|
|
250
|
+
deletedAt: item.deletedAt,
|
|
251
|
+
title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
|
|
252
|
+
}))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
// Toast 差分守卫:连续两个 ready 快照才计新增(镜像 core.mjs archiveToastStep)。
|
|
257
|
+
// 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
|
|
258
|
+
// 归档事件,启动与重连首装不误报。差分 Set 每帧构建一次,与 core.diffArchived 同构
|
|
259
|
+
function archiveToastStep(previous, snapshot) {
|
|
260
|
+
const ready = Boolean(snapshot && snapshot.phase === 'ready')
|
|
261
|
+
const ids = (snapshot && snapshot.archivedSessionIds) || []
|
|
262
|
+
const baseline = previous !== undefined && previous.ready && ready ? new Set(previous.ids) : null
|
|
263
|
+
const added = baseline ? ids.filter((id) => !baseline.has(id)) : []
|
|
264
|
+
return { state: { ready, ids }, added }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 归档通知标题列举上限:超过该数只列前段并以「 等」收尾(镜像 core.mjs 同名常量)
|
|
268
|
+
const TOAST_MAX_TITLES = 3
|
|
269
|
+
|
|
270
|
+
// 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
|
|
271
|
+
// 「 等」收尾,计数恒为真实总数;标题缺失、空白或行不存在回退会话 id
|
|
272
|
+
// (镜像 core.mjs archiveToastText,修改需两处同步)
|
|
273
|
+
function archiveToastText(addedIds, rows) {
|
|
274
|
+
const added = Array.isArray(addedIds) ? addedIds : []
|
|
275
|
+
if (added.length === 0) return ''
|
|
276
|
+
const safeRows = Array.isArray(rows) ? rows : []
|
|
277
|
+
const titleById = new Map()
|
|
278
|
+
for (const row of safeRows) {
|
|
279
|
+
if (!row || row.id === undefined || row.id === null) continue
|
|
280
|
+
const trimmed = String(row.title ?? '').trim()
|
|
281
|
+
if (trimmed !== '') titleById.set(String(row.id), trimmed)
|
|
282
|
+
}
|
|
283
|
+
const names = added.map((id) => titleById.get(String(id)) || String(id))
|
|
284
|
+
if (names.length === 1) return '会话「' + names[0] + '」已归档'
|
|
285
|
+
const listed = names.slice(0, TOAST_MAX_TITLES)
|
|
286
|
+
return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 归档库主列表默认展开行数与手动展开步长(镜像 core.mjs 同名常量)
|
|
290
|
+
const ARCHIVE_PAGE_SIZE = 100
|
|
291
|
+
|
|
292
|
+
// 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理
|
|
293
|
+
// (镜像 core.mjs pageArchiveRows,修改需两处同步)
|
|
294
|
+
function pageArchiveRows(rows, visibleCount) {
|
|
295
|
+
const list = Array.isArray(rows) ? rows : []
|
|
296
|
+
const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
|
|
297
|
+
const visible = list.slice(0, count)
|
|
298
|
+
return { visible, remaining: list.length - visible.length }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序,
|
|
302
|
+
// 组间按组内最新 updatedAt 倒序(镜像 core.mjs groupArchiveRowsByWorkspace,修改需两处同步)
|
|
303
|
+
function groupArchiveRowsByWorkspace(rows) {
|
|
304
|
+
const list = Array.isArray(rows) ? rows : []
|
|
305
|
+
const byKey = new Map()
|
|
306
|
+
for (const row of list) {
|
|
307
|
+
const title = row.workspace ? String(row.workspace) : ''
|
|
308
|
+
if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
|
|
309
|
+
const group = byKey.get(title)
|
|
310
|
+
group.rows.push(row)
|
|
311
|
+
if (row.updatedAt > group.latest) group.latest = row.updatedAt
|
|
312
|
+
}
|
|
313
|
+
return [...byKey.values()]
|
|
314
|
+
.sort((left, right) => right.latest - left.latest)
|
|
315
|
+
.map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,空查询原样
|
|
319
|
+
// 返回全量(镜像 core.mjs filterArchiveRows,修改需两处同步)
|
|
320
|
+
function filterArchiveRows(rows, query) {
|
|
321
|
+
const list = Array.isArray(rows) ? rows : []
|
|
322
|
+
const needle = String(query ?? '').trim().toLowerCase()
|
|
323
|
+
if (needle === '') return list
|
|
324
|
+
return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function ArchiveRow(props) {
|
|
328
|
+
const row = props.row
|
|
329
|
+
const armed = props.armed
|
|
330
|
+
const busy = props.busy
|
|
331
|
+
const confirm = props.confirm
|
|
332
|
+
const actions = armed
|
|
333
|
+
? [
|
|
334
|
+
h('button', {
|
|
335
|
+
key: 'confirm',
|
|
336
|
+
className: 'sm-btn sm-btn--confirm',
|
|
337
|
+
disabled: busy || !confirm || Boolean(confirm.error),
|
|
338
|
+
onClick: props.onConfirm,
|
|
339
|
+
}, '移入回收站'),
|
|
340
|
+
h('button', { key: 'cancel', className: 'sm-btn', disabled: busy, onClick: props.onDisarm }, '取消'),
|
|
341
|
+
]
|
|
342
|
+
: [
|
|
343
|
+
h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
|
|
344
|
+
h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
|
|
345
|
+
]
|
|
346
|
+
return h('div', {
|
|
347
|
+
className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
|
|
348
|
+
},
|
|
349
|
+
armed && confirm && !confirm.error
|
|
350
|
+
? h('span', { key: 'meta', className: 'sm-row__size' }, confirm.missing ? '产物已丢失' : fmtSize(confirm.sizeBytes))
|
|
351
|
+
: h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.updatedAt).toLocaleString() }, fmtTime(row.updatedAt)),
|
|
352
|
+
h('span', { className: 'sm-row__title-wrap' },
|
|
353
|
+
row.workspace
|
|
354
|
+
? h('span', { className: 'sm-row__ws', title: row.workspace }, row.workspace)
|
|
355
|
+
: h('span', { className: 'sm-row__ws sm-row__ws--none', title: '该会话不属于任何已登记工作区' }, '未分组'),
|
|
356
|
+
h('span', { className: 'sm-row__title', title: row.title }, row.title),
|
|
357
|
+
),
|
|
358
|
+
h('div', { className: 'sm-row__actions' }, actions),
|
|
359
|
+
confirm && confirm.error ? h('span', { className: 'sm-row__error' }, confirm.error) : null,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 删除确认弹窗:确认链最后一环,行内两击确认之后仍需在此显式确认才发请求。
|
|
364
|
+
// 无确认前不发任何请求;遮罩不响应点击,取消与确认按钮是仅有的出口。
|
|
365
|
+
// 初始焦点落「取消」(安全默认出口),Escape 等价取消;不做焦点圈闭
|
|
366
|
+
// (tab 可离开弹窗但不会误触发删除,圈闭实现成本不成比例)
|
|
367
|
+
function DeleteDialog(props) {
|
|
368
|
+
const row = props.row
|
|
369
|
+
const busy = props.busy
|
|
370
|
+
const cancelRef = useRef(null)
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (cancelRef.current) cancelRef.current.focus()
|
|
373
|
+
const onKey = (event) => {
|
|
374
|
+
if (event.key === 'Escape' && !busy) props.onCancel()
|
|
375
|
+
}
|
|
376
|
+
window.addEventListener('keydown', onKey)
|
|
377
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
378
|
+
}, [])
|
|
379
|
+
return h('div', { className: 'sm-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'sm-dialog-title', 'aria-describedby': 'sm-dialog-body' },
|
|
380
|
+
h('div', { className: 'sm-dialog__card' },
|
|
381
|
+
h('p', { id: 'sm-dialog-title', className: 'sm-dialog__title' }, DELETE_DIALOG_TITLE),
|
|
382
|
+
h('p', { id: 'sm-dialog-body', className: 'sm-dialog__body' }, DELETE_DIALOG_BODY_PREFIX + (row.title || row.id) + DELETE_DIALOG_BODY_SUFFIX),
|
|
383
|
+
h('p', { className: 'sm-dialog__warn' }, DELETE_DIALOG_WARN),
|
|
384
|
+
h('div', { className: 'sm-dialog__actions' },
|
|
385
|
+
h('button', { ref: cancelRef, className: 'sm-dialog__btn', disabled: busy, onClick: props.onCancel }, '取消'),
|
|
386
|
+
h('button', { className: 'sm-dialog__btn sm-dialog__btn--confirm', disabled: busy, onClick: props.onConfirm }, '确认删除'),
|
|
387
|
+
),
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function DeletedRow(props) {
|
|
393
|
+
const row = props.row
|
|
394
|
+
const busy = props.busy
|
|
395
|
+
return h('div', { className: 'sm-row' + (busy ? ' sm-row--busy' : '') },
|
|
396
|
+
h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
|
|
397
|
+
h('span', { className: 'sm-row__title', title: row.title }, row.title),
|
|
398
|
+
h('div', { className: 'sm-row__actions' },
|
|
399
|
+
h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
|
|
400
|
+
h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
|
|
401
|
+
),
|
|
402
|
+
h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 已删除分区:仅非空渲染;还原指引在分区头部,行内提供重挂载与移除记录
|
|
407
|
+
function DeletedSection(props) {
|
|
408
|
+
const rows = props.rows
|
|
409
|
+
if (rows.length === 0) return null
|
|
410
|
+
return h('div', { className: 'sm-deleted' },
|
|
411
|
+
h('div', { className: 'sm-head' },
|
|
412
|
+
h('span', { className: 'sm-head__title' }, '已删除'),
|
|
413
|
+
h('span', { className: 'sm-head__count' }, rows.length + ' 条'),
|
|
414
|
+
),
|
|
415
|
+
h('div', { className: 'sm-head__hint' },
|
|
416
|
+
'到系统回收站将会话文件夹还原到原位置,再点「重新挂载」找回;回收区暂存的会话直接点「重新挂载」即可找回;清空回收站后无法找回。'),
|
|
417
|
+
h('div', { className: 'sm-tray' },
|
|
418
|
+
rows.map((row) => h(DeletedRow, {
|
|
419
|
+
key: row.sessionId,
|
|
420
|
+
row,
|
|
421
|
+
busy: props.busyId === row.sessionId,
|
|
422
|
+
onRemount: () => props.onRemount(row),
|
|
423
|
+
onForget: () => props.onForget(row),
|
|
424
|
+
}))),
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function SessionManagerApp(props) {
|
|
429
|
+
const rows = props.rows
|
|
430
|
+
const listState = props.listState
|
|
431
|
+
const [busyId, setBusyId] = useState(null)
|
|
432
|
+
const [armedId, setArmedId] = useState(null)
|
|
433
|
+
const [confirms, setConfirms] = useState({})
|
|
434
|
+
// 删除弹窗受控状态:非 null 即弹窗展示中,确认后才真正发删除请求
|
|
435
|
+
const [pendingDelete, setPendingDelete] = useState(null)
|
|
436
|
+
const [deleted, setDeleted] = useState([])
|
|
437
|
+
const [periodic, setPeriodic] = useState(null)
|
|
438
|
+
// 归档库视图状态:recent 为主列表固定首页,library 为工作区分组浏览 + 标题
|
|
439
|
+
// 搜索;分组默认收起,展开即见首页,组内沿用同一分页机制
|
|
440
|
+
const [view, setView] = useState('recent')
|
|
441
|
+
const [query, setQuery] = useState('')
|
|
442
|
+
const [libraryCount, setLibraryCount] = useState(ARCHIVE_PAGE_SIZE)
|
|
443
|
+
const [openGroups, setOpenGroups] = useState(() => new Set())
|
|
444
|
+
const [groupCounts, setGroupCounts] = useState({})
|
|
445
|
+
|
|
446
|
+
function toggleGroup(key) {
|
|
447
|
+
setOpenGroups((prev) => {
|
|
448
|
+
const next = new Set(prev)
|
|
449
|
+
if (next.has(key)) next.delete(key)
|
|
450
|
+
else next.add(key)
|
|
451
|
+
return next
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function expandGroup(key) {
|
|
456
|
+
setGroupCounts((prev) => ({ ...prev, [key]: (prev[key] || ARCHIVE_PAGE_SIZE) + ARCHIVE_PAGE_SIZE }))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function refreshDeleted() {
|
|
460
|
+
api(DELETED_URL)
|
|
461
|
+
.then((payload) => setDeleted((payload && payload.deleted) || []))
|
|
462
|
+
.catch((error) => {
|
|
463
|
+
// 加载失败保留旧数据:清空会让用户误读为台账已清空(数据丢失假象)
|
|
464
|
+
console.warn('[session-manager] 已删除列表加载失败', error)
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
useEffect(() => { refreshDeleted() }, [])
|
|
469
|
+
|
|
470
|
+
// 周期评估状态仅用于降级提示,加载失败按无提示处理
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
api(STATUS_URL)
|
|
473
|
+
.then((payload) => setPeriodic(payload && payload.periodic))
|
|
474
|
+
.catch(() => {})
|
|
475
|
+
}, [])
|
|
476
|
+
|
|
477
|
+
function run(sessionId, action, successText) {
|
|
478
|
+
setBusyId(sessionId)
|
|
479
|
+
return action()
|
|
480
|
+
.then((result) => {
|
|
481
|
+
if (result && result.partial) toast(result.message, { kind: 'ok' })
|
|
482
|
+
else if (result && result.message) toast(result.message, { kind: 'ok' })
|
|
483
|
+
else toast(successText || '操作完成', { kind: 'ok' })
|
|
484
|
+
setArmedId(null)
|
|
485
|
+
setConfirms({})
|
|
486
|
+
})
|
|
487
|
+
.catch((error) => toast(error && error.message ? error.message : String(error), { kind: 'error', sticky: true }))
|
|
488
|
+
.then(() => setBusyId(null))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 删除确认链:击删除(拉体积)→ 行内确认 → 弹窗确认,弹窗内确认才发请求
|
|
492
|
+
function onDelete(row) {
|
|
493
|
+
if (armedId !== row.id) {
|
|
494
|
+
setArmedId(row.id)
|
|
495
|
+
// 首段拉取标题 / 时间 / 体积;产物已缺失(missing)仍可确认,删除仅清理列表
|
|
496
|
+
if (confirms[row.id] === undefined) {
|
|
497
|
+
api(INFO_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) })
|
|
498
|
+
.then((info) => setConfirms((prev) => ({ ...prev, [row.id]: info.supported
|
|
499
|
+
? { sizeBytes: info.sizeBytes, missing: Boolean(info.missing) }
|
|
500
|
+
: { error: '当前存储后端不支持按会话删除' } })))
|
|
501
|
+
.catch((error) => setConfirms((prev) => ({ ...prev, [row.id]: { error: String(error.message || error) } })))
|
|
502
|
+
}
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
setPendingDelete(row)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 弹窗确认:处置模式区分成功文案——两种模式找回路径不同
|
|
509
|
+
function onDialogConfirm() {
|
|
510
|
+
const row = pendingDelete
|
|
511
|
+
if (row === null) return
|
|
512
|
+
void run(row.id, async () => {
|
|
513
|
+
const body = await api(DELETE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) })
|
|
514
|
+
if (body && body.ok && !body.message) {
|
|
515
|
+
return { ...body, message: body.mode === 'quarantine' ? DELETE_TOAST_QUARANTINE : DELETE_TOAST_OS }
|
|
516
|
+
}
|
|
517
|
+
return body
|
|
518
|
+
}, DELETE_TOAST_OS).then(refreshDeleted)
|
|
519
|
+
setPendingDelete(null)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function onRemount(row) {
|
|
523
|
+
void run(row.sessionId, () => api(REMOUNT_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.sessionId }) }),
|
|
524
|
+
'已重新挂载,会话回到工作区列表')
|
|
525
|
+
.then(refreshDeleted)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function onForget(row) {
|
|
529
|
+
void run(row.sessionId, () => api(FORGET_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.sessionId }) }))
|
|
530
|
+
.then(refreshDeleted)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const renderArchiveRow = (row) => h(ArchiveRow, {
|
|
534
|
+
key: row.id,
|
|
535
|
+
row,
|
|
536
|
+
armed: armedId === row.id,
|
|
537
|
+
busy: busyId === row.id,
|
|
538
|
+
confirm: confirms[row.id],
|
|
539
|
+
onUnarchive: () => {
|
|
540
|
+
void run(row.id, () => api(UNARCHIVE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) }))
|
|
541
|
+
},
|
|
542
|
+
onDelete: () => onDelete(row),
|
|
543
|
+
onConfirm: () => onDelete(row),
|
|
544
|
+
onDisarm: () => { setArmedId(null); setConfirms({}) },
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
// 分页展开按钮:无余量不渲染(React 忽略 null 子节点);key 恒定,
|
|
548
|
+
// 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
|
|
549
|
+
const expandMore = (remaining, onMore) => remaining <= 0
|
|
550
|
+
? null
|
|
551
|
+
: h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
|
|
552
|
+
|
|
553
|
+
// 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
|
|
554
|
+
const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
|
|
555
|
+
const recentTray = h('div', { className: 'sm-tray' },
|
|
556
|
+
rows.length === 0
|
|
557
|
+
? h('div', { className: 'sm-empty' },
|
|
558
|
+
h('div', null, '还没有归档的会话'),
|
|
559
|
+
h('div', { className: 'sm-empty__hint' }, '会话归档后集中显示在这里'))
|
|
560
|
+
: [...recentPage.visible.map(renderArchiveRow),
|
|
561
|
+
recentPage.remaining > 0
|
|
562
|
+
? h('button', { key: 'more', className: 'sm-more', onClick: () => setView('library') }, '按工作区浏览')
|
|
563
|
+
: null],
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
// 归档库区域:有搜索词时命中平铺(分页),无搜索词按工作区分组(默认收起);
|
|
567
|
+
// 仅二级视图构建,recent 视图不做分组与搜索投影,防大集合重渲染放大
|
|
568
|
+
function renderLibrary() {
|
|
569
|
+
const searching = query.trim() !== ''
|
|
570
|
+
const matchedRows = filterArchiveRows(rows, query)
|
|
571
|
+
const searchPage = pageArchiveRows(matchedRows, libraryCount)
|
|
572
|
+
const tray = rows.length === 0
|
|
573
|
+
? h('div', { className: 'sm-empty' }, '还没有归档的会话')
|
|
574
|
+
: searching
|
|
575
|
+
? (matchedRows.length === 0
|
|
576
|
+
? h('div', { className: 'sm-empty' }, '没有匹配的归档会话')
|
|
577
|
+
: h('div', { className: 'sm-tray' },
|
|
578
|
+
[...searchPage.visible.map(renderArchiveRow),
|
|
579
|
+
expandMore(searchPage.remaining, () => setLibraryCount((count) => count + ARCHIVE_PAGE_SIZE))]))
|
|
580
|
+
: h('div', { className: 'sm-tray' },
|
|
581
|
+
groupArchiveRowsByWorkspace(rows).map((group) => {
|
|
582
|
+
// 未分组桶键为空串,空串本身是合法 React key,直接用桶键防与用户标题撞车
|
|
583
|
+
const key = group.workspace || ''
|
|
584
|
+
const open = openGroups.has(key)
|
|
585
|
+
const page = pageArchiveRows(group.rows, groupCounts[key] || ARCHIVE_PAGE_SIZE)
|
|
586
|
+
return h('div', { className: 'sm-group', key },
|
|
587
|
+
h('button', {
|
|
588
|
+
className: 'sm-group__head' + (open ? ' sm-group__head--on' : ''),
|
|
589
|
+
'aria-expanded': open,
|
|
590
|
+
onClick: () => toggleGroup(key),
|
|
591
|
+
},
|
|
592
|
+
h('span', { className: 'sm-group__chev', 'aria-hidden': 'true' }, '▸'),
|
|
593
|
+
h('span', { className: 'sm-group__name' }, group.workspace || '未分组'),
|
|
594
|
+
h('span', { className: 'sm-group__count' }, group.rows.length + ' 条'),
|
|
595
|
+
),
|
|
596
|
+
open
|
|
597
|
+
? h('div', { className: 'sm-group__body' },
|
|
598
|
+
page.visible.map(renderArchiveRow),
|
|
599
|
+
expandMore(page.remaining, () => expandGroup(key)))
|
|
600
|
+
: null,
|
|
601
|
+
)
|
|
602
|
+
}))
|
|
603
|
+
return h('div', { className: 'sm-lib' },
|
|
604
|
+
h('div', { className: 'sm-libbar' },
|
|
605
|
+
h('input', {
|
|
606
|
+
className: 'sm-libbar__search',
|
|
607
|
+
type: 'search',
|
|
608
|
+
placeholder: '搜索会话标题',
|
|
609
|
+
'aria-label': '搜索会话标题',
|
|
610
|
+
value: query,
|
|
611
|
+
// 搜索词变化即重置平铺分页:命中集变小后旧可见数无意义
|
|
612
|
+
onChange: (event) => { setQuery(event.target.value); setLibraryCount(ARCHIVE_PAGE_SIZE) },
|
|
613
|
+
}),
|
|
614
|
+
searching ? h('span', { className: 'sm-libbar__count' }, matchedRows.length + ' 条匹配') : null,
|
|
615
|
+
),
|
|
616
|
+
tray,
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const archiveRegion = view === 'recent' ? recentTray : renderLibrary()
|
|
621
|
+
|
|
622
|
+
return h('div', { className: 'sm-panel' },
|
|
623
|
+
view === 'recent'
|
|
624
|
+
? h('div', { className: 'sm-head' },
|
|
625
|
+
h('span', { className: 'sm-head__title' }, '会话归档'),
|
|
626
|
+
rows.length > 0 ? h('span', { className: 'sm-head__count' }, rows.length + ' 条') : null,
|
|
627
|
+
rows.length > 0
|
|
628
|
+
? h('button', { className: 'sm-btn', onClick: () => setView('library') }, '按工作区浏览 ›')
|
|
629
|
+
: null,
|
|
630
|
+
)
|
|
631
|
+
: h('div', { className: 'sm-head' },
|
|
632
|
+
h('button', { className: 'sm-btn', onClick: () => setView('recent') }, '‹ 返回'),
|
|
633
|
+
h('span', { className: 'sm-head__title' }, '按工作区浏览'),
|
|
634
|
+
h('span', { className: 'sm-head__count' }, rows.length + ' 条'),
|
|
635
|
+
),
|
|
636
|
+
h('div', { className: 'sm-head__hint' }, '恢复放回会话列表;删除移入系统回收站(无回收站环境移入插件回收区),可经「已删除」区找回。'),
|
|
637
|
+
periodic && periodic.running === false
|
|
638
|
+
? h('div', { className: 'sm-head__hint' },
|
|
639
|
+
'周期评估未运行(' + (periodic.reason || '宿主定时服务不可用') + ');自动归档仍在新会话创建与启动时生效。')
|
|
640
|
+
: null,
|
|
641
|
+
archiveRegion,
|
|
642
|
+
h(DeletedSection, {
|
|
643
|
+
rows: projectDeletedRows(deleted, listState),
|
|
644
|
+
busyId,
|
|
645
|
+
onRemount,
|
|
646
|
+
onForget,
|
|
647
|
+
}),
|
|
648
|
+
h(AutoArchiveConfig, null),
|
|
649
|
+
pendingDelete !== null
|
|
650
|
+
? h(DeleteDialog, {
|
|
651
|
+
row: pendingDelete,
|
|
652
|
+
busy: busyId === pendingDelete.id,
|
|
653
|
+
onCancel: () => setPendingDelete(null),
|
|
654
|
+
onConfirm: onDialogConfirm,
|
|
655
|
+
})
|
|
656
|
+
: null,
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
|
|
661
|
+
// 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
|
|
662
|
+
const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除。'
|
|
663
|
+
const ARCHIVE_DAYS_TITLE = '会话超过该天数未活跃(产物无更新)即自动归档,空白(无消息)会话不参与归档;0 = 关闭自动归档。仅对产物可读的会话评估,产物不可读的跳过以免误归档,已归档的会话不会重复处理。'
|
|
664
|
+
const ARCHIVE_INTERVAL_TITLE = '周期检查的间隔小时数,到期会话在下一轮检查时归档;0 = 关闭周期检查(新会话创建与插件启动时的检查不受影响);调小最迟等新间隔即可提前触发,调大自下一轮排期起生效。'
|
|
665
|
+
|
|
666
|
+
// 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
|
|
667
|
+
// 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
|
|
668
|
+
// 服务端失败后回读生效值回滚显示
|
|
669
|
+
const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
|
|
670
|
+
|
|
671
|
+
// 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
|
|
672
|
+
// invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
|
|
673
|
+
// 必须对照已提交值而非显示值
|
|
674
|
+
function classifyAutoArchiveInput(committedText, text) {
|
|
675
|
+
const trimmed = text.trim()
|
|
676
|
+
if (trimmed === committedText) return { action: 'noop' }
|
|
677
|
+
const value = Number(trimmed)
|
|
678
|
+
if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
|
|
679
|
+
return { action: 'post', value }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function AutoArchiveConfig() {
|
|
683
|
+
const [state, setState] = useState(null)
|
|
684
|
+
// 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
|
|
685
|
+
const committedRef = React.useRef(null)
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
api(AUTO_ARCHIVE_URL)
|
|
688
|
+
.then((payload) => {
|
|
689
|
+
if (!payload) return
|
|
690
|
+
const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
|
|
691
|
+
committedRef.current = next
|
|
692
|
+
setState(next)
|
|
693
|
+
})
|
|
694
|
+
.catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
|
|
695
|
+
}, [])
|
|
696
|
+
function refresh() {
|
|
697
|
+
return api(AUTO_ARCHIVE_URL)
|
|
698
|
+
.then((payload) => {
|
|
699
|
+
if (!payload) return
|
|
700
|
+
const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
|
|
701
|
+
committedRef.current = next
|
|
702
|
+
setState(next)
|
|
703
|
+
})
|
|
704
|
+
.catch((error) => { console.warn('[session-manager] 自动归档配置回读失败', error) })
|
|
705
|
+
}
|
|
706
|
+
function commit(field, text) {
|
|
707
|
+
const committed = committedRef.current
|
|
708
|
+
if (committed === null) return
|
|
709
|
+
// 提交判定与 core.mjs classifyAutoArchiveInput 镜像同规:受控输入下显示值已随
|
|
710
|
+
// 击键同步,守卫必须对已提交基准比较,与显示值比较恒相等
|
|
711
|
+
const verdict = classifyAutoArchiveInput(committed[field], text)
|
|
712
|
+
if (verdict.action === 'noop') return
|
|
713
|
+
if (verdict.action === 'invalid') {
|
|
714
|
+
setState({ ...committed })
|
|
715
|
+
toast('自动归档配置须为非负整数', { kind: 'error' })
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
const value = verdict.value
|
|
719
|
+
api(AUTO_ARCHIVE_URL, { method: 'POST', body: JSON.stringify({ [field]: value }) })
|
|
720
|
+
.then((payload) => {
|
|
721
|
+
// 仅合回已提交字段,不清掉另一字段的未提交编辑
|
|
722
|
+
const saved = String(payload[field])
|
|
723
|
+
committedRef.current = { ...committedRef.current, [field]: saved }
|
|
724
|
+
setState((prev) => (prev === null ? prev : { ...prev, [field]: saved }))
|
|
725
|
+
toast('自动归档配置已保存')
|
|
726
|
+
})
|
|
727
|
+
.catch(() => {
|
|
728
|
+
toast('自动归档配置保存失败', { kind: 'error' })
|
|
729
|
+
void refresh()
|
|
730
|
+
})
|
|
731
|
+
}
|
|
732
|
+
if (state === null) return null
|
|
733
|
+
const numberInput = (field) => h('input', {
|
|
734
|
+
type: 'number', min: 0, step: 1, value: state[field],
|
|
735
|
+
onChange: (event) => setState((prev) => (prev === null ? prev : { ...prev, [field]: event.target.value })),
|
|
736
|
+
// Enter 经 blur 走同一提交路径,避免与 onBlur 双触发重复提交
|
|
737
|
+
onKeyDown: (event) => { if (event.key === 'Enter') event.target.blur() },
|
|
738
|
+
onBlur: (event) => commit(field, event.target.value),
|
|
739
|
+
})
|
|
740
|
+
return h('div', { className: 'sm-cfg' },
|
|
741
|
+
h('span', { title: ARCHIVE_OVERVIEW_TITLE }, '自动归档'),
|
|
742
|
+
h('label', { className: 'sm-cfg__field', title: ARCHIVE_DAYS_TITLE }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
|
|
743
|
+
h('label', { className: 'sm-cfg__field', title: ARCHIVE_INTERVAL_TITLE }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
inject: ['slots', 'sessions', 'workspaces'],
|
|
749
|
+
apply(ctx) {
|
|
750
|
+
const sessions = ctx.get('sessions')
|
|
751
|
+
const workspaces = ctx.get('workspaces')
|
|
752
|
+
|
|
753
|
+
// 样式挂载在宿主文档级:设置页未打开时面板不存在,
|
|
754
|
+
// 样式若随面板注入则面板裸样式渲染
|
|
755
|
+
ctx.effect(() => {
|
|
756
|
+
const style = document.createElement('style')
|
|
757
|
+
// 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
|
|
758
|
+
style.setAttribute('data-plugin', '@mzzsfy/dsh-session-manager')
|
|
759
|
+
style.textContent = CSS
|
|
760
|
+
document.head.appendChild(style)
|
|
761
|
+
return () => style.remove()
|
|
762
|
+
}, 'session-manager styles')
|
|
763
|
+
|
|
764
|
+
// 归档快照差分:新增驱动通知,文案携带会话标题
|
|
765
|
+
let previous
|
|
766
|
+
const unsubscribe = workspaces.list.subscribe(() => {
|
|
767
|
+
const step = archiveToastStep(previous, workspaces.list.getSnapshot())
|
|
768
|
+
previous = step.state
|
|
769
|
+
if (step.added.length > 0) {
|
|
770
|
+
// 会话行快照与面板投影同源取 byId,标题映射为通知文案行数据
|
|
771
|
+
const listState = sessions.list.getSnapshot()
|
|
772
|
+
const byId = (listState && listState.byId) || {}
|
|
773
|
+
const rows = Object.keys(byId).map((id) => ({ id, title: byId[id].displayTitle }))
|
|
774
|
+
toast(archiveToastText(step.added, rows))
|
|
775
|
+
}
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
// 工作区文件夹运行标记:服务状态驱动的侧边栏组头标注——官方折叠组不渲染
|
|
779
|
+
// 会话行(deriveGroups 的 sessions 为空数组),DOM 扫描对折叠态失效,
|
|
780
|
+
// 归属与运行态一律取服务快照;DOM 仅按下标对位官方组容器(渲染序 = 快照
|
|
781
|
+
// items 序,未分组桶恒在末位)。开关拉取失败按启用兜底;拉取未决期间仅清除
|
|
782
|
+
// 不标记(停用用户刷新不闪现),缺 MutationObserver 或 querySelectorAll 的
|
|
783
|
+
// 环境(异常宿主)整体跳过,不阻塞其余能力
|
|
784
|
+
|
|
785
|
+
ctx.slots.inject('settings.section', () =>
|
|
786
|
+
ctx.slots.register(
|
|
787
|
+
{ name: 'settings.section', id: 'session-manager', order: 46, label: '会话归档' },
|
|
788
|
+
() => React.createElement(SessionManagerPanel, { sessions, workspaces }),
|
|
789
|
+
))
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
ctx.effect(() => unsubscribe, 'session-manager archived diff')
|
|
793
|
+
|
|
794
|
+
function SessionManagerPanel({ sessions: sessionSvc, workspaces: workspaceSvc }) {
|
|
795
|
+
const listState = useSnapshot(sessionSvc.list)
|
|
796
|
+
const workspaceState = useSnapshot(workspaceSvc.list)
|
|
797
|
+
return React.createElement(SessionManagerApp, {
|
|
798
|
+
rows: projectRows(listState, (workspaceState && workspaceState.archivedSessionIds) || [], workspaceState),
|
|
799
|
+
listState,
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
})
|