dsh-delete-session 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,7 @@ DSH 侧边栏的会话菜单只有**重命名 / 分叉 / 归档**——归档只
23
23
  - **删除至系统回收站(默认开)** —— 会话日志、工作区记账与投影缓存一并进**系统回收站**:Windows 回收站、macOS 废纸篓、Linux 的 `gio trash`。删错了从回收站还原即可。设置页可以关掉它,关掉后就是**直接从硬盘移除,不可恢复**
24
24
  - **对话框文案跟随开关** —— 勾选时写「并放进系统回收站」,取消勾选时写「永久删除…不可恢复」。你看到的永远是你实际会得到的
25
25
  - **风险确认** —— 每次删除弹出确认对话框,显示会话标题与 ID。要不要弹这个框**只在设置页改**,弹窗里没有快捷勾选;**按住 Shift 点击**可以临时强制弹窗
26
+ - **子会话一起删** —— 子代理是**独立会话**(独立目录、独立日志),父会话删掉后它们会变成孤儿留在磁盘上。现在删除对话时会处理它派生的子会话:设置里勾选「同时删除子会话」就一起删;不勾选则弹窗列出子会话,由你勾选要删哪些(默认全选)
26
27
  - **多选批量删除** —— 会话菜单「多选」进入选择模式:勾选多行后底部操作条显示「已选 N 个 / 取消 / 删除」;点删除立即批量执行,被选中的行乐观隐藏、操作条实时显示进度,客户端 6 路并发、工作区记账串行;失败的行恢复显示并保持选中,方便重试;Esc 退出
27
28
  - **产出文件隔离(可选,默认关闭)** —— 打开后,删除会话时还能把这个对话在工作目录里产出的文件**移进隔离夹**,而不是直接删掉;范围可选「仅新建」或「新建的和改过的」,模式可选「全部直接隔离」或「先列清单由你勾选」
28
29
  - **跳过 git 仓库内的文件(默认开)** —— 在项目里干过活的对话,产出文件里往往就是项目源码。开启后仓库内的文件一律不碰,避免删一个对话把项目搬空
@@ -67,6 +68,7 @@ dsh plugin --profile web add github:DDDMUC/dsh-delete-session
67
68
  | 产出文件默认全部进隔离夹 | 关 | 关:先列清单由你勾选;开:整批自动移走,不列清单也不扫描 |
68
69
  | 范围:仅新建 / 新建的和改过的 | 仅新建 | 只动对话自己创建的文件,还是连它改写过的既有文件一起 |
69
70
  | **跳过 git 仓库内的文件** | **开** | 仓库内的文件一律不碰 |
71
+ | **同时删除子会话** | **关** | 开:删对话时把它派生的子代理会话一起删掉。关:弹窗列出子会话,由你勾选 |
70
72
  | 隔离夹保存位置 | 空(= `$DSH_HOME`) | 可手输、可点「选择文件夹…」调系统对话框、可点「打开」在资源管理器里定位 |
71
73
 
72
74
  ### 删除之后文件在哪
@@ -131,14 +133,12 @@ node test/checkbox.dom.test.js # 67 项,客户端半
131
133
 
132
134
  ### 已知限制
133
135
 
134
- - **取消勾选「删除至回收站」后不可恢复**,没有撤销
135
- - **系统回收站有每盘容量上限**:超出时 Windows 会永久删除而不是报错。会话只有 KB~MB,实际碰不到
136
136
  - **macOS 首次使用会请求 Finder 自动化权限**,拒绝后文件留在暂存目录,不会假装删掉
137
137
  - 被 DSH 打开占用的会话:删除会自动等待写所有权释放(通常几秒)后完成;极少数仍被占用时提示稍后重试或重启 DSH
138
138
  - 多选勾选框仍是对会话行的 DOM 装饰,依赖宿主 `data-row-key="session:<id>"`;核心 UI 若改名,多选会静默失效(**菜单与对话框不受影响**,它们走官方槽位)
139
139
  - 产出文件清理默认关闭,打开后仍有几条边界:`bash` / `pwsh` 里创建的文件(`npm run build`、`git clone`、`> file`)**永远检测不到**;**目录永不移走**;清单上限 500 条;**没有还原界面**,隔离夹靠手工搬回
140
140
  - 「新建 vs 改过」的判定依赖 `fs-observation-policy` 保持启用
141
- - 批量删除(多选)**不**处理产出文件
141
+ - 批量删除(多选)**不**处理产出文件,也**不**处理子会话
142
142
  - 暂存目录与隔离夹**没有自动清理**,会一直堆在 `$DSH_HOME` 下
143
143
 
144
144
  ### 兼容性
@@ -275,14 +275,12 @@ Both suites are **mutation-checked**: reverting an implementation or breaking it
275
275
 
276
276
  ### Known limitations
277
277
 
278
- - **Unchecking "Delete to the system trash" is not recoverable** — there is no undo
279
- - **The system trash has a per-volume size cap**: over it Windows deletes permanently instead of complaining. Sessions are KB–MB, so this does not come up in practice
280
278
  - **macOS asks for Finder automation permission the first time**; denied, the files stay in the staging folder rather than pretending the delete happened
281
279
  - A session the host holds open is waited for (usually seconds); in rare cases it asks you to retry or restart DSH
282
280
  - The multi-select tick is still a DOM decoration on session rows and depends on `data-row-key="session:<id>"`. If core UI renames it, multi-select goes quiet (the menu and dialog are unaffected — they use official slots)
283
281
  - Produced-file handling is off by default and still has edges: files created inside `bash` / `pwsh` (`npm run build`, `git clone`, `> file`) are **never detected**; **directories are never moved**; the list caps at 500; there is **no restore UI** — move files back from the quarantine by hand
284
282
  - Created-vs-changed depends on `fs-observation-policy` staying enabled
285
- - Batch delete does **not** handle produced files
283
+ - Batch delete does **not** handle produced files, and does **not** touch child sessions
286
284
  - Staging and quarantine folders **are never cleaned up automatically**
287
285
 
288
286
  ### Compatibility
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-delete-session",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Delete sessions from the DSH Web sidebar: adds a Delete action to the session row menu with a risk-consent dialog, sends the session log, workspace accounting and live session state to the system trash (or removes them outright), and, when opted in, quarantines the files the conversation produced.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -60,6 +60,12 @@ window.__ModuleLoader__.load({
60
60
  batchFailed: '批量删除失败:',
61
61
  pageAsk: '删除时询问',
62
62
  pageRecycle: '删除至回收站',
