dsh-session-mng 0.1.2 → 0.1.4
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/CHANGELOG.md +42 -1
- package/README.md +20 -11
- package/ROADMAP.md +18 -3
- package/client.js +293 -65
- package/docs/01-design.md +23 -13
- package/docs/02-open-source-and-marketplace.md +15 -0
- package/docs/HANDOVER-2026-10-27.md +6 -6
- package/docs/PLAN-2026-10-28-branch-visibility.md +116 -0
- package/docs/PLAN-2026-10-28-legacy-fork-safety.md +62 -0
- package/docs/RELEASE-CHECKLIST-0.1.2.md +7 -7
- package/docs/RELEASE-CHECKLIST-0.1.3.md +40 -0
- package/docs/RELEASE-CHECKLIST-0.1.4.md +33 -0
- package/lib/service.js +64 -16
- package/lib/state.js +54 -9
- package/package.json +4 -3
package/client.js
CHANGED
|
@@ -43,11 +43,14 @@ window.__ModuleLoader__.load({
|
|
|
43
43
|
sortCreated: '创建时间',
|
|
44
44
|
sortSize: '大小',
|
|
45
45
|
sortTitle: '标题',
|
|
46
|
-
showSubagents: '
|
|
46
|
+
showSubagents: '显示子代理会话',
|
|
47
|
+
showSubagentsHint: '子代理会话由委派产生,默认隐藏;分支会话始终显示。',
|
|
47
48
|
showBlank: '显示空会话',
|
|
48
49
|
cleanupBlank: '清理空会话({n})',
|
|
49
50
|
cleanupBlankHint: '批量删除已归档的空会话;未归档的空会话需先归档。',
|
|
50
|
-
|
|
51
|
+
cleanupBlankLockedHint: '有 {n} 个已归档空会话已在 DSH 中打开,重启后才能清理。',
|
|
52
|
+
archiveBlank: '归档空会话({n})',
|
|
53
|
+
archiveBlankHint: '把未归档的空会话批量归档;归档后即可用「清理空会话」彻底删除。',
|
|
51
54
|
hiddenBlankCount: '已隐藏 {n} 个空会话',
|
|
52
55
|
selectAll: '全选当前列表',
|
|
53
56
|
expandAll: '展开全部',
|
|
@@ -70,7 +73,14 @@ window.__ModuleLoader__.load({
|
|
|
70
73
|
untitled: '(未命名)',
|
|
71
74
|
running: '运行中',
|
|
72
75
|
archived: '已归档',
|
|
73
|
-
subagent: '
|
|
76
|
+
subagent: '子代理',
|
|
77
|
+
branch: '分支',
|
|
78
|
+
fromParent: '↳ 来自:{title}',
|
|
79
|
+
hiddenSubagentCount: '已隐藏 {n} 个子代理会话',
|
|
80
|
+
forkCount: '{n} 分支',
|
|
81
|
+
forkCountHint: '该会话有 {n} 个分支会话',
|
|
82
|
+
parentSession: '父会话',
|
|
83
|
+
parentMissing: '来源父会话已不存在',
|
|
74
84
|
pinned: '已置顶',
|
|
75
85
|
liveBadge: '已打开',
|
|
76
86
|
noCwd: '无工作目录',
|
|
@@ -113,7 +123,9 @@ window.__ModuleLoader__.load({
|
|
|
113
123
|
moving: '正在移动…',
|
|
114
124
|
moveNoTarget: '没有可移动到的目标工作区。',
|
|
115
125
|
moveLiveHint: '该会话已在当前 DSH 进程中打开,重启 DSH 后再移动。',
|
|
116
|
-
moveSubagentHint: '
|
|
126
|
+
moveSubagentHint: '子代理会话不能单独移动。',
|
|
127
|
+
moveForkHint: '分支会话必须和父会话留在同一工作区,暂不支持单独移动。',
|
|
128
|
+
moveForkChildrenHint: '该会话有 {n} 个分支会话;移动只迁移本会话,分支会留在原工作区并继续独立使用。',
|
|
117
129
|
moveDescendantsHint: '该会话包含子代理会话,暂时不能单独移动。',
|
|
118
130
|
deleteAction: '彻底删除',
|
|
119
131
|
deleteHint: '只允许删除已归档会话;删除后日志目录与工作区记录都会被移除,无法恢复。',
|
|
@@ -130,6 +142,7 @@ window.__ModuleLoader__.load({
|
|
|
130
142
|
deleteNotArchived: '只有已归档会话可以彻底删除。',
|
|
131
143
|
deleteLiveHint: '该会话已在当前 DSH 进程中打开,重启 DSH 后再删除。',
|
|
132
144
|
deleteCascadeNote: '如果该会话有子代理会话,会一并删除。',
|
|
145
|
+
deleteForkNote: '另有 {n} 个历史分支会话不会被删除,但会失去「来自父会话」的来源标识;如需一起删除,请把它们也勾选进来。',
|
|
133
146
|
archiveAction: '归档',
|
|
134
147
|
archiving: '正在归档…',
|
|
135
148
|
archiveDone: '已归档 {n} 个会话。',
|
|
@@ -164,7 +177,7 @@ window.__ModuleLoader__.load({
|
|
|
164
177
|
timeout: '模型调用超时。',
|
|
165
178
|
llmError: '模型调用失败:{message}',
|
|
166
179
|
liveHint: '已打开的会话不能移动或彻底删除;重启 DSH 后重试。',
|
|
167
|
-
moveUnavailableHint: '
|
|
180
|
+
moveUnavailableHint: '所选会话中存在不可移动项(如跨工作区、分支/子代理会话或状态未就绪)。',
|
|
168
181
|
archiveHint: '归档后会话会从活跃列表移到已归档,可在本页恢复。',
|
|
169
182
|
moveArchivedHint: '移动不会改变归档状态,会话仍会留在已归档。',
|
|
170
183
|
working: '处理中…',
|
|
@@ -197,10 +210,13 @@ window.__ModuleLoader__.load({
|
|
|
197
210
|
sortSize: 'Size',
|
|
198
211
|
sortTitle: 'Title',
|
|
199
212
|
showSubagents: 'Show subagent sessions',
|
|
213
|
+
showSubagentsHint: 'Subagent sessions are produced by delegation and stay hidden by default; branch sessions are always listed.',
|
|
200
214
|
showBlank: 'Show empty sessions',
|
|
201
215
|
cleanupBlank: 'Clean up empty sessions ({n})',
|
|
202
216
|
cleanupBlankHint: 'Permanently delete archived empty sessions; unarchived empty sessions must be archived first.',
|
|
203
|
-
|
|
217
|
+
cleanupBlankLockedHint: '{n} archived empty sessions are open in this DSH process; restart DSH before cleaning them up.',
|
|
218
|
+
archiveBlank: 'Archive empty sessions ({n})',
|
|
219
|
+
archiveBlankHint: 'Archive every unarchived empty session in one batch; they can then be cleaned up with "Clean up empty sessions".',
|
|
204
220
|
hiddenBlankCount: '{n} empty sessions hidden',
|
|
205
221
|
selectAll: 'Select all shown',
|
|
206
222
|
expandAll: 'Expand all',
|
|
@@ -224,6 +240,13 @@ window.__ModuleLoader__.load({
|
|
|
224
240
|
running: 'Running',
|
|
225
241
|
archived: 'Archived',
|
|
226
242
|
subagent: 'Subagent',
|
|
243
|
+
branch: 'Branch',
|
|
244
|
+
fromParent: '↳ from: {title}',
|
|
245
|
+
hiddenSubagentCount: '{n} subagent sessions hidden',
|
|
246
|
+
forkCount: '{n} branch',
|
|
247
|
+
forkCountHint: 'This session has {n} branch sessions',
|
|
248
|
+
parentSession: 'Parent session',
|
|
249
|
+
parentMissing: 'Source session no longer exists',
|
|
227
250
|
pinned: 'Pinned',
|
|
228
251
|
liveBadge: 'Open',
|
|
229
252
|
noCwd: 'No working directory',
|
|
@@ -267,6 +290,8 @@ window.__ModuleLoader__.load({
|
|
|
267
290
|
moveNoTarget: 'No target workspace is available.',
|
|
268
291
|
moveLiveHint: 'This session is open in this DSH process; restart DSH and retry.',
|
|
269
292
|
moveSubagentHint: 'Subagent sessions cannot be moved on their own.',
|
|
293
|
+
moveForkHint: 'A branch session must stay in its parent\'s workspace, so it cannot be moved on its own.',
|
|
294
|
+
moveForkChildrenHint: 'This session has {n} branch sessions; moving migrates only this session, and the branches stay in the original workspace and keep working independently.',
|
|
270
295
|
moveDescendantsHint: 'This session has subagent descendants and cannot move alone yet.',
|
|
271
296
|
deleteAction: 'Delete permanently',
|
|
272
297
|
deleteHint: 'Only archived sessions can be deleted. The log directory and workspace record are removed; this cannot be undone.',
|
|
@@ -283,6 +308,7 @@ window.__ModuleLoader__.load({
|
|
|
283
308
|
deleteNotArchived: 'Only archived sessions can be permanently deleted.',
|
|
284
309
|
deleteLiveHint: 'This session is open in this DSH process; restart DSH and retry.',
|
|
285
310
|
deleteCascadeNote: 'Any subagent descendants are deleted too.',
|
|
311
|
+
deleteForkNote: '{n} branch sessions are not deleted, but they lose their "from parent" label. Select them too if they should be deleted as well.',
|
|
286
312
|
archiveAction: 'Archive',
|
|
287
313
|
archiving: 'Archiving…',
|
|
288
314
|
archiveDone: 'Archived {n} sessions.',
|
|
@@ -317,7 +343,7 @@ window.__ModuleLoader__.load({
|
|
|
317
343
|
timeout: 'The model call timed out.',
|
|
318
344
|
llmError: 'The model call failed: {message}',
|
|
319
345
|
liveHint: 'An open session cannot be moved or deleted; restart DSH and retry.',
|
|
320
|
-
moveUnavailableHint: 'Some selected sessions cannot be moved (cross-workspace, subagent
|
|
346
|
+
moveUnavailableHint: 'Some selected sessions cannot be moved (cross-workspace, branch or subagent sessions, or not ready).',
|
|
321
347
|
archiveHint: 'Archiving moves the session from the active list to the archived list; restore it here.',
|
|
322
348
|
moveArchivedHint: 'Moving does not change the archive state; the session stays archived.',
|
|
323
349
|
working: 'Working…',
|
|
@@ -334,10 +360,13 @@ window.__ModuleLoader__.load({
|
|
|
334
360
|
|
|
335
361
|
const CSS = `
|
|
336
362
|
.dsm-page{display:flex;flex-direction:column;gap:12px;font:inherit;color:var(--dsw-alias-label-primary);min-width:0}
|
|
337
|
-
|
|
363
|
+
/* Page head: title and its actions share the first row; the subtitles use the
|
|
364
|
+
full width below, so the buttons sit in the top-right corner. */
|
|
365
|
+
.dsm-head{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
366
|
+
.dsm-head-row{display:flex;align-items:center;justify-content:flex-start;gap:12px;flex-wrap:wrap;min-width:0}
|
|
338
367
|
.dsm-title{margin:0;font-size:17px;font-weight:600;line-height:24px}
|
|
339
368
|
.dsm-sub{margin:2px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
340
|
-
.dsm-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
369
|
+
.dsm-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-left:auto}
|
|
341
370
|
.dsm-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:28px;padding:0 10px;
|
|
342
371
|
border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);
|
|
343
372
|
font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}
|
|
@@ -365,6 +394,12 @@ window.__ModuleLoader__.load({
|
|
|
365
394
|
.dsm-note.warn{border-color:var(--dsw-alias-state-warn-primary)}
|
|
366
395
|
.dsm-note-actions{display:flex;gap:6px;flex-wrap:wrap;flex:0 0 auto}
|
|
367
396
|
.dsm-note detail{flex:1 1 100%}
|
|
397
|
+
/* Operation-result toast: floats above the pane so scrolling never hides it. */
|
|
398
|
+
.dsm-toast{position:fixed;bottom:18px;right:18px;z-index:1200;width:max-content;
|
|
399
|
+
max-width:min(460px,calc(100vw - 36px));padding:10px 12px;border-radius:8px;
|
|
400
|
+
background:var(--dsw-alias-bg-layer-2);box-shadow:0 14px 40px rgba(0,0,0,.45);
|
|
401
|
+
animation:dsm-toast-in .16s ease-out}
|
|
402
|
+
@keyframes dsm-toast-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
|
368
403
|
.dsm-sticky{position:sticky;top:0;z-index:3;display:flex;flex-direction:column;gap:8px;
|
|
369
404
|
padding:6px 0 8px;background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
370
405
|
.dsm-batch{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-radius:7px;
|
|
@@ -403,6 +438,7 @@ window.__ModuleLoader__.load({
|
|
|
403
438
|
.dsm-row-main{display:flex;flex-direction:column;align-items:flex-start;gap:1px;min-width:0;
|
|
404
439
|
border:none;background:transparent;color:inherit;font:inherit;text-align:left;padding:0;cursor:pointer}
|
|
405
440
|
.dsm-row-title{max-width:100%;font-size:13px;line-height:19px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
441
|
+
.dsm-branch-glyph{margin-right:4px;font-size:12px;line-height:1;color:var(--dsw-alias-label-secondary);opacity:.85}
|
|
406
442
|
.dsm-row-meta{max-width:100%;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
407
443
|
.dsm-badges{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
|
|
408
444
|
.dsm-badge{display:inline-flex;align-items:center;height:17px;padding:0 5px;border-radius:4px;font-size:10px;line-height:17px;
|
|
@@ -415,7 +451,10 @@ window.__ModuleLoader__.load({
|
|
|
415
451
|
.dsm-empty{padding:22px 10px;text-align:center;font-size:12px;color:var(--dsw-alias-label-secondary);
|
|
416
452
|
border:1px dashed var(--dsw-alias-border-l2);border-radius:8px}
|
|
417
453
|
.dsm-detail{display:flex;flex-direction:column;gap:12px}
|
|
418
|
-
.
|
|
454
|
+
/* The title row and its actions stay in view while the detail page scrolls. */
|
|
455
|
+
.dsm-detail-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;
|
|
456
|
+
gap:10px;flex-wrap:wrap;padding:6px 0 8px;background:var(--dsw-alias-bg-layer-2);
|
|
457
|
+
border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
419
458
|
.dsm-detail-title{margin:0;font-size:16px;line-height:23px;font-weight:600;word-break:break-word}
|
|
420
459
|
.dsm-card{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);padding:10px}
|
|
421
460
|
.dsm-card h3{margin:0 0 8px;font-size:12px;line-height:18px;font-weight:600}
|
|
@@ -543,6 +582,7 @@ window.__ModuleLoader__.load({
|
|
|
543
582
|
if (code === 'session-running') return t('sessionRunning')
|
|
544
583
|
if (code === 'session-writer-held') return t('writerHeld')
|
|
545
584
|
if (code === 'has-descendants') return t('moveDescendantsHint')
|
|
585
|
+
if (code === 'branch-session') return t('moveForkHint')
|
|
546
586
|
if (code === 'archived-required') return t('deleteNotArchived')
|
|
547
587
|
if (code === 'unsupported-backend') return t('unsupportedBackend')
|
|
548
588
|
if (code === 'unsupported-registry') return t('unsupportedRegistry')
|
|
@@ -631,11 +671,33 @@ window.__ModuleLoader__.load({
|
|
|
631
671
|
return h('span', null, h('span', { className: 'dsm-spin', 'aria-hidden': 'true' }), ' ', label ?? t('working'))
|
|
632
672
|
}
|
|
633
673
|
|
|
674
|
+
/** How long a plain success toast stays before hiding itself. */
|
|
675
|
+
const NOTICE_HIDE_MS = 5000
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Operation-result toast.
|
|
679
|
+
*
|
|
680
|
+
* It floats above the settings pane (instead of sitting in the scroll flow),
|
|
681
|
+
* so a result is visible no matter where the list is scrolled. A plain
|
|
682
|
+
* success toast hides itself after a few seconds; failures and notices with
|
|
683
|
+
* an action stay until the user dismisses them, because they carry details
|
|
684
|
+
* worth reading.
|
|
685
|
+
*/
|
|
634
686
|
function Notice({ notice, t, onDismiss }) {
|
|
687
|
+
const dismiss = React.useRef(onDismiss)
|
|
688
|
+
dismiss.current = onDismiss
|
|
689
|
+
const transient = notice !== null && notice !== undefined
|
|
690
|
+
&& notice.tone !== 'error' && notice.tone !== 'warn'
|
|
691
|
+
&& notice.details === undefined && notice.action === undefined
|
|
692
|
+
React.useEffect(() => {
|
|
693
|
+
if (!transient) return undefined
|
|
694
|
+
const timer = setTimeout(() => dismiss.current?.(), NOTICE_HIDE_MS)
|
|
695
|
+
return () => clearTimeout(timer)
|
|
696
|
+
}, [notice, transient])
|
|
635
697
|
if (notice === null || notice === undefined) return null
|
|
636
698
|
return h(
|
|
637
699
|
'div',
|
|
638
|
-
{ className: `dsm-note ${notice.tone ?? ''}`, role: 'status' },
|
|
700
|
+
{ className: `dsm-note dsm-toast ${notice.tone ?? ''}`, role: 'status' },
|
|
639
701
|
h('div', { style: { flex: '1 1 auto', minWidth: 0 } },
|
|
640
702
|
h('div', null, notice.text),
|
|
641
703
|
notice.detail ? h('div', { className: 'dsm-hint', style: { marginTop: 3 } }, notice.detail) : null,
|
|
@@ -659,15 +721,28 @@ window.__ModuleLoader__.load({
|
|
|
659
721
|
}
|
|
660
722
|
|
|
661
723
|
function Row({ row, selected, onToggle, onOpen, t }) {
|
|
724
|
+
const depth = row.branchDepth ?? 0
|
|
725
|
+
const branch = row.kind === 'fork'
|
|
726
|
+
const forkCount = row.forkCount ?? 0
|
|
662
727
|
const badges = []
|
|
663
728
|
if (row.running) badges.push(h(Badge, { key: 'run', tone: 'run' }, t('running')))
|
|
664
729
|
if (row.archived) badges.push(h(Badge, { key: 'arch' }, t('archived')))
|
|
665
730
|
if (row.live) badges.push(h(Badge, { key: 'live', tone: 'brand' }, t('liveBadge')))
|
|
666
731
|
if (row.pinned) badges.push(h(Badge, { key: 'pin' }, t('pinned')))
|
|
732
|
+
if (branch) badges.push(h(Badge, { key: 'fork', tone: 'brand' }, t('branch')))
|
|
667
733
|
if (row.isSubagent) badges.push(h(Badge, { key: 'sub', tone: 'warn' }, t('subagent')))
|
|
734
|
+
if (forkCount > 0) {
|
|
735
|
+
badges.push(
|
|
736
|
+
h('span', { key: 'children', title: t('forkCountHint', { n: forkCount }) },
|
|
737
|
+
h(Badge, null, t('forkCount', { n: forkCount }))),
|
|
738
|
+
)
|
|
739
|
+
}
|
|
668
740
|
return h(
|
|
669
741
|
'div',
|
|
670
|
-
{
|
|
742
|
+
{
|
|
743
|
+
className: `dsm-row${selected === true ? ' selected' : ''}${depth > 0 ? ' dsm-row-branch' : ''}`,
|
|
744
|
+
style: depth > 0 ? { paddingLeft: `${depth * 18}px` } : undefined,
|
|
745
|
+
},
|
|
671
746
|
h('input', {
|
|
672
747
|
type: 'checkbox',
|
|
673
748
|
checked: selected === true,
|
|
@@ -677,10 +752,20 @@ window.__ModuleLoader__.load({
|
|
|
677
752
|
h(
|
|
678
753
|
'button',
|
|
679
754
|
{ type: 'button', className: 'dsm-row-main', onClick: onOpen, title: row.cwd || row.id },
|
|
680
|
-
h(
|
|
755
|
+
h(
|
|
756
|
+
'span',
|
|
757
|
+
{ className: 'dsm-row-title' },
|
|
758
|
+
depth > 0 ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
|
|
759
|
+
row.title === '' ? t('untitled') : row.title,
|
|
760
|
+
),
|
|
681
761
|
h(
|
|
682
762
|
'span',
|
|
683
763
|
{ className: 'dsm-row-meta' },
|
|
764
|
+
branch && row.parentTitle !== ''
|
|
765
|
+
? `${t('fromParent', { title: row.parentTitle })} · `
|
|
766
|
+
: branch && row.parentPresent === false
|
|
767
|
+
? `${t('parentMissing')} · `
|
|
768
|
+
: '',
|
|
684
769
|
row.cwd === '' ? t('noCwd') : row.cwd,
|
|
685
770
|
' · ',
|
|
686
771
|
row.id,
|
|
@@ -935,30 +1020,44 @@ window.__ModuleLoader__.load({
|
|
|
935
1020
|
// Delete scope (shared by single and batch)
|
|
936
1021
|
// ---------------------------------------------------------------------
|
|
937
1022
|
|
|
938
|
-
function DeleteScope({ ctx, t, ids, onConfirmed, onCancel, busy }) {
|
|
1023
|
+
function DeleteScope({ ctx, t, ids, maxBatch, onConfirmed, onCancel, busy }) {
|
|
939
1024
|
const [phase, setPhase] = React.useState('loading')
|
|
940
1025
|
const [preview, setPreview] = React.useState(null)
|
|
941
1026
|
const [error, setError] = React.useState(null)
|
|
942
1027
|
const [checked, setChecked] = React.useState(false)
|
|
943
1028
|
const idsKey = ids.join('\u0000')
|
|
944
1029
|
|
|
1030
|
+
// One confirmation may name more sessions than a single request admits
|
|
1031
|
+
// (the empty-session shortcut does). Preview in batches and merge the
|
|
1032
|
+
// scopes so the dialog still shows one combined result.
|
|
945
1033
|
React.useEffect(() => {
|
|
946
1034
|
const controller = new AbortController()
|
|
947
1035
|
setPhase('loading')
|
|
948
1036
|
setChecked(false)
|
|
949
|
-
|
|
950
|
-
|
|
1037
|
+
const size = Number.isFinite(maxBatch) && maxBatch > 0 ? maxBatch : 50
|
|
1038
|
+
const merge = async () => {
|
|
1039
|
+
const merged = { roots: [], errors: [], sessions: [], descendantCount: 0, forkChildren: [], totalBytes: 0 }
|
|
1040
|
+
for (let index = 0; index < ids.length; index += size) {
|
|
1041
|
+
const value = await post('delete/preview', { sessionIds: ids.slice(index, index + size) }, controller.signal)
|
|
951
1042
|
if (controller.signal.aborted) return
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1043
|
+
merged.roots.push(...(value?.roots ?? []))
|
|
1044
|
+
merged.errors.push(...(value?.errors ?? []))
|
|
1045
|
+
merged.sessions.push(...(value?.sessions ?? []))
|
|
1046
|
+
merged.descendantCount += value?.descendantCount ?? 0
|
|
1047
|
+
merged.forkChildren.push(...(value?.forkChildren ?? []))
|
|
1048
|
+
merged.totalBytes += value?.totalBytes ?? 0
|
|
1049
|
+
}
|
|
1050
|
+
if (controller.signal.aborted) return
|
|
1051
|
+
setPreview(merged)
|
|
1052
|
+
setPhase('ready')
|
|
1053
|
+
}
|
|
1054
|
+
merge().catch((failure) => {
|
|
1055
|
+
if (failure?.name === 'AbortError') return
|
|
1056
|
+
setError(errorText(t, failure))
|
|
1057
|
+
setPhase('error')
|
|
1058
|
+
})
|
|
960
1059
|
return () => controller.abort()
|
|
961
|
-
}, [idsKey, t])
|
|
1060
|
+
}, [idsKey, maxBatch, t])
|
|
962
1061
|
|
|
963
1062
|
React.useEffect(() => {
|
|
964
1063
|
const onKeyDown = (event) => {
|
|
@@ -1016,6 +1115,9 @@ window.__ModuleLoader__.load({
|
|
|
1016
1115
|
h('div', { className: 'dsm-hint' },
|
|
1017
1116
|
`${t('deleteRoots')}: ${preview?.roots?.length ?? 0} · ${t('deleteDescendants')}: ${preview?.descendantCount ?? 0} · ${t('deleteTotalSize', { size: formatBytes(preview?.totalBytes ?? 0) })}`,
|
|
1018
1117
|
),
|
|
1118
|
+
(preview?.forkChildren?.length ?? 0) > 0
|
|
1119
|
+
? h('p', { className: 'dsm-hint' }, t('deleteForkNote', { n: preview.forkChildren.length }))
|
|
1120
|
+
: null,
|
|
1019
1121
|
h('div', { className: 'dsm-scope-list' },
|
|
1020
1122
|
rows.map((row) =>
|
|
1021
1123
|
h(
|
|
@@ -1118,6 +1220,7 @@ window.__ModuleLoader__.load({
|
|
|
1118
1220
|
row.archived ? h(Badge, null, t('archived')) : null,
|
|
1119
1221
|
row.live ? h(Badge, { tone: 'brand' }, t('liveBadge')) : null,
|
|
1120
1222
|
row.pinned ? h(Badge, null, t('pinned')) : null,
|
|
1223
|
+
row.kind === 'fork' ? h(Badge, { tone: 'brand' }, t('branch')) : null,
|
|
1121
1224
|
row.isSubagent ? h(Badge, { tone: 'warn' }, t('subagent')) : null,
|
|
1122
1225
|
),
|
|
1123
1226
|
),
|
|
@@ -1139,6 +1242,16 @@ window.__ModuleLoader__.load({
|
|
|
1139
1242
|
{ className: 'dsm-meta' },
|
|
1140
1243
|
h('dt', null, t('workspace')),
|
|
1141
1244
|
h('dd', null, row.workspaceTitle || t('workspaceUngrouped')),
|
|
1245
|
+
row.kind === 'fork'
|
|
1246
|
+
? h(React.Fragment, null,
|
|
1247
|
+
h('dt', null, t('parentSession')),
|
|
1248
|
+
h('dd', null,
|
|
1249
|
+
row.parentTitle !== ''
|
|
1250
|
+
? row.parentTitle
|
|
1251
|
+
: row.parentPresent === false
|
|
1252
|
+
? t('parentMissing')
|
|
1253
|
+
: (row.parentSessionId || '')))
|
|
1254
|
+
: null,
|
|
1142
1255
|
h('dt', null, t('path')),
|
|
1143
1256
|
h('dd', { title: row.cwd }, h('span', { className: 'dsm-mono' }, row.cwd || t('noCwd'))),
|
|
1144
1257
|
h('dt', null, t('id')),
|
|
@@ -1168,9 +1281,12 @@ window.__ModuleLoader__.load({
|
|
|
1168
1281
|
h('h3', null, t('moveTo')),
|
|
1169
1282
|
h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveHint')),
|
|
1170
1283
|
row.archived ? h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveArchivedHint')) : null,
|
|
1284
|
+
movable && (row.forkCount ?? 0) > 0
|
|
1285
|
+
? h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveForkChildrenHint', { n: row.forkCount }))
|
|
1286
|
+
: null,
|
|
1171
1287
|
!movable
|
|
1172
1288
|
? h('p', { className: 'dsm-hint' },
|
|
1173
|
-
row.live ? t('moveLiveHint') : row.isSubagent ? t('moveSubagentHint') : row.can?.move === false ? t('moveDescendantsHint') : t('moveNoTarget'))
|
|
1289
|
+
row.live ? t('moveLiveHint') : row.kind === 'fork' ? t('moveForkHint') : row.isSubagent ? t('moveSubagentHint') : row.can?.move === false ? t('moveDescendantsHint') : t('moveNoTarget'))
|
|
1174
1290
|
: h(
|
|
1175
1291
|
'div',
|
|
1176
1292
|
{ className: 'dsm-title-row' },
|
|
@@ -1208,6 +1324,7 @@ window.__ModuleLoader__.load({
|
|
|
1208
1324
|
ctx,
|
|
1209
1325
|
t,
|
|
1210
1326
|
ids: deleteIds,
|
|
1327
|
+
maxBatch: state.limits?.maxBatch ?? 50,
|
|
1211
1328
|
busy,
|
|
1212
1329
|
onCancel: () => setShowDelete(false),
|
|
1213
1330
|
onConfirmed: (ids) => onDelete(ids),
|
|
@@ -1221,6 +1338,38 @@ window.__ModuleLoader__.load({
|
|
|
1221
1338
|
// Page
|
|
1222
1339
|
// ---------------------------------------------------------------------
|
|
1223
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* Order one group's rows so a branch sits directly under its parent, the
|
|
1343
|
+
* way the sidebar nests it. A branch whose parent is filtered out of this
|
|
1344
|
+
* group stays at the top level and keeps its "from parent" meta line.
|
|
1345
|
+
*/
|
|
1346
|
+
function orderBranchRows(rows) {
|
|
1347
|
+
const ids = new Set(rows.map((row) => row.id))
|
|
1348
|
+
const childrenOf = new Map()
|
|
1349
|
+
const tops = []
|
|
1350
|
+
for (const row of rows) {
|
|
1351
|
+
const parentId = row.kind === 'fork' ? row.parentSessionId : null
|
|
1352
|
+
if (parentId !== null && parentId !== row.id && ids.has(parentId)) {
|
|
1353
|
+
const list = childrenOf.get(parentId) ?? []
|
|
1354
|
+
list.push(row)
|
|
1355
|
+
childrenOf.set(parentId, list)
|
|
1356
|
+
} else {
|
|
1357
|
+
tops.push(row)
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
const out = []
|
|
1361
|
+
const visited = new Set()
|
|
1362
|
+
const visit = (row, depth) => {
|
|
1363
|
+
if (visited.has(row.id)) return
|
|
1364
|
+
visited.add(row.id)
|
|
1365
|
+
out.push(depth === 0 ? row : { ...row, branchDepth: depth })
|
|
1366
|
+
for (const child of childrenOf.get(row.id) ?? []) visit(child, depth + 1)
|
|
1367
|
+
}
|
|
1368
|
+
for (const row of tops) visit(row, 0)
|
|
1369
|
+
// A corrupt parent cycle must not silently drop rows from the list.
|
|
1370
|
+
return out.length === rows.length ? out : rows
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1224
1373
|
/**
|
|
1225
1374
|
* Derive the filtered / grouped / rendered view for the list.
|
|
1226
1375
|
* Pure so the selection-pruning effect can use it before the loading guard.
|
|
@@ -1237,7 +1386,7 @@ window.__ModuleLoader__.load({
|
|
|
1237
1386
|
if (workspaceFilter !== 'all' && workspaceFilter !== 'ungrouped' && row.workspaceId !== workspaceFilter) return false
|
|
1238
1387
|
if (showBlank !== true && row.blank === true) return false
|
|
1239
1388
|
if (filtering) {
|
|
1240
|
-
const haystack = `${row.title}\n${row.cwd}\n${row.id}`.toLowerCase()
|
|
1389
|
+
const haystack = `${row.title}\n${row.cwd}\n${row.id}\n${row.parentTitle ?? ''}`.toLowerCase()
|
|
1241
1390
|
if (!haystack.includes(text)) return false
|
|
1242
1391
|
}
|
|
1243
1392
|
return true
|
|
@@ -1250,7 +1399,7 @@ window.__ModuleLoader__.load({
|
|
|
1250
1399
|
})
|
|
1251
1400
|
const baseGroups = []
|
|
1252
1401
|
for (const workspace of workspaceOrder) {
|
|
1253
|
-
const groupRows = sorted.filter((row) => row.workspaceId === workspace.id)
|
|
1402
|
+
const groupRows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
|
|
1254
1403
|
if (groupRows.length === 0) continue
|
|
1255
1404
|
baseGroups.push({
|
|
1256
1405
|
...workspace,
|
|
@@ -1258,7 +1407,7 @@ window.__ModuleLoader__.load({
|
|
|
1258
1407
|
bytes: groupRows.reduce((sum, row) => sum + row.sizeBytes, 0),
|
|
1259
1408
|
})
|
|
1260
1409
|
}
|
|
1261
|
-
const ungroupedRows = sorted.filter((row) => row.workspaceId === null)
|
|
1410
|
+
const ungroupedRows = orderBranchRows(sorted.filter((row) => row.workspaceId === null))
|
|
1262
1411
|
if (ungroupedRows.length > 0) {
|
|
1263
1412
|
baseGroups.push({
|
|
1264
1413
|
id: 'ungrouped',
|
|
@@ -1463,17 +1612,43 @@ window.__ModuleLoader__.load({
|
|
|
1463
1612
|
return value
|
|
1464
1613
|
}
|
|
1465
1614
|
|
|
1615
|
+
/**
|
|
1616
|
+
* Permanently delete sessions in backend-sized batches. One confirmation
|
|
1617
|
+
* may cover more ids than a single request admits (the empty-session
|
|
1618
|
+
* shortcut can), so the request is split and the totals are summed into
|
|
1619
|
+
* one notice.
|
|
1620
|
+
*/
|
|
1466
1621
|
const deleteRows = async (ids) => {
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
)
|
|
1471
|
-
|
|
1622
|
+
if (ids.length === 0) return undefined
|
|
1623
|
+
const max = state?.limits?.maxBatch ?? 50
|
|
1624
|
+
setBusy(true)
|
|
1625
|
+
setNotice(null)
|
|
1626
|
+
try {
|
|
1627
|
+
let succeeded = 0
|
|
1628
|
+
let failed = 0
|
|
1629
|
+
const failures = []
|
|
1630
|
+
for (let index = 0; index < ids.length; index += max) {
|
|
1631
|
+
const value = await post('delete', { sessionIds: ids.slice(index, index + max), confirm: true })
|
|
1632
|
+
succeeded += value?.totals?.succeeded ?? 0
|
|
1633
|
+
failed += value?.totals?.failed ?? 0
|
|
1634
|
+
for (const item of value?.results ?? []) if (item.ok === false) failures.push(item)
|
|
1635
|
+
}
|
|
1636
|
+
setNotice(
|
|
1637
|
+
failed === 0
|
|
1638
|
+
? { tone: 'ok', text: t('batchDone', { ok: succeeded, failed: 0 }) }
|
|
1639
|
+
: { tone: 'error', text: t('batchDone', { ok: succeeded, failed }), details: failures.slice(0, 20) },
|
|
1640
|
+
)
|
|
1472
1641
|
setBulkDeleteIds(null)
|
|
1473
1642
|
setSelected(new Set())
|
|
1474
1643
|
if (selectedId !== null && ids.includes(selectedId)) setSelectedId(null)
|
|
1644
|
+
await load({ silent: true })
|
|
1645
|
+
return { totals: { succeeded, failed } }
|
|
1646
|
+
} catch (failure) {
|
|
1647
|
+
setNotice({ tone: 'error', text: errorText(t, failure), detail: failure?.message })
|
|
1648
|
+
return undefined
|
|
1649
|
+
} finally {
|
|
1650
|
+
setBusy(false)
|
|
1475
1651
|
}
|
|
1476
|
-
return value
|
|
1477
1652
|
}
|
|
1478
1653
|
|
|
1479
1654
|
const cleanupOrphans = async () => {
|
|
@@ -1497,6 +1672,40 @@ window.__ModuleLoader__.load({
|
|
|
1497
1672
|
}
|
|
1498
1673
|
}
|
|
1499
1674
|
|
|
1675
|
+
/**
|
|
1676
|
+
* Archive every unarchived empty session, in backend-sized batches. The
|
|
1677
|
+
* user can then clean them up with one confirm dialog.
|
|
1678
|
+
*/
|
|
1679
|
+
const archiveBlankSessions = async (ids) => {
|
|
1680
|
+
if (ids.length === 0) return
|
|
1681
|
+
const max = state?.limits?.maxBatch ?? 50
|
|
1682
|
+
setBusy(true)
|
|
1683
|
+
setNotice(null)
|
|
1684
|
+
try {
|
|
1685
|
+
let archived = 0
|
|
1686
|
+
const failures = []
|
|
1687
|
+
for (let index = 0; index < ids.length; index += max) {
|
|
1688
|
+
const value = await post('archive', { sessionIds: ids.slice(index, index + max) })
|
|
1689
|
+
archived += value?.totals?.succeeded ?? 0
|
|
1690
|
+
for (const item of value?.results ?? []) if (item.ok === false) failures.push(item)
|
|
1691
|
+
}
|
|
1692
|
+
if (failures.length === 0) {
|
|
1693
|
+
setNotice({ tone: 'ok', text: t('archiveDone', { n: archived }) })
|
|
1694
|
+
} else {
|
|
1695
|
+
setNotice({
|
|
1696
|
+
tone: 'error',
|
|
1697
|
+
text: t('batchDone', { ok: archived, failed: failures.length }),
|
|
1698
|
+
details: failures.slice(0, 20),
|
|
1699
|
+
})
|
|
1700
|
+
}
|
|
1701
|
+
await load({ silent: true })
|
|
1702
|
+
} catch (failure) {
|
|
1703
|
+
setNotice({ tone: 'error', text: errorText(t, failure) })
|
|
1704
|
+
} finally {
|
|
1705
|
+
setBusy(false)
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1500
1709
|
const openSession = (id) => {
|
|
1501
1710
|
let service
|
|
1502
1711
|
try {
|
|
@@ -1533,8 +1742,9 @@ window.__ModuleLoader__.load({
|
|
|
1533
1742
|
|
|
1534
1743
|
const rowsForTabs = rows.filter((row) => showBlank || row.blank !== true)
|
|
1535
1744
|
const blankRows = rows.filter((row) => row.blank === true)
|
|
1536
|
-
const
|
|
1537
|
-
const
|
|
1745
|
+
const blankUnarchivedRows = blankRows.filter((row) => row.archived !== true)
|
|
1746
|
+
const blankArchivedRows = blankRows.filter((row) => row.archived === true)
|
|
1747
|
+
const blankDeletableRows = blankArchivedRows.filter((row) => row.can?.delete === true)
|
|
1538
1748
|
const tabs = [
|
|
1539
1749
|
{ key: 'all', label: t('tabAll'), count: rowsForTabs.length },
|
|
1540
1750
|
{ key: 'active', label: t('tabActive'), count: rowsForTabs.filter((row) => !row.archived).length },
|
|
@@ -1635,35 +1845,48 @@ window.__ModuleLoader__.load({
|
|
|
1635
1845
|
h(
|
|
1636
1846
|
'div',
|
|
1637
1847
|
{ className: 'dsm-head' },
|
|
1638
|
-
h(
|
|
1848
|
+
h(
|
|
1849
|
+
'div',
|
|
1850
|
+
{ className: 'dsm-head-row' },
|
|
1639
1851
|
h('h2', { className: 'dsm-title' }, t('pageTitle')),
|
|
1640
|
-
h('
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
:
|
|
1852
|
+
h('div', { className: 'dsm-head-actions' },
|
|
1853
|
+
blankUnarchivedRows.length > 0
|
|
1854
|
+
? h('button', {
|
|
1855
|
+
type: 'button',
|
|
1856
|
+
className: 'dsm-btn',
|
|
1857
|
+
disabled: busy,
|
|
1858
|
+
title: t('archiveBlankHint'),
|
|
1859
|
+
onClick: () => void archiveBlankSessions(blankUnarchivedRows.map((row) => row.id)),
|
|
1860
|
+
}, t('archiveBlank', { n: blankUnarchivedRows.length }))
|
|
1861
|
+
: null,
|
|
1862
|
+
blankArchivedRows.length > 0
|
|
1863
|
+
? h('button', {
|
|
1864
|
+
type: 'button',
|
|
1865
|
+
className: 'dsm-btn',
|
|
1866
|
+
disabled: busy || blankDeletableRows.length === 0,
|
|
1867
|
+
title: blankDeletableRows.length === blankArchivedRows.length
|
|
1868
|
+
? t('cleanupBlankHint')
|
|
1869
|
+
: t('cleanupBlankLockedHint', { n: blankArchivedRows.length - blankDeletableRows.length }),
|
|
1870
|
+
onClick: () => setBulkDeleteIds(blankDeletableRows.map((row) => row.id)),
|
|
1871
|
+
}, t('cleanupBlank', { n: blankArchivedRows.length }))
|
|
1872
|
+
: null,
|
|
1873
|
+
h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void load({ silent: true }) },
|
|
1874
|
+
busy ? h(Spinner, { t, label: t('working') }) : t('refresh')),
|
|
1650
1875
|
),
|
|
1651
|
-
blankActiveCount > 0 && showBlank
|
|
1652
|
-
? h('p', { className: 'dsm-hint' }, t('blankActiveHint', { n: blankActiveCount }))
|
|
1653
|
-
: null,
|
|
1654
1876
|
),
|
|
1655
|
-
h('
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
:
|
|
1665
|
-
|
|
1666
|
-
|
|
1877
|
+
h('p', { className: 'dsm-sub' }, t('pageSubtitle')),
|
|
1878
|
+
h('p', { className: 'dsm-sub' },
|
|
1879
|
+
t('totalLine', {
|
|
1880
|
+
all: state.totals.all,
|
|
1881
|
+
archived: state.totals.archived,
|
|
1882
|
+
size: formatBytes(state.totals.bytes),
|
|
1883
|
+
}),
|
|
1884
|
+
blankRows.length > 0 && !showBlank
|
|
1885
|
+
? ` · ${t('hiddenBlankCount', { n: blankRows.length })}`
|
|
1886
|
+
: '',
|
|
1887
|
+
(state.totals?.hiddenSubagents ?? 0) > 0 && !includeSubagents
|
|
1888
|
+
? ` · ${t('hiddenSubagentCount', { n: state.totals.hiddenSubagents })}`
|
|
1889
|
+
: '',
|
|
1667
1890
|
),
|
|
1668
1891
|
),
|
|
1669
1892
|
h(Notice, { notice, t, onDismiss: () => setNotice(null) }),
|
|
@@ -1726,7 +1949,7 @@ window.__ModuleLoader__.load({
|
|
|
1726
1949
|
h('option', { value: 'size' }, t('sortSize')),
|
|
1727
1950
|
h('option', { value: 'title' }, t('sortTitle')),
|
|
1728
1951
|
),
|
|
1729
|
-
h('label', { className: 'dsm-check' },
|
|
1952
|
+
h('label', { className: 'dsm-check', title: t('showSubagentsHint') },
|
|
1730
1953
|
h('input', {
|
|
1731
1954
|
type: 'checkbox',
|
|
1732
1955
|
checked: includeSubagents,
|
|
@@ -1802,7 +2025,11 @@ window.__ModuleLoader__.load({
|
|
|
1802
2025
|
}, t('clearSelection')),
|
|
1803
2026
|
!selectedMovable && selectedRows.length > 0
|
|
1804
2027
|
? h('span', { className: 'dsm-hint' },
|
|
1805
|
-
selectedRows.some((row) => row.live || row.running)
|
|
2028
|
+
selectedRows.some((row) => row.live || row.running)
|
|
2029
|
+
? t('liveHint')
|
|
2030
|
+
: selectedRows.some((row) => row.kind === 'fork')
|
|
2031
|
+
? t('moveForkHint')
|
|
2032
|
+
: t('moveUnavailableHint'))
|
|
1806
2033
|
: null,
|
|
1807
2034
|
)
|
|
1808
2035
|
: null,
|
|
@@ -1829,6 +2056,7 @@ window.__ModuleLoader__.load({
|
|
|
1829
2056
|
ctx,
|
|
1830
2057
|
t,
|
|
1831
2058
|
ids: bulkDeleteIds,
|
|
2059
|
+
maxBatch: state.limits?.maxBatch ?? 50,
|
|
1832
2060
|
busy,
|
|
1833
2061
|
onCancel: () => setBulkDeleteIds(null),
|
|
1834
2062
|
onConfirmed: (ids) => void deleteRows(ids),
|