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/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
- blankActiveHint: '另有 {n} 个未归档空会话,归档后才能清理。',
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
- blankActiveHint: '{n} unarchived empty sessions must be archived before cleanup.',
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 descendants, or not ready).',
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
- .dsm-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
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
- .dsm-detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap}
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
- { className: `dsm-row${selected === true ? ' selected' : ''}` },
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('span', { className: 'dsm-row-title' }, row.title === '' ? t('untitled') : row.title),
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
- post('delete/preview', { sessionIds: ids }, controller.signal)
950
- .then((value) => {
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
- setPreview(value)
953
- setPhase('ready')
954
- })
955
- .catch((failure) => {
956
- if (failure?.name === 'AbortError') return
957
- setError(errorText(t, failure))
958
- setPhase('error')
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
- const value = await run(
1468
- () => post('delete', { sessionIds: ids, confirm: true }),
1469
- (result) => resultText(result),
1470
- )
1471
- if (value !== undefined) {
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 blankArchivedRows = blankRows.filter((row) => row.archived === true && row.can?.delete === true)
1537
- const blankActiveCount = blankRows.filter((row) => row.archived !== true || row.can?.delete !== true).length
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('div', null,
1848
+ h(
1849
+ 'div',
1850
+ { className: 'dsm-head-row' },
1639
1851
  h('h2', { className: 'dsm-title' }, t('pageTitle')),
1640
- h('p', { className: 'dsm-sub' }, t('pageSubtitle')),
1641
- h('p', { className: 'dsm-sub' },
1642
- t('totalLine', {
1643
- all: state.totals.all,
1644
- archived: state.totals.archived,
1645
- size: formatBytes(state.totals.bytes),
1646
- }),
1647
- blankRows.length > 0 && !showBlank
1648
- ? ` · ${t('hiddenBlankCount', { n: blankRows.length })}`
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('div', { className: 'dsm-head-actions' },
1656
- blankArchivedRows.length > 0
1657
- ? h('button', {
1658
- type: 'button',
1659
- className: 'dsm-btn',
1660
- disabled: busy,
1661
- title: `${t('cleanupBlankHint')}${blankActiveCount > 0 ? ` ${t('blankActiveHint', { n: blankActiveCount })}` : ''}`,
1662
- onClick: () => setBulkDeleteIds(blankArchivedRows.map((row) => row.id)),
1663
- }, t('cleanupBlank', { n: blankArchivedRows.length }))
1664
- : null,
1665
- h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void load({ silent: true }) },
1666
- busy ? h(Spinner, { t, label: t('working') }) : t('refresh')),
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) ? t('liveHint') : t('moveUnavailableHint'))
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),