dsh-session-mng 0.1.1 → 0.1.3

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,7 +43,13 @@ window.__ModuleLoader__.load({
43
43
  sortCreated: '创建时间',
44
44
  sortSize: '大小',
45
45
  sortTitle: '标题',
46
- showSubagents: '显示子会话',
46
+ showSubagents: '显示子代理会话',
47
+ showSubagentsHint: '子代理会话由委派产生,默认隐藏;分支会话始终显示。',
48
+ showBlank: '显示空会话',
49
+ cleanupBlank: '清理空会话({n})',
50
+ cleanupBlankHint: '批量删除已归档的空会话;未归档的空会话需先归档。',
51
+ blankActiveHint: '另有 {n} 个未归档空会话,归档后才能清理。',
52
+ hiddenBlankCount: '已隐藏 {n} 个空会话',
47
53
  selectAll: '全选当前列表',
48
54
  expandAll: '展开全部',
49
55
  collapseAll: '收起全部',
@@ -65,7 +71,14 @@ window.__ModuleLoader__.load({
65
71
  untitled: '(未命名)',
66
72
  running: '运行中',
67
73
  archived: '已归档',
68
- subagent: '子会话',
74
+ subagent: '子代理',
75
+ branch: '分支',
76
+ fromParent: '↳ 来自:{title}',
77
+ hiddenSubagentCount: '已隐藏 {n} 个子代理会话',
78
+ forkCount: '{n} 分支',
79
+ forkCountHint: '该会话有 {n} 个分支会话',
80
+ parentSession: '父会话',
81
+ parentMissing: '来源父会话已不存在',
69
82
  pinned: '已置顶',
70
83
  liveBadge: '已打开',
71
84
  noCwd: '无工作目录',
@@ -108,7 +121,9 @@ window.__ModuleLoader__.load({
108
121
  moving: '正在移动…',
109
122
  moveNoTarget: '没有可移动到的目标工作区。',
110
123
  moveLiveHint: '该会话已在当前 DSH 进程中打开,重启 DSH 后再移动。',
111
- moveSubagentHint: '子会话不能单独移动。',
124
+ moveSubagentHint: '子代理会话不能单独移动。',
125
+ moveForkHint: '分支会话必须和父会话留在同一工作区,暂不支持单独移动。',
126
+ moveForkChildrenHint: '该会话有 {n} 个分支会话;移动只迁移本会话,分支会留在原工作区并继续独立使用。',
112
127
  moveDescendantsHint: '该会话包含子代理会话,暂时不能单独移动。',
113
128
  deleteAction: '彻底删除',
114
129
  deleteHint: '只允许删除已归档会话;删除后日志目录与工作区记录都会被移除,无法恢复。',
@@ -125,6 +140,7 @@ window.__ModuleLoader__.load({
125
140
  deleteNotArchived: '只有已归档会话可以彻底删除。',
126
141
  deleteLiveHint: '该会话已在当前 DSH 进程中打开,重启 DSH 后再删除。',
127
142
  deleteCascadeNote: '如果该会话有子代理会话,会一并删除。',
143
+ deleteForkNote: '另有 {n} 个历史分支会话不会被删除,但会失去「来自父会话」的来源标识;如需一起删除,请把它们也勾选进来。',
128
144
  archiveAction: '归档',
129
145
  archiving: '正在归档…',
130
146
  archiveDone: '已归档 {n} 个会话。',
@@ -159,7 +175,7 @@ window.__ModuleLoader__.load({
159
175
  timeout: '模型调用超时。',
160
176
  llmError: '模型调用失败:{message}',
161
177
  liveHint: '已打开的会话不能移动或彻底删除;重启 DSH 后重试。',
162
- moveUnavailableHint: '所选会话中存在不可移动项(如跨工作区、有子会话或状态未就绪)。',
178
+ moveUnavailableHint: '所选会话中存在不可移动项(如跨工作区、分支/子代理会话或状态未就绪)。',
163
179
  archiveHint: '归档后会话会从活跃列表移到已归档,可在本页恢复。',
164
180
  moveArchivedHint: '移动不会改变归档状态,会话仍会留在已归档。',
165
181
  working: '处理中…',
@@ -192,6 +208,12 @@ window.__ModuleLoader__.load({
192
208
  sortSize: 'Size',
193
209
  sortTitle: 'Title',
194
210
  showSubagents: 'Show subagent sessions',
211
+ showSubagentsHint: 'Subagent sessions are produced by delegation and stay hidden by default; branch sessions are always listed.',
212
+ showBlank: 'Show empty sessions',
213
+ cleanupBlank: 'Clean up empty sessions ({n})',
214
+ cleanupBlankHint: 'Permanently delete archived empty sessions; unarchived empty sessions must be archived first.',
215
+ blankActiveHint: '{n} unarchived empty sessions must be archived before cleanup.',
216
+ hiddenBlankCount: '{n} empty sessions hidden',
195
217
  selectAll: 'Select all shown',
196
218
  expandAll: 'Expand all',
197
219
  collapseAll: 'Collapse all',
@@ -214,6 +236,13 @@ window.__ModuleLoader__.load({
214
236
  running: 'Running',
215
237
  archived: 'Archived',
216
238
  subagent: 'Subagent',
239
+ branch: 'Branch',
240
+ fromParent: '↳ from: {title}',
241
+ hiddenSubagentCount: '{n} subagent sessions hidden',
242
+ forkCount: '{n} branch',
243
+ forkCountHint: 'This session has {n} branch sessions',
244
+ parentSession: 'Parent session',
245
+ parentMissing: 'Source session no longer exists',
217
246
  pinned: 'Pinned',
218
247
  liveBadge: 'Open',
219
248
  noCwd: 'No working directory',
@@ -257,6 +286,8 @@ window.__ModuleLoader__.load({
257
286
  moveNoTarget: 'No target workspace is available.',
258
287
  moveLiveHint: 'This session is open in this DSH process; restart DSH and retry.',
259
288
  moveSubagentHint: 'Subagent sessions cannot be moved on their own.',
289
+ moveForkHint: 'A branch session must stay in its parent\'s workspace, so it cannot be moved on its own.',
290
+ moveForkChildrenHint: 'This session has {n} branch sessions; moving migrates only this session, and the branches stay in the original workspace and keep working independently.',
260
291
  moveDescendantsHint: 'This session has subagent descendants and cannot move alone yet.',
261
292
  deleteAction: 'Delete permanently',
262
293
  deleteHint: 'Only archived sessions can be deleted. The log directory and workspace record are removed; this cannot be undone.',
@@ -273,6 +304,7 @@ window.__ModuleLoader__.load({
273
304
  deleteNotArchived: 'Only archived sessions can be permanently deleted.',
274
305
  deleteLiveHint: 'This session is open in this DSH process; restart DSH and retry.',
275
306
  deleteCascadeNote: 'Any subagent descendants are deleted too.',
307
+ deleteForkNote: '{n} branch sessions are not deleted, but they lose their "from parent" label. Select them too if they should be deleted as well.',
276
308
  archiveAction: 'Archive',
277
309
  archiving: 'Archiving…',
278
310
  archiveDone: 'Archived {n} sessions.',
@@ -307,7 +339,7 @@ window.__ModuleLoader__.load({
307
339
  timeout: 'The model call timed out.',
308
340
  llmError: 'The model call failed: {message}',
309
341
  liveHint: 'An open session cannot be moved or deleted; restart DSH and retry.',
310
- moveUnavailableHint: 'Some selected sessions cannot be moved (cross-workspace, subagent descendants, or not ready).',
342
+ moveUnavailableHint: 'Some selected sessions cannot be moved (cross-workspace, branch or subagent sessions, or not ready).',
311
343
  archiveHint: 'Archiving moves the session from the active list to the archived list; restore it here.',
312
344
  moveArchivedHint: 'Moving does not change the archive state; the session stays archived.',
313
345
  working: 'Working…',
@@ -393,6 +425,7 @@ window.__ModuleLoader__.load({
393
425
  .dsm-row-main{display:flex;flex-direction:column;align-items:flex-start;gap:1px;min-width:0;
394
426
  border:none;background:transparent;color:inherit;font:inherit;text-align:left;padding:0;cursor:pointer}
395
427
  .dsm-row-title{max-width:100%;font-size:13px;line-height:19px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
428
+ .dsm-branch-glyph{margin-right:4px;font-size:12px;line-height:1;color:var(--dsw-alias-label-secondary);opacity:.85}
396
429
  .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}
397
430
  .dsm-badges{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
398
431
  .dsm-badge{display:inline-flex;align-items:center;height:17px;padding:0 5px;border-radius:4px;font-size:10px;line-height:17px;
@@ -430,6 +463,22 @@ window.__ModuleLoader__.load({
430
463
  .dsm-scope-row .dsm-mono{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
431
464
  .dsm-danger-box{border:1px solid var(--dsw-alias-state-error-primary);border-radius:8px;padding:10px;background:var(--dsw-alias-bg-layer-1)}
432
465
  .dsm-danger-box h3{color:var(--dsw-alias-state-error-primary)}
466
+ .dsm-scope-overlay{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;
467
+ padding:20px;background:rgba(0,0,0,.58)}
468
+ .dsm-scope-dialog{display:flex;flex-direction:column;width:min(660px,100%);max-height:min(82vh,760px);
469
+ border:1px solid var(--dsw-alias-state-error-primary);border-radius:10px;overflow:hidden;
470
+ background:var(--dsw-alias-bg-layer-2);box-shadow:0 24px 70px rgba(0,0,0,.5)}
471
+ .dsm-scope-head{display:flex;align-items:center;justify-content:space-between;gap:10px;
472
+ padding:11px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
473
+ .dsm-scope-head h3{margin:0;font-size:13px;line-height:18px;color:var(--dsw-alias-state-error-primary)}
474
+ .dsm-scope-body{flex:1 1 auto;min-height:0;overflow:auto;padding:12px 14px}
475
+ .dsm-scope-foot{display:flex;flex-direction:column;gap:8px;padding:10px 14px;
476
+ border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
477
+ .dsm-scope-dialog .dsm-scope-list{max-height:none;overflow:visible;margin:8px 0}
478
+ @media (max-width:640px){
479
+ .dsm-scope-overlay{padding:10px}
480
+ .dsm-scope-dialog{max-height:92vh}
481
+ }
433
482
  .dsm-footer{font-size:11px;line-height:17px;color:var(--dsw-alias-label-secondary)}
434
483
  .dsm-spin{display:inline-block;width:12px;height:12px;border:2px solid var(--dsw-alias-border-l2);
435
484
  border-top-color:var(--dsw-alias-brand-primary);border-radius:50%;animation:dsm-spin .8s linear infinite;vertical-align:-2px}
@@ -517,6 +566,7 @@ window.__ModuleLoader__.load({
517
566
  if (code === 'session-running') return t('sessionRunning')
518
567
  if (code === 'session-writer-held') return t('writerHeld')
519
568
  if (code === 'has-descendants') return t('moveDescendantsHint')
569
+ if (code === 'branch-session') return t('moveForkHint')
520
570
  if (code === 'archived-required') return t('deleteNotArchived')
521
571
  if (code === 'unsupported-backend') return t('unsupportedBackend')
522
572
  if (code === 'unsupported-registry') return t('unsupportedRegistry')
@@ -633,15 +683,28 @@ window.__ModuleLoader__.load({
633
683
  }
634
684
 
635
685
  function Row({ row, selected, onToggle, onOpen, t }) {
686
+ const depth = row.branchDepth ?? 0
687
+ const branch = row.kind === 'fork'
688
+ const forkCount = row.forkCount ?? 0
636
689
  const badges = []
637
690
  if (row.running) badges.push(h(Badge, { key: 'run', tone: 'run' }, t('running')))
638
691
  if (row.archived) badges.push(h(Badge, { key: 'arch' }, t('archived')))
639
692
  if (row.live) badges.push(h(Badge, { key: 'live', tone: 'brand' }, t('liveBadge')))
640
693
  if (row.pinned) badges.push(h(Badge, { key: 'pin' }, t('pinned')))
694
+ if (branch) badges.push(h(Badge, { key: 'fork', tone: 'brand' }, t('branch')))
641
695
  if (row.isSubagent) badges.push(h(Badge, { key: 'sub', tone: 'warn' }, t('subagent')))
696
+ if (forkCount > 0) {
697
+ badges.push(
698
+ h('span', { key: 'children', title: t('forkCountHint', { n: forkCount }) },
699
+ h(Badge, null, t('forkCount', { n: forkCount }))),
700
+ )
701
+ }
642
702
  return h(
643
703
  'div',
644
- { className: `dsm-row${selected === true ? ' selected' : ''}` },
704
+ {
705
+ className: `dsm-row${selected === true ? ' selected' : ''}${depth > 0 ? ' dsm-row-branch' : ''}`,
706
+ style: depth > 0 ? { paddingLeft: `${depth * 18}px` } : undefined,
707
+ },
645
708
  h('input', {
646
709
  type: 'checkbox',
647
710
  checked: selected === true,
@@ -651,10 +714,20 @@ window.__ModuleLoader__.load({
651
714
  h(
652
715
  'button',
653
716
  { type: 'button', className: 'dsm-row-main', onClick: onOpen, title: row.cwd || row.id },
654
- h('span', { className: 'dsm-row-title' }, row.title === '' ? t('untitled') : row.title),
717
+ h(
718
+ 'span',
719
+ { className: 'dsm-row-title' },
720
+ depth > 0 ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
721
+ row.title === '' ? t('untitled') : row.title,
722
+ ),
655
723
  h(
656
724
  'span',
657
725
  { className: 'dsm-row-meta' },
726
+ branch && row.parentTitle !== ''
727
+ ? `${t('fromParent', { title: row.parentTitle })} · `
728
+ : branch && row.parentPresent === false
729
+ ? `${t('parentMissing')} · `
730
+ : '',
658
731
  row.cwd === '' ? t('noCwd') : row.cwd,
659
732
  ' · ',
660
733
  row.id,
@@ -919,6 +992,7 @@ window.__ModuleLoader__.load({
919
992
  React.useEffect(() => {
920
993
  const controller = new AbortController()
921
994
  setPhase('loading')
995
+ setChecked(false)
922
996
  post('delete/preview', { sessionIds: ids }, controller.signal)
923
997
  .then((value) => {
924
998
  if (controller.signal.aborted) return
@@ -933,43 +1007,89 @@ window.__ModuleLoader__.load({
933
1007
  return () => controller.abort()
934
1008
  }, [idsKey, t])
935
1009
 
1010
+ React.useEffect(() => {
1011
+ const onKeyDown = (event) => {
1012
+ if (event.key !== 'Escape') return
1013
+ event.preventDefault()
1014
+ event.stopPropagation()
1015
+ if (busy !== true) onCancel()
1016
+ }
1017
+ document.addEventListener('keydown', onKeyDown, true)
1018
+ return () => document.removeEventListener('keydown', onKeyDown, true)
1019
+ }, [busy, onCancel])
1020
+
936
1021
  const rows = preview?.sessions ?? []
937
1022
  const blocked = rows.some((row) => row.live || row.running)
1023
+ const ready = phase === 'ready' && preview !== null
938
1024
  return h(
939
1025
  'div',
940
- { className: 'dsm-danger-box' },
941
- h('h3', null, t('deleteScope')),
942
- h('p', { className: 'dsm-hint', style: { margin: '0 0 6px' } }, t('deleteHint')),
943
- phase === 'loading'
944
- ? h('p', { className: 'dsm-hint' }, t('deletePreviewLoading'))
945
- : phase === 'error'
946
- ? h('p', { className: 'dsm-error' }, error)
947
- : h(
948
- React.Fragment,
949
- null,
950
- h('div', { className: 'dsm-hint' },
951
- `${t('deleteRoots')}: ${preview?.roots?.length ?? 0} · ${t('deleteDescendants')}: ${preview?.descendantCount ?? 0} · ${t('deleteTotalSize', { size: formatBytes(preview?.totalBytes ?? 0) })}`,
952
- ),
953
- h('div', { className: 'dsm-scope-list' },
954
- rows.map((row) =>
955
- h(
956
- 'div',
957
- { key: row.id, className: 'dsm-scope-row' },
958
- h('span', { className: 'dsm-badge' }, row.descendant ? t('scopeChild') : t('scopeRoot')),
959
- h('span', { className: 'dsm-mono', title: row.dir ?? row.id }, row.id),
960
- row.live ? h(Badge, { tone: 'brand' }, t('liveBadge')) : null,
961
- row.running ? h(Badge, { tone: 'run' }, t('running')) : null,
962
- row.missing ? h(Badge, { tone: 'warn' }, t('missingDir')) : null,
963
- row.readable === false ? h(Badge, { tone: 'warn' }, t('unreadable')) : null,
964
- h('span', { style: { marginLeft: 'auto' } }, formatBytes(row.bytes)),
1026
+ {
1027
+ className: 'dsm-scope-overlay',
1028
+ onMouseDown: (event) => {
1029
+ if (event.target === event.currentTarget && busy !== true) onCancel()
1030
+ },
1031
+ },
1032
+ h(
1033
+ 'div',
1034
+ {
1035
+ className: 'dsm-scope-dialog',
1036
+ role: 'dialog',
1037
+ 'aria-modal': 'true',
1038
+ 'aria-label': t('deleteScope'),
1039
+ },
1040
+ h(
1041
+ 'div',
1042
+ { className: 'dsm-scope-head' },
1043
+ h('h3', null, t('deleteScope')),
1044
+ h('button', {
1045
+ type: 'button',
1046
+ className: 'dsm-btn ghost',
1047
+ 'aria-label': t('close'),
1048
+ disabled: busy,
1049
+ onClick: onCancel,
1050
+ }, '×'),
1051
+ ),
1052
+ h(
1053
+ 'div',
1054
+ { className: 'dsm-scope-body' },
1055
+ h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('deleteHint')),
1056
+ phase === 'loading'
1057
+ ? h('p', { className: 'dsm-hint' }, t('deletePreviewLoading'))
1058
+ : phase === 'error'
1059
+ ? h('p', { className: 'dsm-error' }, error)
1060
+ : h(
1061
+ React.Fragment,
1062
+ null,
1063
+ h('div', { className: 'dsm-hint' },
1064
+ `${t('deleteRoots')}: ${preview?.roots?.length ?? 0} · ${t('deleteDescendants')}: ${preview?.descendantCount ?? 0} · ${t('deleteTotalSize', { size: formatBytes(preview?.totalBytes ?? 0) })}`,
1065
+ ),
1066
+ (preview?.forkChildren?.length ?? 0) > 0
1067
+ ? h('p', { className: 'dsm-hint' }, t('deleteForkNote', { n: preview.forkChildren.length }))
1068
+ : null,
1069
+ h('div', { className: 'dsm-scope-list' },
1070
+ rows.map((row) =>
1071
+ h(
1072
+ 'div',
1073
+ { key: row.id, className: 'dsm-scope-row' },
1074
+ h('span', { className: 'dsm-badge' }, row.descendant ? t('scopeChild') : t('scopeRoot')),
1075
+ h('span', { className: 'dsm-mono', title: row.dir ?? row.id }, row.id),
1076
+ row.live ? h(Badge, { tone: 'brand' }, t('liveBadge')) : null,
1077
+ row.running ? h(Badge, { tone: 'run' }, t('running')) : null,
1078
+ row.missing ? h(Badge, { tone: 'warn' }, t('missingDir')) : null,
1079
+ row.readable === false ? h(Badge, { tone: 'warn' }, t('unreadable')) : null,
1080
+ h('span', { style: { marginLeft: 'auto' } }, formatBytes(row.bytes)),
1081
+ ),
1082
+ ),
965
1083
  ),
1084
+ h('p', { className: 'dsm-hint', style: { margin: '0 0 6px' } }, t('deleteCascadeNote')),
1085
+ blocked ? h('p', { className: 'dsm-error' }, t('deleteLiveHint')) : null,
966
1086
  ),
967
- ),
968
- h('p', { className: 'dsm-hint', style: { margin: '0 0 6px' } }, t('deleteCascadeNote')),
969
- blocked
970
- ? h('p', { className: 'dsm-error' }, t('deleteLiveHint'))
971
- : null,
972
- h('label', { className: 'dsm-check', style: { marginBottom: 8 } },
1087
+ ),
1088
+ h(
1089
+ 'div',
1090
+ { className: 'dsm-scope-foot' },
1091
+ ready
1092
+ ? h('label', { className: 'dsm-check' },
973
1093
  h('input', {
974
1094
  type: 'checkbox',
975
1095
  checked,
@@ -977,20 +1097,22 @@ window.__ModuleLoader__.load({
977
1097
  onChange: (event) => setChecked(event.target.checked),
978
1098
  }),
979
1099
  t('deleteConfirmCheck'),
980
- ),
981
- h(
982
- 'div',
983
- { className: 'dsm-actions' },
984
- h('button', {
985
- type: 'button',
986
- className: 'dsm-btn danger',
987
- disabled: busy || blocked || checked !== true,
988
- onClick: () => onConfirmed(ids),
989
- }, busy ? t('deleting') : t('deleteConfirm')),
990
- h('button', { type: 'button', className: 'dsm-btn ghost', disabled: busy, onClick: onCancel }, t('cancel')),
991
- blocked ? h('span', { className: 'dsm-hint' }, t('liveHint')) : null,
992
- ),
993
- ),
1100
+ )
1101
+ : null,
1102
+ h(
1103
+ 'div',
1104
+ { className: 'dsm-actions' },
1105
+ h('button', {
1106
+ type: 'button',
1107
+ className: 'dsm-btn danger',
1108
+ disabled: busy || ready !== true || blocked || checked !== true,
1109
+ onClick: () => onConfirmed(ids),
1110
+ }, busy ? t('deleting') : t('deleteConfirm')),
1111
+ h('button', { type: 'button', className: 'dsm-btn ghost', disabled: busy, onClick: onCancel }, t('cancel')),
1112
+ blocked ? h('span', { className: 'dsm-hint' }, t('liveHint')) : null,
1113
+ ),
1114
+ ),
1115
+ ),
994
1116
  )
995
1117
  }
996
1118
 
@@ -1046,6 +1168,7 @@ window.__ModuleLoader__.load({
1046
1168
  row.archived ? h(Badge, null, t('archived')) : null,
1047
1169
  row.live ? h(Badge, { tone: 'brand' }, t('liveBadge')) : null,
1048
1170
  row.pinned ? h(Badge, null, t('pinned')) : null,
1171
+ row.kind === 'fork' ? h(Badge, { tone: 'brand' }, t('branch')) : null,
1049
1172
  row.isSubagent ? h(Badge, { tone: 'warn' }, t('subagent')) : null,
1050
1173
  ),
1051
1174
  ),
@@ -1067,6 +1190,16 @@ window.__ModuleLoader__.load({
1067
1190
  { className: 'dsm-meta' },
1068
1191
  h('dt', null, t('workspace')),
1069
1192
  h('dd', null, row.workspaceTitle || t('workspaceUngrouped')),
1193
+ row.kind === 'fork'
1194
+ ? h(React.Fragment, null,
1195
+ h('dt', null, t('parentSession')),
1196
+ h('dd', null,
1197
+ row.parentTitle !== ''
1198
+ ? row.parentTitle
1199
+ : row.parentPresent === false
1200
+ ? t('parentMissing')
1201
+ : (row.parentSessionId || '')))
1202
+ : null,
1070
1203
  h('dt', null, t('path')),
1071
1204
  h('dd', { title: row.cwd }, h('span', { className: 'dsm-mono' }, row.cwd || t('noCwd'))),
1072
1205
  h('dt', null, t('id')),
@@ -1096,9 +1229,12 @@ window.__ModuleLoader__.load({
1096
1229
  h('h3', null, t('moveTo')),
1097
1230
  h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveHint')),
1098
1231
  row.archived ? h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveArchivedHint')) : null,
1232
+ movable && (row.forkCount ?? 0) > 0
1233
+ ? h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveForkChildrenHint', { n: row.forkCount }))
1234
+ : null,
1099
1235
  !movable
1100
1236
  ? h('p', { className: 'dsm-hint' },
1101
- row.live ? t('moveLiveHint') : row.isSubagent ? t('moveSubagentHint') : row.can?.move === false ? t('moveDescendantsHint') : t('moveNoTarget'))
1237
+ row.live ? t('moveLiveHint') : row.kind === 'fork' ? t('moveForkHint') : row.isSubagent ? t('moveSubagentHint') : row.can?.move === false ? t('moveDescendantsHint') : t('moveNoTarget'))
1102
1238
  : h(
1103
1239
  'div',
1104
1240
  { className: 'dsm-title-row' },
@@ -1149,11 +1285,43 @@ window.__ModuleLoader__.load({
1149
1285
  // Page
1150
1286
  // ---------------------------------------------------------------------
1151
1287
 
1288
+ /**
1289
+ * Order one group's rows so a branch sits directly under its parent, the
1290
+ * way the sidebar nests it. A branch whose parent is filtered out of this
1291
+ * group stays at the top level and keeps its "from parent" meta line.
1292
+ */
1293
+ function orderBranchRows(rows) {
1294
+ const ids = new Set(rows.map((row) => row.id))
1295
+ const childrenOf = new Map()
1296
+ const tops = []
1297
+ for (const row of rows) {
1298
+ const parentId = row.kind === 'fork' ? row.parentSessionId : null
1299
+ if (parentId !== null && parentId !== row.id && ids.has(parentId)) {
1300
+ const list = childrenOf.get(parentId) ?? []
1301
+ list.push(row)
1302
+ childrenOf.set(parentId, list)
1303
+ } else {
1304
+ tops.push(row)
1305
+ }
1306
+ }
1307
+ const out = []
1308
+ const visited = new Set()
1309
+ const visit = (row, depth) => {
1310
+ if (visited.has(row.id)) return
1311
+ visited.add(row.id)
1312
+ out.push(depth === 0 ? row : { ...row, branchDepth: depth })
1313
+ for (const child of childrenOf.get(row.id) ?? []) visit(child, depth + 1)
1314
+ }
1315
+ for (const row of tops) visit(row, 0)
1316
+ // A corrupt parent cycle must not silently drop rows from the list.
1317
+ return out.length === rows.length ? out : rows
1318
+ }
1319
+
1152
1320
  /**
1153
1321
  * Derive the filtered / grouped / rendered view for the list.
1154
1322
  * Pure so the selection-pruning effect can use it before the loading guard.
1155
1323
  */
1156
- function computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits }) {
1324
+ function computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank }) {
1157
1325
  const rows = state?.sessions ?? []
1158
1326
  const workspaceOrder = state?.workspaces ?? []
1159
1327
  const text = query.trim().toLowerCase()
@@ -1163,8 +1331,9 @@ window.__ModuleLoader__.load({
1163
1331
  if (tab === 'archived' && !row.archived) return false
1164
1332
  if (workspaceFilter === 'ungrouped' && row.workspaceId !== null) return false
1165
1333
  if (workspaceFilter !== 'all' && workspaceFilter !== 'ungrouped' && row.workspaceId !== workspaceFilter) return false
1334
+ if (showBlank !== true && row.blank === true) return false
1166
1335
  if (filtering) {
1167
- const haystack = `${row.title}\n${row.cwd}\n${row.id}`.toLowerCase()
1336
+ const haystack = `${row.title}\n${row.cwd}\n${row.id}\n${row.parentTitle ?? ''}`.toLowerCase()
1168
1337
  if (!haystack.includes(text)) return false
1169
1338
  }
1170
1339
  return true
@@ -1177,7 +1346,7 @@ window.__ModuleLoader__.load({
1177
1346
  })
1178
1347
  const baseGroups = []
1179
1348
  for (const workspace of workspaceOrder) {
1180
- const groupRows = sorted.filter((row) => row.workspaceId === workspace.id)
1349
+ const groupRows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
1181
1350
  if (groupRows.length === 0) continue
1182
1351
  baseGroups.push({
1183
1352
  ...workspace,
@@ -1185,7 +1354,7 @@ window.__ModuleLoader__.load({
1185
1354
  bytes: groupRows.reduce((sum, row) => sum + row.sizeBytes, 0),
1186
1355
  })
1187
1356
  }
1188
- const ungroupedRows = sorted.filter((row) => row.workspaceId === null)
1357
+ const ungroupedRows = orderBranchRows(sorted.filter((row) => row.workspaceId === null))
1189
1358
  if (ungroupedRows.length > 0) {
1190
1359
  baseGroups.push({
1191
1360
  id: 'ungrouped',
@@ -1233,6 +1402,7 @@ window.__ModuleLoader__.load({
1233
1402
  const [bulkTarget, setBulkTarget] = React.useState('')
1234
1403
  const [expanded, setExpanded] = React.useState(null)
1235
1404
  const [limits, setLimits] = React.useState({})
1405
+ const [showBlank, setShowBlank] = React.useState(false)
1236
1406
  const abortRef = React.useRef(null)
1237
1407
  const searchRef = React.useRef(null)
1238
1408
  const listScroll = React.useRef(0)
@@ -1298,6 +1468,7 @@ window.__ModuleLoader__.load({
1298
1468
  if (selectedId === null) return undefined
1299
1469
  const onKeyDown = (event) => {
1300
1470
  if (event.key === 'Escape') {
1471
+ if (document.querySelector('.dsm-scope-overlay') !== null) return
1301
1472
  event.stopPropagation()
1302
1473
  backToList()
1303
1474
  }
@@ -1308,13 +1479,13 @@ window.__ModuleLoader__.load({
1308
1479
 
1309
1480
  React.useEffect(() => {
1310
1481
  setLimits({})
1311
- }, [tab, workspaceFilter, query, includeSubagents])
1482
+ }, [tab, workspaceFilter, query, includeSubagents, showBlank])
1312
1483
 
1313
1484
  const rows = state?.sessions ?? []
1314
1485
  const defaultExpanded = (state?.totals?.all ?? 0) <= DEFAULT_COLLAPSE_AFTER
1315
1486
  const view = React.useMemo(
1316
- () => computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits }),
1317
- [state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits],
1487
+ () => computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank }),
1488
+ [state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank],
1318
1489
  )
1319
1490
  const viewKey = React.useMemo(
1320
1491
  () => [
@@ -1323,10 +1494,11 @@ window.__ModuleLoader__.load({
1323
1494
  workspaceFilter,
1324
1495
  query,
1325
1496
  includeSubagents ? 1 : 0,
1497
+ showBlank ? 1 : 0,
1326
1498
  defaultExpanded ? 1 : 0,
1327
1499
  expanded === null ? 'auto' : [...expanded].sort().join(','),
1328
1500
  ].join('|'),
1329
- [state?.generatedAt, tab, workspaceFilter, query, includeSubagents, defaultExpanded, expanded],
1501
+ [state?.generatedAt, tab, workspaceFilter, query, includeSubagents, showBlank, defaultExpanded, expanded],
1330
1502
  )
1331
1503
  React.useEffect(() => {
1332
1504
  setSelected((previous) => {
@@ -1455,10 +1627,14 @@ window.__ModuleLoader__.load({
1455
1627
  return h('div', { className: 'dsm-page' }, h('p', { className: 'dsm-hint' }, h(Spinner, { t, label: t('loading') })))
1456
1628
  }
1457
1629
 
1630
+ const rowsForTabs = rows.filter((row) => showBlank || row.blank !== true)
1631
+ const blankRows = rows.filter((row) => row.blank === true)
1632
+ const blankArchivedRows = blankRows.filter((row) => row.archived === true && row.can?.delete === true)
1633
+ const blankActiveCount = blankRows.filter((row) => row.archived !== true || row.can?.delete !== true).length
1458
1634
  const tabs = [
1459
- { key: 'all', label: t('tabAll'), count: rows.length },
1460
- { key: 'active', label: t('tabActive'), count: rows.filter((row) => !row.archived).length },
1461
- { key: 'archived', label: t('tabArchived'), count: rows.filter((row) => row.archived).length },
1635
+ { key: 'all', label: t('tabAll'), count: rowsForTabs.length },
1636
+ { key: 'active', label: t('tabActive'), count: rowsForTabs.filter((row) => !row.archived).length },
1637
+ { key: 'archived', label: t('tabArchived'), count: rowsForTabs.filter((row) => row.archived).length },
1462
1638
  ]
1463
1639
  const visibleAllSelected = view.visibleRows.length > 0 && view.visibleRows.every((row) => selected.has(row.id))
1464
1640
  const selectedRows = view.visibleRows.filter((row) => selected.has(row.id))
@@ -1564,9 +1740,27 @@ window.__ModuleLoader__.load({
1564
1740
  archived: state.totals.archived,
1565
1741
  size: formatBytes(state.totals.bytes),
1566
1742
  }),
1743
+ blankRows.length > 0 && !showBlank
1744
+ ? ` · ${t('hiddenBlankCount', { n: blankRows.length })}`
1745
+ : '',
1746
+ (state.totals?.hiddenSubagents ?? 0) > 0 && !includeSubagents
1747
+ ? ` · ${t('hiddenSubagentCount', { n: state.totals.hiddenSubagents })}`
1748
+ : '',
1567
1749
  ),
1750
+ blankActiveCount > 0 && showBlank
1751
+ ? h('p', { className: 'dsm-hint' }, t('blankActiveHint', { n: blankActiveCount }))
1752
+ : null,
1568
1753
  ),
1569
1754
  h('div', { className: 'dsm-head-actions' },
1755
+ blankArchivedRows.length > 0
1756
+ ? h('button', {
1757
+ type: 'button',
1758
+ className: 'dsm-btn',
1759
+ disabled: busy,
1760
+ title: `${t('cleanupBlankHint')}${blankActiveCount > 0 ? ` ${t('blankActiveHint', { n: blankActiveCount })}` : ''}`,
1761
+ onClick: () => setBulkDeleteIds(blankArchivedRows.map((row) => row.id)),
1762
+ }, t('cleanupBlank', { n: blankArchivedRows.length }))
1763
+ : null,
1570
1764
  h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void load({ silent: true }) },
1571
1765
  busy ? h(Spinner, { t, label: t('working') }) : t('refresh')),
1572
1766
  ),
@@ -1631,7 +1825,7 @@ window.__ModuleLoader__.load({
1631
1825
  h('option', { value: 'size' }, t('sortSize')),
1632
1826
  h('option', { value: 'title' }, t('sortTitle')),
1633
1827
  ),
1634
- h('label', { className: 'dsm-check' },
1828
+ h('label', { className: 'dsm-check', title: t('showSubagentsHint') },
1635
1829
  h('input', {
1636
1830
  type: 'checkbox',
1637
1831
  checked: includeSubagents,
@@ -1639,6 +1833,14 @@ window.__ModuleLoader__.load({
1639
1833
  }),
1640
1834
  t('showSubagents'),
1641
1835
  ),
1836
+ h('label', { className: 'dsm-check' },
1837
+ h('input', {
1838
+ type: 'checkbox',
1839
+ checked: showBlank,
1840
+ onChange: (event) => setShowBlank(event.target.checked),
1841
+ }),
1842
+ t('showBlank'),
1843
+ ),
1642
1844
  ),
1643
1845
  view.visibleRows.length > 0
1644
1846
  ? h(
@@ -1699,7 +1901,11 @@ window.__ModuleLoader__.load({
1699
1901
  }, t('clearSelection')),
1700
1902
  !selectedMovable && selectedRows.length > 0
1701
1903
  ? h('span', { className: 'dsm-hint' },
1702
- selectedRows.some((row) => row.live || row.running) ? t('liveHint') : t('moveUnavailableHint'))
1904
+ selectedRows.some((row) => row.live || row.running)
1905
+ ? t('liveHint')
1906
+ : selectedRows.some((row) => row.kind === 'fork')
1907
+ ? t('moveForkHint')
1908
+ : t('moveUnavailableHint'))
1703
1909
  : null,
1704
1910
  )
1705
1911
  : null,