63
+ pageSyncChildren: '同时删除子会话',
64
+ starAsk: '⭐ 顺手留颗 Star,作者能高兴一整天',
65
+ starCta: '行,给你一颗 Star',
66
+ pageSyncChildrenOn: '删除对话时,把它派生的子代理会话一起删掉。',
67
+ pageSyncChildrenOff: '不勾选时,删除前会列出子会话,由你勾选要删哪些。',
68
+ childrenHint: '这个对话有 {n} 个子会话(子代理),勾选的会一起删除。',
63
69
  pageRecycleOn: '删除后放进系统回收站,可以还原。',
64
70
  pageRecycleOff: '删除后直接从硬盘移除,不可恢复。',
65
71
  pageHintAsk: '每次删除会话前都会弹出确认框。',
@@ -148,6 +154,12 @@ window.__ModuleLoader__.load({
148
154
  batchFailed: 'Batch delete failed: ',
149
155
  pageAsk: 'Ask before deleting',
150
156
  pageRecycle: 'Delete to the Recycle Bin',
157
+ pageSyncChildren: 'Delete child sessions too',
158
+ starAsk: '⭐ A star would make the author\'s day',
159
+ starCta: 'Fine — have one',
160
+ pageSyncChildrenOn: 'A delete also removes the subagent sessions it spawned.',
161
+ pageSyncChildrenOff: 'When off, the dialog lists the child sessions and you tick the ones to delete.',
162
+ childrenHint: 'This conversation has {n} child sessions (subagents). Ticked ones are deleted too.',
151
163
  pageRecycleOn: 'The session goes to the system trash and can be restored.',
152
164
  pageRecycleOff: 'The session is removed from the disk right away and cannot be restored.',
153
165
  pageHintAsk: 'Show a confirmation dialog before every session delete.',
@@ -366,6 +378,8 @@ window.__ModuleLoader__.load({
366
378
  const SKIP_GIT_KEY = 'dsh-delete-session:skip-git'
367
379
  /** Whether a delete goes to the system trash instead of vanishing. */
368
380
  const RECYCLE_KEY = 'dsh-delete-session:recycle'
381
+ /** Whether a delete takes the subagent sessions with it, without asking. */
382
+ const SYNC_CHILDREN_KEY = 'dsh-delete-session:sync-children'
369
383
  /** How many artifact rows the dialog lists before collapsing the rest. */
370
384
  const ARTIFACT_ROWS_SHOWN = 40
371
385
  const prefListeners = new Set()
@@ -449,6 +463,18 @@ window.__ModuleLoader__.load({
449
463
  writePref(RECYCLE_KEY, on ? null : '0')
450
464
  }
451
465
 
466
+ /**
467
+ * Take the child sessions along without asking. Off by default: a subagent is
468
+ * a conversation of its own, so it is offered rather than swept up.
469
+ */
470
+ function syncChildren() {
471
+ return readPref(SYNC_CHILDREN_KEY, '0') === '1'
472
+ }
473
+
474
+ function setSyncChildren(on) {
475
+ writePref(SYNC_CHILDREN_KEY, on ? '1' : null)
476
+ }
477
+
452
478
  /** The chosen quarantine root, or '' for "use the host default". */
453
479
  function quarantineRoot() {
454
480
  return readPref(ROOT_KEY, '')
@@ -535,10 +561,15 @@ window.__ModuleLoader__.load({
535
561
  .catch((reason) => toast(t('rootOpenFailed') + String((reason && reason.message) || reason), true))
536
562
  }
537
563
 
538
- function postDelete(sessionId, quarantine) {
564
+ function postDelete(sessionId, quarantine, childSessionIds) {
539
565
  const root = quarantineRoot()
540
566
  const payload = quarantine === undefined ? { sessionId } : { sessionId, quarantine }
541
567
  if (root !== '') payload.root = root
568
+ // Subagent sessions are sessions of their own, so they travel as a list the
569
+ // host can verify rather than something it has to discover.
570
+ if (Array.isArray(childSessionIds) && childSessionIds.length > 0) {
571
+ payload.childSessionIds = childSessionIds
572
+ }
542
573
  // Whether the files go to the system trash is a per-delete choice, so it
543
574
  // travels with the request rather than being read from the host config.
544
575
  payload.recycle = recycleSession()
@@ -628,6 +659,8 @@ window.__ModuleLoader__.load({
628
659
  '.dsdel-bar-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
629
660
  '.dsdel-page{display:flex;flex-direction:column;gap:8px}',
630
661
  '.dsdel-page-hint{margin:0;color:var(--dsw-alias-label-tertiary,#888);font-size:13px;line-height:20px}',
662
+ '.dsdel-star{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-tertiary,#8b93a1);padding:2px 2px 0;}',
663
+ '.dsdel-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}',
631
664
  '.dsdel-root-input{flex:1 1 220px;min-width:160px;min-height:30px;padding:0 10px;border:1px solid var(--dsw-alias-border-secondary,#d0d0d0);border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:13px}',
632
665
  '.dsdel-picker-path{margin:0 0 8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary,#666);word-break:break-all}',
633
666
  // A wider confirmation dialog: the class reaches it when `Modal` forwards
@@ -1063,9 +1096,63 @@ window.__ModuleLoader__.load({
1063
1096
  : artifactsAuto()
1064
1097
  ? { auto: true, loading: false, plan: null, error: null, selected: new Set() }
1065
1098
  : { auto: false, loading: true, plan: null, error: null, selected: new Set() }
1066
- state.dialog = { sessionId, title: title || null, busy: false, error: null, skip: false, artifacts }
1099
+ state.dialog = {
1100
+ sessionId,
1101
+ title: title || null,
1102
+ busy: false,
1103
+ error: null,
1104
+ artifacts,
1105
+ // The subagent sessions this conversation spawned. Filled in after the
1106
+ // dialog opens: the list costs one host call, and on most conversations
1107
+ // there is nothing to list.
1108
+ children: { loading: true, list: [], selected: new Set() },
1109
+ }
1067
1110
  publish()
1068
1111
  if (artifacts !== null && artifacts.auto !== true) loadArtifacts(sessionId)
1112
+ loadChildren(sessionId)
1113
+ }
1114
+
1115
+ /**
1116
+ * List the subagent sessions behind this conversation. With the switch on they
1117
+ * are all taken with the parent; with it off every one starts ticked and the
1118
+ * dialog lets the user narrow the set.
1119
+ */
1120
+ function loadChildren(sessionId) {
1121
+ fetch(`/dsh-delete-session/children`, {
1122
+ method: 'POST',
1123
+ headers: { 'content-type': 'application/json' },
1124
+ body: JSON.stringify({ sessionId }),
1125
+ })
1126
+ .then(async (res) => {
1127
+ let data = {}
1128
+ try { data = await res.json() } catch { /* keep {} */ }
1129
+ const list = res.ok && Array.isArray(data.children) ? data.children : []
1130
+ const dialog = state.dialog
1131
+ if (dialog === null || dialog.sessionId !== sessionId) return
1132
+ dialog.children = {
1133
+ loading: false,
1134
+ list,
1135
+ selected: new Set(list.map((child) => child.id)),
1136
+ }
1137
+ publish()
1138
+ })
1139
+ .catch(() => {
1140
+ const dialog = state.dialog
1141
+ if (dialog === null || dialog.sessionId !== sessionId) return
1142
+ // A host that cannot list children still deletes the parent; reporting
1143
+ // an error here would block a delete that is otherwise fine.
1144
+ dialog.children = { loading: false, list: [], selected: new Set() }
1145
+ publish()
1146
+ })
1147
+ }
1148
+
1149
+ /** The children this delete should take along. */
1150
+ function childSessionIdsFor(dialog) {
1151
+ if (dialog.children === undefined || dialog.children === null) return []
1152
+ if (syncChildren()) return dialog.children.list.map((child) => child.id)
1153
+ return dialog.children.list
1154
+ .filter((child) => dialog.children.selected.has(child.id))
1155
+ .map((child) => child.id)
1069
1156
  }
1070
1157
 
1071
1158
  /** Fetch the read-only scan and preselect the current scope's candidates. */
@@ -1221,11 +1308,12 @@ window.__ModuleLoader__.load({
1221
1308
  const dialog = state.dialog
1222
1309
  if (dialog === null || dialog.busy) return
1223
1310
  const quarantine = quarantineRequest(dialog)
1311
+ const childSessionIds = childSessionIdsFor(dialog)
1224
1312
  dialog.busy = true
1225
1313
  dialog.error = null
1226
1314
  state.pending.add(dialog.sessionId)
1227
1315
  publish()
1228
- postDelete(dialog.sessionId, quarantine)
1316
+ postDelete(dialog.sessionId, quarantine, childSessionIds)
1229
1317
  .then((result) => {
1230
1318
  state.dialog = null
1231
1319
  const outcome = result && result.quarantine ? result.quarantine : null
@@ -1320,6 +1408,32 @@ window.__ModuleLoader__.load({
1320
1408
  * (ask mode) or a single line stating how many files will move (auto mode).
1321
1409
  * A null `artifacts` means the feature is off and the dialog says nothing.
1322
1410
  */
1411
+ /**
1412
+ * The subagent sessions behind this conversation. Hidden while the switch
1413
+ * already answers the question, and while the list is still loading.
1414
+ */
1415
+ function renderChildren(dialog, translate) {
1416
+ const children = dialog.children
1417
+ if (children === undefined || children === null) return null
1418
+ if (children.loading) return null
1419
+ if (children.list.length === 0) return null
1420
+ if (syncChildren()) return null
1421
+ return h('div', { key: 'children', className: 'dsdel-modal-children' },
1422
+ h('p', { key: 'hint', className: 'dsdel-modal-text' },
1423
+ translate('childrenHint', children.list.length)),
1424
+ ...children.list.map((child) => h(primitives.Checkbox, {
1425
+ key: `child:${child.id}`,
1426
+ checked: children.selected.has(child.id),
1427
+ label: child.cwd ? `${child.id} · ${child.cwd}` : child.id,
1428
+ onChange: (checked) => {
1429
+ if (checked === true) children.selected.add(child.id)
1430
+ else children.selected.delete(child.id)
1431
+ publish()
1432
+ },
1433
+ })),
1434
+ )
1435
+ }
1436
+
1323
1437
  function renderArtifacts(artifacts, translate) {
1324
1438
  if (artifacts === null) return null
1325
1439
  // Auto mode never scanned, so there is no plan to describe: name the
@@ -1512,6 +1626,7 @@ window.__ModuleLoader__.load({
1512
1626
  h('p', { key: 'desc', className: 'dsdel-modal-text' },
1513
1627
  translate(recycleSession() ? 'descRecycle' : 'descPermanent')),
1514
1628
  renderArtifacts(dialog.artifacts, translate),
1629
+ renderChildren(dialog, translate),
1515
1630
  // Whether a delete asks first is a setting, not a dialog affordance:
1516
1631
  // it lives on the plugin page only, so this dialog never changes it.
1517
1632
  dialog.error !== null
@@ -1540,6 +1655,7 @@ window.__ModuleLoader__.load({
1540
1655
  const defaultRootValue = () => (pageSnapshot && pageSnapshot.defaultRoot) || ''
1541
1656
  const [ask, setAsk] = react.useState(!skipConfirm())
1542
1657
  const [recycle, setRecycle] = react.useState(recycleSession())
1658
+ const [syncChildrenState, setSyncChildrenState] = react.useState(syncChildren())
1543
1659
  const [off, setOff] = react.useState(artifactsOff())
1544
1660
  const [auto, setAuto] = react.useState(artifactsAuto())
1545
1661
  const [scope, setScope] = react.useState(artifactScope())
@@ -1586,6 +1702,18 @@ window.__ModuleLoader__.load({
1586
1702
  h('p', { key: 'recycleHint', className: 'dsdel-page-hint' },
1587
1703
  translate(recycle ? 'pageRecycleOn' : 'pageRecycleOff')),
1588
1704
 
1705
+ h(primitives.Checkbox, {
1706
+ key: 'syncChildren',
1707
+ checked: syncChildrenState,
1708
+ label: translate('pageSyncChildren'),
1709
+ onChange: (checked) => {
1710
+ setSyncChildrenState(checked === true)
1711
+ setSyncChildren(checked === true)
1712
+ },
1713
+ }),
1714
+ h('p', { key: 'syncChildrenHint', className: 'dsdel-page-hint' },
1715
+ translate(syncChildrenState ? 'pageSyncChildrenOn' : 'pageSyncChildrenOff')),
1716
+
1589
1717
  h(primitives.Checkbox, {
1590
1718
  key: 'off',
1591
1719
  checked: off,
@@ -1713,6 +1841,19 @@ window.__ModuleLoader__.load({
1713
1841
  )
1714
1842
  }
1715
1843
 
1844
+ // A quiet link to the repository, last in the card. It is a nudge, not a
1845
+ // setting, so it never competes with the controls above it — and it is a
1846
+ // plain anchor, so clicking it opens the page instead of switching tabs here.
1847
+ children.push(h('div', { key: 'star', className: 'dsdel-star' },
1848
+ h('span', { key: 'ask' }, translate('starAsk')),
1849
+ h('a', {
1850
+ key: 'cta',
1851
+ className: 'dsdel-star-link',
1852
+ href: 'https://github.com/DDDMUC/dsh-delete-session',
1853
+ target: '_blank',
1854
+ rel: 'noreferrer',
1855
+ }, translate('starCta'))))
1856
+
1716
1857
  return h('div', { className: 'dsdel-page' }, children)
1717
1858
  }
1718
1859
 
package/src/index.js CHANGED
@@ -66,6 +66,132 @@ function idVariants(sessionId) {
66
66
  else out.add(`session-${sessionId}`)
67
67
  return [...out]
68
68
  }
69
+ /** The bare uuid, for comparing ids that may or may not carry the prefix. */
70
+ function bareSessionId(sessionId) {
71
+ return sessionId.startsWith('session-') ? sessionId.slice('session-'.length) : sessionId
72
+ }
73
+
74
+ /**
75
+ * Every session header the host knows about, or `[]` when no service can list
76
+ * them. `sessionQuery` merges live and persisted sessions and carries
77
+ * `parentSession`, so the parent/child map never has to be decoded out of the
78
+ * zstd logs.
79
+ */
80
+ async function listSessionHeaders(ctx) {
81
+ try {
82
+ const query = ctx.get('sessionQuery')
83
+ if (query && typeof query.listSessions === 'function') {
84
+ const records = await query.listSessions()
85
+ const headers = records
86
+ .map((record) => record && record.header)
87
+ .filter((header) => header && typeof header.id === 'string')
88
+ if (headers.length > 0) return headers
89
+ }
90
+ } catch {
91
+ // fall through to the persistence service
92
+ }
93
+ try {
94
+ const persistence = ctx.get('sessionPersistence')
95
+ if (persistence && typeof persistence.list === 'function') {
96
+ const snapshots = await persistence.list({})
97
+ return snapshots
98
+ .map((snapshot) => snapshot && snapshot.header)
99
+ .filter((header) => header && typeof header.id === 'string')
100
+ }
101
+ } catch {
102
+ // no service can list sessions on this host
103
+ }
104
+ return []
105
+ }
106
+
107
+ /**
108
+ * Every descendant of one session, breadth first. A subagent is a session of
109
+ * its own — its own directory and its own log — linked only by `parentSession`,
110
+ * so a delete that ignores the link leaves orphans behind.
111
+ *
112
+ * @param headers - the host session headers.
113
+ * @param sessionId - the session being deleted.
114
+ * @returns descendant headers, parents before their own children.
115
+ */
116
+ function collectDescendants(headers, sessionId) {
117
+ const byParent = new Map()
118
+ for (const header of headers) {
119
+ if (typeof header.parentSession !== 'string') continue
120
+ const key = bareSessionId(header.parentSession)
121
+ const list = byParent.get(key)
122
+ if (list === undefined) byParent.set(key, [header])
123
+ else list.push(header)
124
+ }
125
+ const found = []
126
+ const seen = new Set([bareSessionId(sessionId)])
127
+ const queue = [bareSessionId(sessionId)]
128
+ while (queue.length > 0) {
129
+ const current = queue.shift()
130
+ for (const child of byParent.get(current) ?? []) {
131
+ const key = bareSessionId(child.id)
132
+ if (seen.has(key)) continue
133
+ seen.add(key)
134
+ found.push(child)
135
+ queue.push(key)
136
+ }
137
+ }
138
+ return found
139
+ }
140
+
141
+ /**
142
+ * Sessions whose parent no longer exists. Deleting a parent used to leave these
143
+ * on disk, and the sidebar cannot show the ones that never joined a workspace.
144
+ */
145
+ function collectOrphans(headers) {
146
+ const present = new Set(headers.map((entry) => bareSessionId(entry.id)))
147
+ return headers.filter((entry) => typeof entry.parentSession === 'string'
148
+ && !present.has(bareSessionId(entry.parentSession)))
149
+ }
150
+
151
+ /**
152
+ * The child list the dialog shows: identity, nesting depth and the project the
153
+ * subagent ran in, so the names are recognisable before anything is ticked.
154
+ */
155
+ function describeChildren(headers, sessionId) {
156
+ const byBare = new Map(headers.map((entry) => [bareSessionId(entry.id), entry]))
157
+ return collectDescendants(headers, sessionId).map((entry) => {
158
+ let depth = 1
159
+ let cursor = entry
160
+ while (typeof cursor.parentSession === 'string') {
161
+ const parent = byBare.get(bareSessionId(cursor.parentSession))
162
+ if (parent === undefined || bareSessionId(parent.id) === bareSessionId(sessionId)) break
163
+ depth += 1
164
+ cursor = parent
165
+ }
166
+ return {
167
+ id: entry.id,
168
+ depth,
169
+ cwd: typeof entry.cwd === 'string' ? entry.cwd : undefined,
170
+ }
171
+ })
172
+ }
173
+
174
+ /**
175
+ * The children the caller asked to delete, narrowed to real descendants. A
176
+ * stale or unrelated id is dropped instead of failing the delete: the parent is
177
+ * the session the user clicked, and that click must still land.
178
+ *
179
+ * @returns the descendants' own ids, in the order they were asked for.
180
+ */
181
+ function selectChildDeletes(headers, sessionId, requested) {
182
+ if (!Array.isArray(requested) || requested.length === 0) return []
183
+ const descendants = new Map()
184
+ for (const entry of collectDescendants(headers, sessionId)) {
185
+ descendants.set(bareSessionId(entry.id), entry.id)
186
+ }
187
+ const picked = []
188
+ for (const id of requested) {
189
+ if (typeof id !== 'string' || id === '') continue
190
+ const hit = descendants.get(bareSessionId(id))
191
+ if (hit !== undefined && !picked.includes(hit)) picked.push(hit)
192
+ }
193
+ return picked
194
+ }
69
195
 
70
196
  // Scan every workspace directory under $DSH_HOME/sessions for the session's
71
197
  // directories, so the workspace-path encoding never has to be re-derived here.
@@ -604,6 +730,14 @@ async function deleteSession(ctx, sessionId, options) {
604
730
  // Undoable by default: only an explicit `recycle: false` sends the files to a
605
731
  // plain removal instead of the system trash.
606
732
  const recycle = options === undefined || options === null || options.recycle !== false
733
+ // Subagent sessions are sessions of their own; the ones the caller ticked are
734
+ // taken with their parent. Anything that is not a well-formed id is dropped
735
+ // here, before it reaches the filesystem.
736
+ const childSessionIds = options !== undefined && options !== null && Array.isArray(options.childSessionIds)
737
+ ? options.childSessionIds
738
+ .filter((id) => typeof id === 'string' && SESSION_ID_RE.test(id))
739
+ .filter((id) => bareSessionId(id) !== bareSessionId(sessionId))
740
+ : []
607
741
  const stopped = await quiesceAgent(ctx, sessionId)
608
742
  const { flushed, detached } = await flushAndDetach(ctx, sessionId)
609
743
  await assertNoActiveWriter(ctx, sessionId)
@@ -612,14 +746,7 @@ async function deleteSession(ctx, sessionId, options) {
612
746
  // Bin switch on (the default) they are staged and the shell moves them to the
613
747
  // bin in the background; switched off, they are removed outright — that is what
614
748
  // the dialog promises in that state.
615
- //
616
- // The second directory pass costs nothing when the drain recreated nothing,
617
- // which is the normal case.
618
- const cacheFiles = findSessionCaches(sessionId)
619
- const targets = [...findSessionDirs(sessionId), ...cacheFiles]
620
- let removed = await retireTargets(targets, recycle)
621
- await new Promise((resolve) => setImmediate(resolve))
622
- removed = removed.concat(await retireTargets(findSessionDirs(sessionId), recycle))
749
+ const { removed, caches } = await retireSessionFiles(sessionId, recycle)
623
750
 
624
751
  const sp = ctx.get('sessionPersistence')
625
752
  if (sp && typeof sp.stat === 'function') {
@@ -633,18 +760,49 @@ async function deleteSession(ctx, sessionId, options) {
633
760
  throw new HttpError(500, 'verify-failed', 'session directories could not be fully removed')
634
761
  }
635
762
 
636
- // Cache rows are projection state, not the source of truth: retire them even
637
- // when the directories were already gone, so a deleted session cannot leave
638
- // a ghost behind.
639
- // Anything of the cache set that survived the shared call is reported, so a
640
- // locked row is still visible in the result.
641
- const cachesRemoved = cacheFiles.filter((file) => !fs.existsSync(file))
642
-
643
763
  const workspaceDetached = await withWorkspaceLock(() => detachFromWorkspace(ctx, sessionId))
644
- if (removed.length === 0 && !workspaceDetached && !flushed && !detached && cachesRemoved.length === 0) {
764
+ if (removed === 0 && !workspaceDetached && !flushed && !detached && caches === 0) {
645
765
  throw new HttpError(404, 'not-found', 'session not found')
646
766
  }
647
- return { removed: removed.length, caches: cachesRemoved.length, stopped, flushed, detached, workspaceDetached }
767
+
768
+ // Children last, and one failure never takes the parent result down with it:
769
+ // the parent is what the user clicked, so that click has to land.
770
+ const children = []
771
+ for (const childId of childSessionIds) {
772
+ try {
773
+ const childFiles = await retireSessionFiles(childId, recycle)
774
+ await withWorkspaceLock(() => detachFromWorkspace(ctx, childId))
775
+ children.push({ id: childId, removed: childFiles.removed, caches: childFiles.caches })
776
+ } catch (error) {
777
+ children.push({ id: childId, removed: 0, caches: 0, error: String((error && error.message) || error) })
778
+ }
779
+ }
780
+
781
+ return {
782
+ removed,
783
+ caches,
784
+ stopped,
785
+ flushed,
786
+ detached,
787
+ workspaceDetached,
788
+ children,
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Take one session off the disk: its directories and its cache rows, then any
794
+ * directory the drain recreated. Shared by the parent and every child, so a
795
+ * subagent is retired exactly like the conversation the user clicked.
796
+ *
797
+ * @returns how many paths went away, and how many cache rows are now gone.
798
+ */
799
+ async function retireSessionFiles(sessionId, recycle) {
800
+ const cacheFiles = findSessionCaches(sessionId)
801
+ const targets = [...findSessionDirs(sessionId), ...cacheFiles]
802
+ let gone = await retireTargets(targets, recycle)
803
+ await new Promise((resolve) => setImmediate(resolve))
804
+ gone = gone.concat(await retireTargets(findSessionDirs(sessionId), recycle))
805
+ return { removed: gone.length, caches: cacheFiles.filter((file) => !fs.existsSync(file)).length }
648
806
  }
649
807
 
650
808
  // --- artifacts: what a session touched, and quarantining it -----------------
@@ -1871,6 +2029,41 @@ export function apply(ctx) {
1871
2029
  },
1872
2030
  }))
1873
2031
 
2032
+ // The subagent sessions behind one conversation. A subagent is a session of
2033
+ // its own, linked only by `parentSession`, so the dialog needs this list
2034
+ // before it can offer anything to tick.
2035
+ fiber.effect(() => webServer.register({
2036
+ kind: 'exact',
2037
+ path: `${ROUTE_PREFIX}/children`,
2038
+ handler: async (req, res) => {
2039
+ if (!guard(req, res)) return
2040
+ if (req.method !== 'POST') {
2041
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
2042
+ return
2043
+ }
2044
+ let body = {}
2045
+ try {
2046
+ const raw = await readBody(req)
2047
+ if (raw) body = JSON.parse(raw)
2048
+ } catch {
2049
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
2050
+ return
2051
+ }
2052
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2053
+ if (!SESSION_ID_RE.test(sessionId)) {
2054
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'invalid session id' })
2055
+ return
2056
+ }
2057
+ try {
2058
+ const headers = await listSessionHeaders(ctx)
2059
+ sendJson(res, 200, { ok: true, children: describeChildren(headers, sessionId) })
2060
+ } catch (error) {
2061
+ const status = error instanceof HttpError ? error.status : 500
2062
+ const code = error instanceof HttpError ? error.code : 'internal'
2063
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
2064
+ }
2065
+ },
2066
+ }))
1874
2067
  fiber.effect(() => webServer.register({
1875
2068
  kind: 'exact',
1876
2069
  path: `${ROUTE_PREFIX}/delete`,
@@ -1907,7 +2100,10 @@ export function apply(ctx) {
1907
2100
  ? await planArtifacts(ctx, sessionId, undefined, { skipGit: body.quarantine.skipGit === true })
1908
2101
  : null
1909
2102
  const quarantine = await runQuarantine(ctx, sessionId, body.quarantine, plan)
1910
- const result = await deleteSession(ctx, sessionId, { recycle: body.recycle !== false })
2103
+ const result = await deleteSession(ctx, sessionId, {
2104
+ recycle: body.recycle !== false,
2105
+ childSessionIds: Array.isArray(body.childSessionIds) ? body.childSessionIds : [],
2106
+ })
1911
2107
  sendJson(res, 200, quarantine === null ? { ok: true, ...result } : { ok: true, ...result, quarantine })
1912
2108
  } catch (error) {
1913
2109
  const status = error instanceof HttpError ? error.status : 500
@@ -1952,7 +2148,10 @@ export function apply(ctx) {
1952
2148
  // deletions of the same storage.
1953
2149
  const results = await runWithConcurrency(sessionIds, BATCH_CONCURRENCY, async (sessionId) => {
1954
2150
  try {
1955
- const result = await deleteSession(ctx, sessionId, { recycle: body.recycle !== false })
2151
+ const result = await deleteSession(ctx, sessionId, {
2152
+ recycle: body.recycle !== false,
2153
+ childSessionIds: Array.isArray(body.childSessionIds) ? body.childSessionIds : [],
2154
+ })
1956
2155
  return { sessionId, ok: true, removed: result.removed }
1957
2156
  } catch (error) {
1958
2157
  return {
@@ -1997,8 +2196,15 @@ export const internals = {
1997
2196
  quarantineFiles,
1998
2197
  resolveQuarantineRoot,
1999
2198
  removeForever,
2199
+ deleteSession,
2000
2200
  retirePaths,
2001
2201
  retireTargets,
2202
+ listSessionHeaders,
2203
+ collectOrphans,
2204
+ collectDescendants,
2205
+ bareSessionId,
2206
+ describeChildren,
2207
+ selectChildDeletes,
2002
2208
  runQuarantine,
2003
2209
  sendToRecycleBin,
2004
2210
  toolTarget,
@@ -951,3 +951,191 @@ test('the switch decides between staging for the bin and removing outright', asy
951
951
  const stagedAfterPermanent = fs.readdirSync(HOME_ROOT).filter((n) => n.startsWith('session-trash-')).length
952
952
  assert.equal(stagedAfterPermanent, stagedBeforePermanent, 'a permanent delete stages nothing')
953
953
  })
954
+
955
+ // --- subagent sessions are separate sessions, linked by parentSession ------
956
+
957
+ const header = (id, parentSession) => (parentSession === undefined ? { id } : { id, parentSession })
958
+
959
+ function fakeContext(services) {
960
+ return { get: (name) => services[name] }
961
+ }
962
+
963
+ test('a delete finds the grandchildren behind a child, not just the children', () => {
964
+ const headers = [
965
+ header('session-parent'),
966
+ header('session-child', 'session-parent'),
967
+ header('session-grandchild', 'session-child'),
968
+ ]
969
+
970
+ const found = internals.collectDescendants(headers, 'session-parent')
971
+
972
+ assert.deepEqual(found.map((h) => h.id), ['session-child', 'session-grandchild'],
973
+ 'parents come before their own children')
974
+ })
975
+
976
+ test('a parent link matches whether or not either id carries the prefix', () => {
977
+ const headers = [
978
+ header('session-parent'),
979
+ header('child-uuid', 'parent-uuid'),
980
+ ]
981
+
982
+ const found = internals.collectDescendants(headers, 'parent-uuid')
983
+
984
+ assert.deepEqual(found.map((h) => h.id), ['child-uuid'])
985
+ })
986
+
987
+ test('a session that names itself as its parent does not loop forever', () => {
988
+ const headers = [
989
+ header('session-parent'),
990
+ header('session-child', 'session-child'),
991
+ ]
992
+
993
+ const found = internals.collectDescendants(headers, 'session-parent')
994
+
995
+ assert.deepEqual(found, [], 'a self-parented session is not its own descendant')
996
+ })
997
+
998
+ test('an orphan is a session whose parent is gone', () => {
999
+ const headers = [
1000
+ header('session-parent'),
1001
+ header('session-kept', 'session-parent'),
1002
+ header('session-orphan', 'session-deleted-long-ago'),
1003
+ ]
1004
+
1005
+ const orphans = internals.collectOrphans(headers)
1006
+
1007
+ assert.deepEqual(orphans.map((h) => h.id), ['session-orphan'])
1008
+ })
1009
+
1010
+ test('session headers come from the query service when the host has one', async () => {
1011
+ const headers = [header('session-a'), header('session-b', 'session-a')]
1012
+ const ctx = fakeContext({
1013
+ sessionQuery: { listSessions: async () => headers.map((h) => ({ header: h })) },
1014
+ sessionPersistence: { list: async () => { throw new Error('should not be reached') } },
1015
+ })
1016
+
1017
+ const listed = await internals.listSessionHeaders(ctx)
1018
+
1019
+ assert.deepEqual(listed.map((h) => h.id), ['session-a', 'session-b'])
1020
+ })
1021
+
1022
+ test('session headers fall back to persistence when there is no query service', async () => {
1023
+ const headers = [header('session-a')]
1024
+ const ctx = fakeContext({ sessionPersistence: { list: async () => headers.map((h) => ({ header: h })) } })
1025
+
1026
+ const listed = await internals.listSessionHeaders(ctx)
1027
+
1028
+ assert.deepEqual(listed.map((h) => h.id), ['session-a'])
1029
+ })
1030
+
1031
+ test('a host that can list nothing yields no headers rather than throwing', async () => {
1032
+ const listed = await internals.listSessionHeaders(fakeContext({}))
1033
+ assert.deepEqual(listed, [])
1034
+ })
1035
+
1036
+ test('the child list carries a depth so nesting is visible in the dialog', () => {
1037
+ const headers = [
1038
+ header('session-parent'),
1039
+ { id: 'session-child', parentSession: 'session-parent', cwd: 'E:\\work' },
1040
+ { id: 'session-grandchild', parentSession: 'session-child', cwd: 'E:\\work' },
1041
+ ]
1042
+
1043
+ const children = internals.describeChildren(headers, 'session-parent')
1044
+
1045
+ assert.deepEqual(children, [
1046
+ { id: 'session-child', depth: 1, cwd: 'E:\\work' },
1047
+ { id: 'session-grandchild', depth: 2, cwd: 'E:\\work' },
1048
+ ])
1049
+ })
1050
+
1051
+ test('only the ids the user picked, and only real descendants, are deleted', () => {
1052
+ const headers = [
1053
+ header('session-parent'),
1054
+ header('session-child', 'session-parent'),
1055
+ header('session-grandchild', 'session-child'),
1056
+ header('session-unrelated'),
1057
+ ]
1058
+
1059
+ const picked = internals.selectChildDeletes(headers, 'session-parent', [
1060
+ 'session-child',
1061
+ 'session-unrelated',
1062
+ 'session-does-not-exist',
1063
+ ])
1064
+
1065
+ assert.deepEqual(picked, ['session-child'], 'an unrelated id can never ride along on a delete')
1066
+ })
1067
+
1068
+ test('a picked child is matched by its bare uuid but deleted under its own id', () => {
1069
+ const headers = [
1070
+ header('session-parent'),
1071
+ header('session-grandchild', 'session-child'),
1072
+ header('session-child', 'session-parent'),
1073
+ ]
1074
+
1075
+ // The dialog may hold either spelling; the delete always uses the header's own.
1076
+ const picked = internals.selectChildDeletes(headers, 'session-parent', ['child'])
1077
+
1078
+ assert.deepEqual(picked, ['session-child'], 'the bare uuid matches the prefixed id')
1079
+ const byPrefixed = internals.selectChildDeletes(headers, 'session-parent', ['session-child'])
1080
+ assert.deepEqual(byPrefixed, ['session-child'], 'and the prefixed one matches too')
1081
+ const unrelated = internals.selectChildDeletes(headers, 'session-parent', ['session-grandchild-ish'])
1082
+ assert.deepEqual(unrelated, [], 'a near miss is not a descendant')
1083
+ })
1084
+
1085
+ test('no picked children means the parent goes alone', () => {
1086
+ const headers = [header('session-parent'), header('session-child', 'session-parent')]
1087
+
1088
+ assert.deepEqual(internals.selectChildDeletes(headers, 'session-parent', undefined), [])
1089
+ assert.deepEqual(internals.selectChildDeletes(headers, 'session-parent', []), [])
1090
+ })
1091
+
1092
+ const PARENT_ID = 'session-22222222-2222-4222-8222-222222222222'
1093
+ const CHILD_ID = 'session-11111111-1111-4111-8111-111111111111'
1094
+ const OTHER_ID = 'session-33333333-3333-4333-8333-333333333333'
1095
+
1096
+ /** Plant a session the way the JSONL backend lays one out, plus its cache row. */
1097
+ function plantSession(id) {
1098
+ const dir = path.join(HOME_ROOT, 'sessions', '--tmp-work--', id)
1099
+ fs.mkdirSync(dir, { recursive: true })
1100
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl.zstd'), 'x')
1101
+ const cache = path.join(HOME_ROOT, 'storages', 'session_projcache', 'sessions', `${id}.json`)
1102
+ fs.mkdirSync(path.dirname(cache), { recursive: true })
1103
+ fs.writeFileSync(cache, '{}')
1104
+ return { dir, cache }
1105
+ }
1106
+
1107
+ const serviceFree = { get: () => undefined }
1108
+
1109
+ test('a delete takes the child sessions it was handed with it', async () => {
1110
+ const parent = plantSession(PARENT_ID)
1111
+ const child = plantSession(CHILD_ID)
1112
+ const bystander = plantSession(OTHER_ID)
1113
+
1114
+ await internals.deleteSession(serviceFree, PARENT_ID, { recycle: false, childSessionIds: [CHILD_ID] })
1115
+
1116
+ assert.equal(fs.existsSync(parent.dir), false, 'the parent is gone')
1117
+ assert.equal(fs.existsSync(child.dir), false, 'the ticked child went with it')
1118
+ assert.equal(fs.existsSync(child.cache), false, 'and so did the child cache row')
1119
+ assert.equal(fs.existsSync(bystander.dir), true, 'a session nobody ticked stays')
1120
+ })
1121
+
1122
+ test('without a child list the parent goes alone', async () => {
1123
+ const parent = plantSession(PARENT_ID)
1124
+ const child = plantSession(CHILD_ID)
1125
+
1126
+ await internals.deleteSession(serviceFree, PARENT_ID, { recycle: false })
1127
+
1128
+ assert.equal(fs.existsSync(parent.dir), false)
1129
+ assert.equal(fs.existsSync(child.dir), true, 'unticked children are left standing')
1130
+ })
1131
+
1132
+ test('the delete reports which children failed', async () => {
1133
+ const parent = plantSession(PARENT_ID)
1134
+
1135
+ const result = await internals.deleteSession(serviceFree, PARENT_ID, {
1136
+ recycle: false,
1137
+ childSessionIds: ['not-a-session-id'],
1138
+ })
1139
+
1140
+ assert.equal(result.children.length, 0, 'an invalid id never becomes a child delete')
1141
+ })
@@ -307,6 +307,11 @@ async function loadPlugin(options = {}) {
307
307
  const plan = options.plan ?? { created: [], modified: [], refused: [], truncated: false, warnings: [] }
308
308
  return { ok: true, status: 200, json: async () => ({ ok: true, ...plan }) }
309
309
  }
310
+ // The subagent sessions behind one conversation.
311
+ if (href.endsWith('/dsh-delete-session/children')) {
312
+ const children = options.children ?? []
313
+ return { ok: true, status: 200, json: async () => ({ ok: true, children }) }
314
+ }
310
315
  // The folder picker asks for one directory listing per navigation.
311
316
  if (href.endsWith('/dsh-delete-session/browse')) {
312
317
  const asked = JSON.parse((init && init.body) || '{}')
@@ -632,7 +637,7 @@ test('the delete row reads its session id from the host props, not from a fiber'
632
637
  element.props.onSelect({ shiftKey: false })
633
638
  const parts = dialogParts(harness)
634
639
  assert.ok(parts, 'the dialog opened for the id the host injected')
635
- assert.equal(harness.requests.length, 0, 'opening the dialog sends nothing yet')
640
+ assert.equal(deleteCalls(harness), 0, 'opening the dialog deletes nothing yet')
636
641
  })
637
642
 
638
643
  test('shift-click forces the dialog even after asking was turned off', async () => {
@@ -651,7 +656,7 @@ test('shift-click forces the dialog even after asking was turned off', async ()
651
656
  const forced = deleteRow(harness)
652
657
  forced.element.props.onSelect({ shiftKey: true })
653
658
  assert.ok(dialogParts(harness), 'shift-click brings the dialog back')
654
- assert.equal(harness.requests.length, 1, 'and still sends nothing until confirmed')
659
+ assert.equal(deleteCalls(harness), 1, 'and still deletes nothing until confirmed')
655
660
  })
656
661
 
657
662
  // --- the regression ---------------------------------------------------------
@@ -896,6 +901,9 @@ const rowConfig = (harness) =>
896
901
  const pageCard = (harness, view = 'page') => rowConfig(harness).Component({ view })
897
902
  // The settings belonging to the master switch live in a nested container, so the
898
903
  // walk descends into any element that carries a children list.
904
+ /** Only the delete request itself: opening the dialog now reads one thing. */
905
+ const deleteCalls = (harness) =>
906
+ harness.requests.filter((call) => call.url === '/dsh-delete-session/delete').length
899
907
  const collectChecks = (nodes, harness, out = []) => {
900
908
  for (const node of nodes || []) {
901
909
  if (node === null || node === undefined) continue
@@ -1047,7 +1055,7 @@ test('the produced-files feature ships off: no scan, no section, no quarantine f
1047
1055
 
1048
1056
  test('the page hides the artifact sub-controls while the master switch is on', async () => {
1049
1057
  const harness = await loadPlugin()
1050
- assert.equal(pageChecks(harness).length, 3, 'ask, recycle and the master switch render')
1058
+ assert.equal(pageChecks(harness).length, 4, 'ask, recycle, subagents and the master switch render')
1051
1059
  assert.equal(offCheckbox(harness).props.checked, true, 'the master switch starts ticked')
1052
1060
  })
1053
1061
 
@@ -1056,7 +1064,7 @@ test('clearing the master switch reveals the auto and scope controls', async ()
1056
1064
  offCheckbox(harness).props.onChange(false)
1057
1065
 
1058
1066
  assert.equal(localStorage.getItem(OFF_KEY), '0', 'the opt-in was stored')
1059
- assert.equal(pageChecks(harness).length, 7, 'the sub-controls render once enabled')
1067
+ assert.equal(pageChecks(harness).length, 8, 'the sub-controls render once enabled')
1060
1068
  assert.equal(autoCheckbox(harness).props.checked, false, 'asking is the default once enabled')
1061
1069
  assert.equal(scopeCreatedCheckbox(harness).props.checked, true, 'created-only is the default scope')
1062
1070
  assert.equal(scopeAllCheckbox(harness).props.checked, false)
@@ -1842,3 +1850,98 @@ test('unchecking it switches both the sentence and the request to a hard delete'
1842
1850
  await flush()
1843
1851
  assert.equal(deleteBody(harness).recycle, false, 'the host is told to remove it outright')
1844
1852
  })
1853
+
1854
+ // --- subagent sessions: ticked in the dialog, or taken with the parent ------
1855
+
1856
+ const SYNC_CHILDREN_KEY = 'dsh-delete-session:sync-children'
1857
+ const CHILDREN = [
1858
+ { id: 'session-child-one', depth: 1, cwd: 'E:\\work' },
1859
+ { id: 'session-child-two', depth: 2, cwd: 'E:\\work' },
1860
+ ]
1861
+
1862
+ const childChecks = (harness) => {
1863
+ const modal = dialogElement(harness)
1864
+ if (modal === null) return []
1865
+ const section = (modal.props.children ?? []).filter(Boolean).find((node) => node.props.key === 'children')
1866
+ return section === undefined ? [] : collectChecks([section], harness)
1867
+ }
1868
+
1869
+ test('the page offers to take child sessions with their parent, off by default', async () => {
1870
+ const harness = await loadPlugin()
1871
+ const box = pageCheck(harness, 'syncChildren')
1872
+
1873
+ assert.ok(box, 'the switch renders with the other delete controls')
1874
+ assert.equal(box.props.checked, false, 'asking is the default')
1875
+ assert.equal(box.props.label, '同时删除子会话')
1876
+
1877
+ box.props.onChange(true)
1878
+ assert.equal(localStorage.getItem(SYNC_CHILDREN_KEY), '1', 'the choice is remembered')
1879
+ })
1880
+
1881
+ test('with the switch off the dialog lists the children, ticked, for you to narrow', async () => {
1882
+ const harness = await loadPlugin({ children: CHILDREN })
1883
+ mountRow(harness, SESSION_A)
1884
+
1885
+ const { element } = deleteRow(harness, SESSION_A)
1886
+ element.props.onSelect({ shiftKey: false })
1887
+ await flush()
1888
+
1889
+ const checks = childChecks(harness)
1890
+ assert.equal(checks.length, 2, 'every child is offered')
1891
+ assert.deepEqual(checks.map((c) => c.props.checked), [true, true], 'they start ticked')
1892
+ })
1893
+
1894
+ test('unticking a child keeps it out of the delete', async () => {
1895
+ const harness = await loadPlugin({ children: CHILDREN })
1896
+ mountRow(harness, SESSION_A)
1897
+
1898
+ const { element } = deleteRow(harness, SESSION_A)
1899
+ element.props.onSelect({ shiftKey: false })
1900
+ await flush()
1901
+
1902
+ childChecks(harness)[0].props.onChange(false)
1903
+ dialogParts(harness).confirm.props.onClick()
1904
+ await flush()
1905
+
1906
+ assert.deepEqual(deleteBody(harness).childSessionIds, ['session-child-two'], 'only the remaining tick is deleted')
1907
+ })
1908
+
1909
+ test('with the switch on the children ride along without asking', async () => {
1910
+ const harness = await loadPlugin({ children: CHILDREN })
1911
+ localStorage.setItem(SYNC_CHILDREN_KEY, '1')
1912
+ mountRow(harness, SESSION_A)
1913
+
1914
+ const { element } = deleteRow(harness, SESSION_A)
1915
+ element.props.onSelect({ shiftKey: false })
1916
+ await flush()
1917
+
1918
+ assert.equal(childChecks(harness).length, 0, 'no list when the switch already answered')
1919
+
1920
+ dialogParts(harness).confirm.props.onClick()
1921
+ await flush()
1922
+
1923
+ assert.deepEqual(
1924
+ deleteBody(harness).childSessionIds,
1925
+ ['session-child-one', 'session-child-two'],
1926
+ 'every child goes with the parent',
1927
+ )
1928
+ })
1929
+
1930
+ // --- the quiet repository link at the bottom of the page --------------------
1931
+
1932
+ test('the page ends with a quiet link to the repository', async () => {
1933
+ const harness = await loadPlugin()
1934
+ const card = pageCard(harness)
1935
+ const star = findByKey(card.props.children, 'star')[0]
1936
+
1937
+ assert.ok(star, 'the star line renders with the card')
1938
+ assert.match(star.props.className, /dsdel-star\b/)
1939
+
1940
+ const [ask, cta] = star.props.children
1941
+ assert.equal(ask.props.children, '⭐ 顺手留颗 Star,作者能高兴一整天')
1942
+ assert.equal(cta.type, 'a', 'the call to action is a real link')
1943
+ assert.equal(cta.props.href, 'https://github.com/DDDMUC/dsh-delete-session')
1944
+ assert.equal(cta.props.target, '_blank')
1945
+ assert.equal(cta.props.rel, 'noreferrer')
1946
+ assert.equal(cta.props.children, '行,给你一颗 Star')
1947
+ })