@wangxiaocuo/dsh-session-trash 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.6] - 2026-11-11
8
+
9
+ ### Added
10
+
11
+ - 回收站页支持按移入时间、标题、大小、到期时间排序,可勾选多个条目批量恢复(只含工作区仍存在的条目),并支持全选当前页。
12
+ - 含子会话的条目可以展开查看成员列表(父/子会话 ID 与大小)。
13
+ - 同源浏览器标签页通过 `BroadcastChannel` 实时同步回收站变更,不再需要切换焦点或手动刷新。
14
+
15
+ ### Changed
16
+
17
+ - 类型收紧:`members.js` 的 `Member`/`TrashEntry` 与 `state.js` 的状态投影形状改为具体类型。
18
+
7
19
  ## [0.6.5] - 2026-11-11
8
20
 
9
21
  ### Added
@@ -112,6 +124,7 @@
112
124
 
113
125
  - 回收站工具栏的「永久删除全部」与二次确认;`missing` 记录只移出索引,不删除会话文件。
114
126
 
127
+ [0.6.6]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.5...v0.6.6
115
128
  [0.6.5]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.4...v0.6.5
116
129
  [0.6.4]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.3...v0.6.4
117
130
  [0.6.3]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.2...v0.6.3
package/README.en.md CHANGED
@@ -15,7 +15,9 @@ Status: feature complete. CI (GitHub Actions) runs the unit tests, the Host smok
15
15
  - Right-click a session in the sidebar and choose "Move to trash". It is available for cold sessions with no running turn, job, subagent, or scheduled task; hidden subagent children move with their parent and are restored together.
16
16
  - For sessions that are still open, still running work, or own visible child sessions, the menu keeps a labelled explanatory "Move to trash" item that states why the move is unavailable instead of hiding the entry entirely.
17
17
  - After the move, the shared DSH Toast offers one action: Undo. Undo tries a normal restore first and falls back to an orphan restore when the original workspace is gone.
18
- - The Trash page in Settings shows title, Session ID, original workspace, trashed date, remaining days, size, and status. The list supports search, filters, and paging at 50 rows per page.
18
+ - The Trash page in Settings shows title, Session ID, original workspace, trashed date, remaining days, size, and status. The list supports search, filters, sorting by trashed date/title/size/expiry, and paging at 50 rows per page.
19
+ - Entries with hidden sub-sessions show a count badge and can expand to list every member (parent/child session IDs and sizes); restore and permanent deletion still operate on the whole tree.
20
+ - Select multiple entries to restore them as a batch (only rows whose workspace still exists), including a select-all-page checkbox.
19
21
  - Row actions include restore, restore as orphan, permanent deletion, removal from the index, copying the Session ID, and revealing the original workspace in Finder or Explorer.
20
22
  - The toolbar's "Delete all permanently" empties the whole trash after one confirmation: it deletes every trashed session log and drops file-missing records from the index. Attachments, images, and search-index rows are not deleted.
21
23
  - Cleanup keeps entries for 30 days by default and runs every 6 hours. It also runs shortly after activation and when the Trash page opens. Failed cleanup runs are retried with exponential backoff, and the page shows the failure.
@@ -171,7 +173,8 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
171
173
  - Permanent deletion removes the session log only. Attachments, images, projection-cache rows, and search-index rows are not deleted.
172
174
  - Unindexed objects (object directories whose `meta.json` is damaged or unreadable) are never deleted automatically; the page can only clean empty leftovers that hold no session log, and anything that may contain logs is left for manual inspection.
173
175
  - `/state` returns all entry summaries in one response. Search, filtering, and paging happen in the Client.
174
- - There is no cross-window live push. Another window's change appears after a refresh or when the window regains focus.
176
+ - Same-origin tabs in the same browser sync index changes through `BroadcastChannel`; different browsers or machines still need a refresh.
177
+ - Batch restore only covers rows whose workspace still exists; entries with a missing workspace need an individual orphan restore.
175
178
  - The desktop apps on Windows and Linux have not been verified manually; CI boots an npm-installed `dsh web` Host, so desktop-shell UI and desktop-only features (such as reveal in the file manager) are still verified on macOS only. On Windows, cases that rely on POSIX permission bits are skipped, and long paths over 260 characters / symlink copying depend on system configuration (the related tests skip themselves when unsupported).
176
179
  - macOS path comparison is case-insensitive. On a case-sensitive APFS volume, paths that differ only by case are treated as the same location.
177
180
  - Multiple DSH processes sharing one `DSH_HOME` serialize trash operations through `.lock`. The Host retries briefly before reporting `LOCKED`, and the Client retries once automatically; a process paused for more than 10 minutes can lose the lock to another process.
package/README.md CHANGED
@@ -15,8 +15,9 @@ DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,
15
15
  - 侧边栏右键会话,选择「移入回收站」。只有冷会话、没有运行中的 turn、job、subagent 或定时任务时才能移入;会话跑过的隐藏 subagent 子会话会随父会话一起移入、一起恢复。
16
16
  - 对仍打开的会话、正在运行的会话或含可见子会话的会话,菜单保留一个带原因的「移入回收站」说明项:点开解释为什么暂时不能移入,而不是把菜单项直接隐藏。
17
17
  - 移入后显示 DSH 共享 Toast,只有「撤销」一个动作。撤销先尝试正常恢复,原工作区不存在时改为孤儿恢复。
18
- - 设置页「回收站」显示标题、Session ID、原工作区、移入时间、剩余天数、大小和状态。列表支持搜索、筛选,每页 50 条。
19
- - 含子会话的条目显示「含 N 个子会话」徽章;恢复和永久删除按整棵树处理,不做部分恢复。
18
+ - 设置页「回收站」显示标题、Session ID、原工作区、移入时间、剩余天数、大小和状态。列表支持搜索、筛选、按移入时间/标题/大小/到期排序,每页 50 条。
19
+ - 含子会话的条目显示「含 N 个子会话」徽章,可展开查看每个成员(父/子会话 ID 与大小);恢复和永久删除按整棵树处理,不做部分恢复。
20
+ - 可勾选多个条目批量恢复(只对工作区仍存在的条目开放),也支持全选当前页。
20
21
  - 行操作包括恢复、恢复为孤儿会话、永久删除、从索引移除、复制 Session ID,以及在 Finder 或资源管理器中显示原工作区。
21
22
  - 工具栏「永久删除全部」在二次确认后清空回收站:删除所有已移入的会话日志,并把文件缺失的记录移出索引;附件、图片和搜索索引等衍生物不删除。
22
23
  - 默认保留 30 天,每 6 小时扫描一次;插件激活后和打开回收站页面时也会检查。清理失败按指数退避重试,失败信息显示在页面上。
@@ -170,7 +171,8 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
170
171
  - 永久删除只删除会话日志。附件、图片、投影缓存和搜索索引不会被删除。
171
172
  - 无主对象(`meta.json` 损坏或无法识别的对象目录)不会自动删除;页面只能清理其中不含会话日志的空目录,可能含日志的需要人工处理。
172
173
  - `/state` 一次返回全部条目摘要,页面在客户端搜索、筛选和分页。
173
- - 没有跨窗口实时推送。另一个窗口的变更要等刷新或窗口重新获得焦点后出现。
174
+ - 同一浏览器的同源标签页通过 `BroadcastChannel` 实时同步索引变更;不同浏览器或不同设备之间仍需刷新页面。
175
+ - 批量恢复只包含工作区仍存在的条目;工作区缺失的条目需要逐条使用「恢复为孤儿会话」。
174
176
  - Windows/Linux 的桌面版尚未手动验证;CI 用 npm 安装的 `dsh web` 覆盖真实 Host 行为,但桌面壳 UI 与桌面专属能力(例如「在文件管理器中显示」)仍只在 macOS 上验证。Windows 上依赖 POSIX 权限位的用例会跳过;超过 260 字符的长路径和符号链接复制受系统设置影响,对应用例在不支持时自动跳过。
175
177
  - macOS 的路径比较按大小写不敏感处理。在大小写敏感的 APFS 卷上,仅大小写不同的两个路径会被视为同一位置。
176
178
  - 多个 DSH 进程共享同一个 `DSH_HOME` 时,回收站操作通过 `.lock` 串行;Host 在报 `LOCKED` 前会短暂重试,客户端也会自动重试一次;进程暂停超过 10 分钟后,锁可能被另一个进程接管。
package/client.js CHANGED
@@ -21,6 +21,13 @@ window.__ModuleLoader__.load({
21
21
  const EXPIRING_DAYS = 3
22
22
  /** Filter identifiers in their sidebar order. */
23
23
  const FILTERS = ['all', 'workspace-exists', 'workspace-missing', 'expiring']
24
+ /** Sort options in their menu order. */
25
+ const SORT_OPTIONS = [
26
+ { value: 'trashed', key: 'sortTrashed' },
27
+ { value: 'title', key: 'sortTitle' },
28
+ { value: 'size', key: 'sortSize' },
29
+ { value: 'expires', key: 'sortExpires' },
30
+ ]
24
31
  /** Locale keys for each filter label. */
25
32
  const FILTER_LABEL_KEYS = {
26
33
  all: 'filterAll',
@@ -56,12 +63,38 @@ window.__ModuleLoader__.load({
56
63
 
57
64
  /** Open trash views subscribe here so one successful mutation refreshes them all. */
58
65
  const revisionListeners = new Set()
66
+ /** Cross-window channel name; same-origin tabs refresh each other. */
67
+ const REVISION_CHANNEL_NAME = 'dsh-session-trash/revision'
68
+ /** Lazily created BroadcastChannel shared by every view in this tab. */
69
+ let revisionChannel = null
70
+ let revisionChannelTried = false
59
71
 
60
72
  /**
61
- * Notify every mounted trash view that the Host index changed.
73
+ * Open the cross-window revision channel once, when the platform supports it.
62
74
  * @returns {void}
63
75
  */
64
- function bumpRevision() {
76
+ function ensureRevisionChannel() {
77
+ if (revisionChannelTried) return
78
+ revisionChannelTried = true
79
+ const Ctor = typeof window === 'undefined' ? undefined : window.BroadcastChannel
80
+ if (typeof Ctor !== 'function') return
81
+ try {
82
+ revisionChannel = new Ctor(REVISION_CHANNEL_NAME)
83
+ revisionChannel.addEventListener('message', (event) => {
84
+ if (event === null || event === undefined) return
85
+ const data = event.data
86
+ if (data === null || typeof data !== 'object') return
87
+ if (data.type === 'revision') notifyRevisionListeners()
88
+ })
89
+ } catch {
90
+ revisionChannel = null
91
+ }
92
+ }
93
+
94
+ /**
95
+ * @returns {void}
96
+ */
97
+ function notifyRevisionListeners() {
65
98
  for (const listener of [...revisionListeners]) {
66
99
  try {
67
100
  listener()
@@ -71,11 +104,27 @@ window.__ModuleLoader__.load({
71
104
  }
72
105
  }
73
106
 
107
+ /**
108
+ * Notify every mounted trash view in this tab and across same-origin tabs.
109
+ * @returns {void}
110
+ */
111
+ function bumpRevision() {
112
+ ensureRevisionChannel()
113
+ notifyRevisionListeners()
114
+ if (revisionChannel === null) return
115
+ try {
116
+ revisionChannel.postMessage({ type: 'revision' })
117
+ } catch {
118
+ // A closed channel must never break the mutation that triggered it.
119
+ }
120
+ }
121
+
74
122
  /**
75
123
  * @param {() => void} listener refresh callback.
76
124
  * @returns {() => void} unsubscribe.
77
125
  */
78
126
  function subscribeRevision(listener) {
127
+ ensureRevisionChannel()
79
128
  revisionListeners.add(listener)
80
129
  return () => {
81
130
  revisionListeners.delete(listener)
@@ -97,6 +146,24 @@ window.__ModuleLoader__.load({
97
146
  searchPlaceholder: '搜索标题、Session ID 或原始工作区',
98
147
  searchClear: '清除搜索',
99
148
  resultsCount: '显示 {shown} / {total} 个会话',
149
+ selectRow: '选择「{title}」',
150
+ selectAll: '全选本页可选会话',
151
+ selectedCount: '已选择 {count} 个会话',
152
+ clearSelection: '取消选择',
153
+ batchRestore: '恢复选中',
154
+ batchRestoring: '正在恢复…',
155
+ batchRestoreDoneOne: '已恢复 {count} 个会话',
156
+ batchRestoreDoneOther: '已恢复 {count} 个会话',
157
+ batchRestorePartial: '已恢复 {restored} 个会话,{failed} 个失败',
158
+ sortLabel: '排序方式',
159
+ sortTrashed: '按移入时间(最新在前)',
160
+ sortTitle: '按标题',
161
+ sortSize: '按大小(最大在前)',
162
+ sortExpires: '按到期时间(最早在前)',
163
+ expandMembers: '展开子会话',
164
+ collapseMembers: '收起子会话',
165
+ memberParent: '父会话',
166
+ memberSubagent: '子会话',
100
167
  filterLabel: '筛选回收站',
101
168
  filterAll: '全部',
102
169
  filterWorkspaceExists: '工作区仍存在',
@@ -259,6 +326,24 @@ window.__ModuleLoader__.load({
259
326
  searchPlaceholder: 'Search title, Session ID, or original workspace',
260
327
  searchClear: 'Clear search',
261
328
  resultsCount: 'Showing {shown} of {total} sessions',
329
+ selectRow: 'Select “{title}”',
330
+ selectAll: 'Select all selectable sessions on this page',
331
+ selectedCount: 'Selected: {count} sessions',
332
+ clearSelection: 'Clear selection',
333
+ batchRestore: 'Restore selected',
334
+ batchRestoring: 'Restoring…',
335
+ batchRestoreDoneOne: 'Restored {count} session',
336
+ batchRestoreDoneOther: 'Restored {count} sessions',
337
+ batchRestorePartial: 'Restored {restored}; {failed} failed',
338
+ sortLabel: 'Sort by',
339
+ sortTrashed: 'Newest trashed first',
340
+ sortTitle: 'Title',
341
+ sortSize: 'Largest first',
342
+ sortExpires: 'Expiring soonest first',
343
+ expandMembers: 'Show sub-sessions',
344
+ collapseMembers: 'Hide sub-sessions',
345
+ memberParent: 'Parent',
346
+ memberSubagent: 'Sub-session',
262
347
  filterLabel: 'Filter the trash',
263
348
  filterAll: 'All',
264
349
  filterWorkspaceExists: 'Workspace exists',
@@ -433,6 +518,27 @@ window.__ModuleLoader__.load({
433
518
  return format(template, { ...values, count })
434
519
  }
435
520
 
521
+ /**
522
+ * Compare two trash entries for the selected sort order.
523
+ *
524
+ * @param {any} left first entry.
525
+ * @param {any} right second entry.
526
+ * @param {string} sortKey active sort identifier.
527
+ * @param {string | undefined} locale active locale.
528
+ * @returns {number} comparator result.
529
+ */
530
+ function compareEntries(left, right, sortKey, locale) {
531
+ const titleOf = (entry) => (typeof entry.title === 'string' && entry.title !== '' ? entry.title : entry.sessionId)
532
+ if (sortKey === 'title') return String(titleOf(left)).localeCompare(String(titleOf(right)), locale)
533
+ if (sortKey === 'size') return (Number(right.sizeBytes) || 0) - (Number(left.sizeBytes) || 0)
534
+ if (sortKey === 'expires') {
535
+ const leftAt = typeof left.expiresAt === 'number' ? left.expiresAt : Number.POSITIVE_INFINITY
536
+ const rightAt = typeof right.expiresAt === 'number' ? right.expiresAt : Number.POSITIVE_INFINITY
537
+ return leftAt - rightAt
538
+ }
539
+ return (right.trashedAt ?? 0) - (left.trashedAt ?? 0)
540
+ }
541
+
436
542
  /**
437
543
  * @param {number | null | undefined} bytes raw byte count.
438
544
  * @returns {string} a compact human-readable size, or an em dash when unknown.
@@ -848,6 +954,26 @@ window.__ModuleLoader__.load({
848
954
  .dsh-trash__dialogBody { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
849
955
  .dsh-trash__dialogActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
850
956
  .dsh-trash__srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
957
+ .dsh-trash__controlsRight { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; margin-left: auto; }
958
+ .dsh-trash__selectAll { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); cursor: pointer; white-space: nowrap; }
959
+ .dsh-trash__selectAllInput { width: 14px; height: 14px; margin: 0; flex: none; accent-color: var(--dsw-alias-state-business-primary); }
960
+ .dsh-trash__sort { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
961
+ .dsh-trash__sortLabel { white-space: nowrap; }
962
+ .dsh-trash__sortSelect { height: 28px; padding: 0 8px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; }
963
+ .dsh-trash__sortSelect:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
964
+ .dsh-trash__rowCheck { width: 15px; height: 15px; margin: 3px 0 0; flex: none; accent-color: var(--dsw-alias-state-business-primary); }
965
+ .dsh-trash__batch { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-2); }
966
+ .dsh-trash__batchCount { margin-right: auto; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
967
+ .dsh-trash__membersWrap { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
968
+ .dsh-trash__membersToggle { appearance: none; align-self: flex-start; border: none; background: none; padding: 0; color: var(--dsw-alias-state-business-primary); font: inherit; font-size: 12.5px; line-height: 1.5; cursor: pointer; }
969
+ .dsh-trash__membersToggle:hover { text-decoration: underline; }
970
+ .dsh-trash__membersToggle:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
971
+ .dsh-trash__members { list-style: none; margin: 0; padding: 0 0 0 10px; display: flex; flex-direction: column; gap: 4px; border-left: 2px solid var(--dsw-alias-border-l1); }
972
+ .dsh-trash__member { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
973
+ .dsh-trash__memberKind { color: var(--dsw-alias-label-tertiary); }
974
+ .dsh-trash__memberId { font-family: var(--dsw-font-mono, monospace); word-break: break-all; }
975
+ .dsh-trash__memberSize { margin-left: auto; color: var(--dsw-alias-label-tertiary); }
976
+
851
977
  @media (max-width: 560px) {
852
978
  .dsh-trash__row { flex-direction: column; }
853
979
  .dsh-trash__rowActions { align-self: flex-end; }
@@ -1188,6 +1314,10 @@ window.__ModuleLoader__.load({
1188
1314
  const [query, setQuery] = React.useState('')
1189
1315
  const [filter, setFilter] = React.useState('all')
1190
1316
  const [page, setPage] = React.useState(1)
1317
+ const [selectedIds, setSelectedIds] = React.useState(() => new Set())
1318
+ const [sortKey, setSortKey] = React.useState('trashed')
1319
+ const [expandedRows, setExpandedRows] = React.useState(() => new Set())
1320
+ const [batchRestoring, setBatchRestoring] = React.useState(false)
1191
1321
  const abortRef = React.useRef(null)
1192
1322
  const searchRef = React.useRef(null)
1193
1323
  const panelRef = React.useRef(null)
@@ -1434,7 +1564,7 @@ window.__ModuleLoader__.load({
1434
1564
 
1435
1565
  const data = view.data
1436
1566
  const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
1437
- entries.sort((left, right) => (right.trashedAt ?? 0) - (left.trashedAt ?? 0))
1567
+ entries.sort((left, right) => compareEntries(left, right, sortKey, locale))
1438
1568
  const retentionDays =
1439
1569
  data !== null && data.config !== undefined && typeof data.config.retentionDays === 'number'
1440
1570
  ? data.config.retentionDays
@@ -1467,7 +1597,12 @@ window.__ModuleLoader__.load({
1467
1597
  const refreshing = view.status === 'refreshing'
1468
1598
  const readsBusy = loading || refreshing
1469
1599
  const pendingCount =
1470
- pendingRows.size + (cleaning ? 1 : 0) + (purgingAll ? 1 : 0) + (rebuilding ? 1 : 0) + (forgettingOrphans ? 1 : 0)
1600
+ pendingRows.size +
1601
+ (cleaning ? 1 : 0) +
1602
+ (purgingAll ? 1 : 0) +
1603
+ (rebuilding ? 1 : 0) +
1604
+ (forgettingOrphans ? 1 : 0) +
1605
+ (batchRestoring ? 1 : 0)
1471
1606
  /** Any write (row action or maintenance) is in flight. */
1472
1607
  const mutationBusy = pendingCount > 0
1473
1608
  const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
@@ -1512,6 +1647,10 @@ window.__ModuleLoader__.load({
1512
1647
  const pageStart = (currentPage - 1) * PAGE_SIZE
1513
1648
  const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
1514
1649
  const filtersActive = normalizedQuery !== '' || filter !== 'all'
1650
+ const pageSelectable = visibleEntries.filter((entry) => isSelectable(entry))
1651
+ const selectedEntries = entries.filter((entry) => selectedIds.has(entry.sessionId) && isSelectable(entry))
1652
+ const allPageSelected =
1653
+ pageSelectable.length > 0 && pageSelectable.every((entry) => selectedIds.has(entry.sessionId))
1515
1654
 
1516
1655
  /**
1517
1656
  * @returns {void} focus the search field when it is mounted.
@@ -1546,13 +1685,118 @@ window.__ModuleLoader__.load({
1546
1685
  }
1547
1686
  }
1548
1687
 
1688
+ /**
1689
+ * @param {object} entry wire entry.
1690
+ * @returns {boolean} whether this row can join a batch restore.
1691
+ */
1692
+ function isSelectable(entry) {
1693
+ return entry.canRestore === true && entry.requiresOrphanRestore !== true
1694
+ }
1695
+
1696
+ /**
1697
+ * @param {string} sessionId selected row identity.
1698
+ * @returns {void}
1699
+ */
1700
+ function toggleSelected(sessionId) {
1701
+ setSelectedIds((previous) => {
1702
+ const next = new Set(previous)
1703
+ if (next.has(sessionId)) next.delete(sessionId)
1704
+ else next.add(sessionId)
1705
+ return next
1706
+ })
1707
+ }
1708
+
1709
+ /**
1710
+ * @param {boolean} checked whether the page should be selected.
1711
+ * @returns {void}
1712
+ */
1713
+ function togglePageSelection(checked) {
1714
+ setSelectedIds((previous) => {
1715
+ const next = new Set(previous)
1716
+ for (const entry of pageSelectable) {
1717
+ if (checked) next.add(entry.sessionId)
1718
+ else next.delete(entry.sessionId)
1719
+ }
1720
+ return next
1721
+ })
1722
+ }
1723
+
1724
+ /**
1725
+ * @returns {void}
1726
+ */
1727
+ function clearSelection() {
1728
+ setSelectedIds(new Set())
1729
+ }
1730
+
1731
+ /**
1732
+ * @param {string} sessionId row whose member list is toggled.
1733
+ * @returns {void}
1734
+ */
1735
+ function toggleExpanded(sessionId) {
1736
+ setExpandedRows((previous) => {
1737
+ const next = new Set(previous)
1738
+ if (next.has(sessionId)) next.delete(sessionId)
1739
+ else next.add(sessionId)
1740
+ return next
1741
+ })
1742
+ }
1743
+
1744
+ /**
1745
+ * Restore every selected row, one Host request at a time. Failures are
1746
+ * collected per row and summarized in one Toast.
1747
+ *
1748
+ * @returns {Promise<void>} completion of the operation.
1749
+ */
1750
+ async function performBatchRestore() {
1751
+ if (!beginWrite()) return
1752
+ const targets = selectedEntries.slice()
1753
+ setBatchRestoring(true)
1754
+ let restored = 0
1755
+ let failed = 0
1756
+ try {
1757
+ for (const entry of targets) {
1758
+ if (!aliveRef.current) return
1759
+ const outcome = await sendMutation('restore', { sessionId: entry.sessionId, mode: 'normal' })
1760
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1761
+ if (outcome.kind !== 'response') {
1762
+ failed += 1
1763
+ recordRowError(entry, transportMessage(outcome.kind))
1764
+ continue
1765
+ }
1766
+ if (!outcome.ok) {
1767
+ failed += 1
1768
+ recordRowError(entry, describeActionFailure(outcome.status, outcome.payload, t))
1769
+ continue
1770
+ }
1771
+ restored += 1
1772
+ clearRowError(entry.sessionId)
1773
+ }
1774
+ if (!aliveRef.current) return
1775
+ bumpRevision()
1776
+ clearSelection()
1777
+ const message =
1778
+ failed === 0
1779
+ ? countText(t, 'batchRestoreDone', restored)
1780
+ : format(t('batchRestorePartial'), { restored, failed })
1781
+ ctx.contextMenus.notify(message, failed === 0)
1782
+ announce(message)
1783
+ } finally {
1784
+ endWrite()
1785
+ if (aliveRef.current) setBatchRestoring(false)
1786
+ }
1787
+ }
1788
+
1549
1789
  /**
1550
1790
  * @param {object} entry wire entry.
1551
1791
  * @param {string} message localized failure text.
1552
1792
  * @returns {void}
1553
1793
  */
1554
- function fail(entry, message) {
1794
+ function recordRowError(entry, message) {
1555
1795
  setRowErrors((previous) => ({ ...previous, [entry.sessionId]: message }))
1796
+ }
1797
+
1798
+ function fail(entry, message) {
1799
+ recordRowError(entry, message)
1556
1800
  ctx.contextMenus.notify(message, false)
1557
1801
  }
1558
1802
 
@@ -2178,13 +2422,48 @@ window.__ModuleLoader__.load({
2178
2422
  h(CloseGlyph, { size: 11 }),
2179
2423
  ),
2180
2424
  ),
2181
- filtersActive
2182
- ? h(
2183
- 'span',
2184
- { className: 'dsh-trash__resultsCount' },
2185
- format(t('resultsCount'), { shown: filteredEntries.length, total: entries.length }),
2186
- )
2187
- : null,
2425
+ h(
2426
+ 'div',
2427
+ { className: 'dsh-trash__controlsRight' },
2428
+ filtersActive
2429
+ ? h(
2430
+ 'span',
2431
+ { className: 'dsh-trash__resultsCount' },
2432
+ format(t('resultsCount'), { shown: filteredEntries.length, total: entries.length }),
2433
+ )
2434
+ : null,
2435
+ h(
2436
+ 'label',
2437
+ { className: 'dsh-trash__selectAll' },
2438
+ h('input', {
2439
+ type: 'checkbox',
2440
+ className: 'dsh-trash__selectAllInput',
2441
+ checked: allPageSelected,
2442
+ 'aria-label': t('selectAll'),
2443
+ disabled: pageSelectable.length === 0 || loading || mutationBusy || corrupt,
2444
+ onChange: (event) => togglePageSelection(event.target.checked),
2445
+ }),
2446
+ h('span', null, t('selectAll')),
2447
+ ),
2448
+ h(
2449
+ 'label',
2450
+ { className: 'dsh-trash__sort' },
2451
+ h('span', { className: 'dsh-trash__sortLabel' }, t('sortLabel')),
2452
+ h(
2453
+ 'select',
2454
+ {
2455
+ className: 'dsh-trash__sortSelect',
2456
+ value: sortKey,
2457
+ 'aria-label': t('sortLabel'),
2458
+ onChange: (event) => {
2459
+ setSortKey(event.target.value)
2460
+ setPage(1)
2461
+ },
2462
+ },
2463
+ SORT_OPTIONS.map((option) => h('option', { value: option.value, key: option.value }, t(option.key))),
2464
+ ),
2465
+ ),
2466
+ ),
2188
2467
  ),
2189
2468
  h(
2190
2469
  'div',
@@ -2212,6 +2491,37 @@ window.__ModuleLoader__.load({
2212
2491
  ),
2213
2492
  )
2214
2493
 
2494
+ const batchBar =
2495
+ selectedEntries.length === 0
2496
+ ? null
2497
+ : h(
2498
+ 'div',
2499
+ { className: 'dsh-trash__batch', key: 'batch', role: 'region', 'aria-label': format(t('selectedCount'), { count: selectedEntries.length }) },
2500
+ h('span', { className: 'dsh-trash__batchCount' }, format(t('selectedCount'), { count: selectedEntries.length })),
2501
+ h(
2502
+ 'button',
2503
+ {
2504
+ type: 'button',
2505
+ className: 'dsh-trash__button',
2506
+ onClick: () => {
2507
+ void performBatchRestore()
2508
+ },
2509
+ disabled: loading || mutationBusy || corrupt,
2510
+ },
2511
+ batchRestoring ? t('batchRestoring') : t('batchRestore'),
2512
+ ),
2513
+ h(
2514
+ 'button',
2515
+ {
2516
+ type: 'button',
2517
+ className: 'dsh-trash__button',
2518
+ onClick: clearSelection,
2519
+ disabled: batchRestoring,
2520
+ },
2521
+ t('clearSelection'),
2522
+ ),
2523
+ )
2524
+
2215
2525
  let panelBody
2216
2526
  if (view.status === 'loading') {
2217
2527
  panelBody = h(
@@ -2259,6 +2569,16 @@ window.__ModuleLoader__.load({
2259
2569
  return h(
2260
2570
  'li',
2261
2571
  { className: 'dsh-trash__row', key: entry.sessionId, 'aria-busy': rowBusy ? 'true' : 'false' },
2572
+ isSelectable(entry)
2573
+ ? h('input', {
2574
+ type: 'checkbox',
2575
+ className: 'dsh-trash__rowCheck',
2576
+ checked: selectedIds.has(entry.sessionId),
2577
+ 'aria-label': format(t('selectRow'), { title }),
2578
+ disabled: rowBusy || mutationBusy || corrupt || loading,
2579
+ onChange: () => toggleSelected(entry.sessionId),
2580
+ })
2581
+ : null,
2262
2582
  h(
2263
2583
  'div',
2264
2584
  { className: 'dsh-trash__rowMain' },
@@ -2293,6 +2613,41 @@ window.__ModuleLoader__.load({
2293
2613
  h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
2294
2614
  h('span', null, formatBytes(entry.sizeBytes)),
2295
2615
  ),
2616
+ entry.subagentCount > 0
2617
+ ? h(
2618
+ 'div',
2619
+ { className: 'dsh-trash__membersWrap' },
2620
+ h(
2621
+ 'button',
2622
+ {
2623
+ type: 'button',
2624
+ className: 'dsh-trash__membersToggle',
2625
+ 'aria-expanded': expandedRows.has(entry.sessionId) ? 'true' : 'false',
2626
+ onClick: () => toggleExpanded(entry.sessionId),
2627
+ },
2628
+ expandedRows.has(entry.sessionId) ? t('collapseMembers') : t('expandMembers'),
2629
+ ),
2630
+ expandedRows.has(entry.sessionId)
2631
+ ? h(
2632
+ 'ul',
2633
+ { className: 'dsh-trash__members' },
2634
+ (Array.isArray(entry.members) ? entry.members : []).map((member) =>
2635
+ h(
2636
+ 'li',
2637
+ { className: 'dsh-trash__member', key: member.sessionId },
2638
+ h(
2639
+ 'span',
2640
+ { className: 'dsh-trash__memberKind' },
2641
+ member.origin === 'subagent' ? t('memberSubagent') : t('memberParent'),
2642
+ ),
2643
+ h('code', { className: 'dsh-trash__memberId' }, member.sessionId),
2644
+ h('span', { className: 'dsh-trash__memberSize' }, formatBytes(member.sizeBytes)),
2645
+ ),
2646
+ ),
2647
+ )
2648
+ : null,
2649
+ )
2650
+ : null,
2296
2651
  error !== null && error !== undefined && error !== ''
2297
2652
  ? h(
2298
2653
  'div',
@@ -2418,6 +2773,7 @@ window.__ModuleLoader__.load({
2418
2773
  banners,
2419
2774
  toolbar,
2420
2775
  controls,
2776
+ batchBar,
2421
2777
  panel,
2422
2778
  footnote,
2423
2779
  h('div', { className: 'dsh-trash__srOnly', role: 'status', 'aria-live': 'polite' }, announcement),
package/lib/members.js CHANGED
@@ -8,39 +8,74 @@
8
8
  */
9
9
 
10
10
  /**
11
- * @param {any} entry index entry (v1 or v2 shape).
12
- * @returns {object|null} the parent member, or null when the entry has no directory data.
11
+ * @typedef {object} Member
12
+ * @property {string} sessionId
13
+ * @property {'subagent' | null} [origin]
14
+ * @property {string} [title]
15
+ * @property {string} [originalSessionDir]
16
+ * @property {string} [originalLogPath]
17
+ * @property {string} [persistenceProjectDirHint]
18
+ * @property {string} [formatStatus]
19
+ * @property {number | null} [sizeBytes]
20
+ * @property {number | null} [fileCount]
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} TrashEntry
25
+ * @property {string} sessionId
26
+ * @property {string} [trashId]
27
+ * @property {string} [state]
28
+ * @property {string} [title]
29
+ * @property {number} [trashedAt]
30
+ * @property {number} [startedAt]
31
+ * @property {string | null} [originalCwd]
32
+ * @property {boolean} [workspaceExistsAtDeletion]
33
+ * @property {string} [originalSessionDir]
34
+ * @property {string} [originalLogPath]
35
+ * @property {string} [persistenceProjectDirHint]
36
+ * @property {string} [formatStatus]
37
+ * @property {number | null} [sizeBytes]
38
+ * @property {number | null} [fileCount]
39
+ * @property {string | null} [lastError]
40
+ * @property {Member[]} [members]
41
+ */
42
+
43
+ /**
44
+ * @param {unknown} entry index entry (v1 or v2 shape).
45
+ * @returns {Member | null} the parent member, or null when the entry has no directory data.
13
46
  */
14
47
  export function parentMember(entry) {
15
48
  if (entry === null || typeof entry !== 'object') return null
16
- if (typeof entry.sessionId !== 'string' || entry.sessionId === '') return null
49
+ const record = /** @type {Record<string, any>} */ (entry)
50
+ if (typeof record.sessionId !== 'string' || record.sessionId === '') return null
17
51
  return {
18
- sessionId: entry.sessionId,
52
+ sessionId: record.sessionId,
19
53
  origin: null,
20
- title: typeof entry.title === 'string' ? entry.title : entry.sessionId,
21
- originalSessionDir: entry.originalSessionDir,
22
- originalLogPath: entry.originalLogPath,
23
- persistenceProjectDirHint: entry.persistenceProjectDirHint,
24
- formatStatus: entry.formatStatus,
25
- sizeBytes: entry.sizeBytes,
26
- fileCount: entry.fileCount,
54
+ title: typeof record.title === 'string' ? record.title : record.sessionId,
55
+ originalSessionDir: record.originalSessionDir,
56
+ originalLogPath: record.originalLogPath,
57
+ persistenceProjectDirHint: record.persistenceProjectDirHint,
58
+ formatStatus: record.formatStatus,
59
+ sizeBytes: record.sizeBytes,
60
+ fileCount: record.fileCount,
27
61
  }
28
62
  }
29
63
 
30
64
  /**
31
- * @param {any} entry index entry (v1 or v2 shape).
32
- * @returns {any[]} every owned directory, parent first.
65
+ * @param {unknown} entry index entry (v1 or v2 shape).
66
+ * @returns {Member[]} every owned directory, parent first.
33
67
  */
34
68
  export function memberList(entry) {
35
- if (entry !== null && typeof entry === 'object' && Array.isArray(entry.members) && entry.members.length > 0) {
36
- return entry.members
69
+ if (entry !== null && typeof entry === 'object') {
70
+ const record = /** @type {Record<string, any>} */ (entry)
71
+ if (Array.isArray(record.members) && record.members.length > 0) return record.members
37
72
  }
38
73
  const parent = parentMember(entry)
39
74
  return parent === null ? [] : [parent]
40
75
  }
41
76
 
42
77
  /**
43
- * @param {readonly any[]} members member list.
78
+ * @param {ReadonlyArray<Partial<Member> | null | undefined>} members member list.
44
79
  * @returns {{ sizeBytes: number, fileCount: number }} summed measurable totals.
45
80
  */
46
81
  export function aggregateMetrics(members) {
@@ -57,21 +92,26 @@ export function aggregateMetrics(members) {
57
92
  * Shape-level validation shared by the durable index and object sidecars.
58
93
  * Path strictness belongs to the sidecar validator.
59
94
  *
60
- * @param {any} member candidate member.
95
+ * @param {unknown} member candidate member.
61
96
  * @returns {boolean} whether the member carries the required v2 identity.
62
97
  */
63
98
  export function isValidMemberShape(member) {
64
99
  if (member === null || typeof member !== 'object' || Array.isArray(member)) return false
65
- if (typeof member.sessionId !== 'string' || member.sessionId === '' || member.sessionId.length > 256) return false
66
- if (member.origin !== undefined && member.origin !== null && member.origin !== 'subagent') return false
67
- if (member.title !== undefined && typeof member.title !== 'string') return false
68
- if (member.formatStatus !== undefined && member.formatStatus !== 'current' && member.formatStatus !== 'migration-required') {
100
+ const record = /** @type {Record<string, any>} */ (member)
101
+ if (typeof record.sessionId !== 'string' || record.sessionId === '' || record.sessionId.length > 256) return false
102
+ if (record.origin !== undefined && record.origin !== null && record.origin !== 'subagent') return false
103
+ if (record.title !== undefined && typeof record.title !== 'string') return false
104
+ if (
105
+ record.formatStatus !== undefined &&
106
+ record.formatStatus !== 'current' &&
107
+ record.formatStatus !== 'migration-required'
108
+ ) {
69
109
  return false
70
110
  }
71
- if (member.sizeBytes !== undefined && member.sizeBytes !== null && typeof member.sizeBytes !== 'number') return false
72
- if (member.fileCount !== undefined && member.fileCount !== null && typeof member.fileCount !== 'number') return false
111
+ if (record.sizeBytes !== undefined && record.sizeBytes !== null && typeof record.sizeBytes !== 'number') return false
112
+ if (record.fileCount !== undefined && record.fileCount !== null && typeof record.fileCount !== 'number') return false
73
113
  for (const key of ['originalSessionDir', 'originalLogPath', 'persistenceProjectDirHint']) {
74
- if (member[key] !== undefined && member[key] !== null && typeof member[key] !== 'string') return false
114
+ if (record[key] !== undefined && record[key] !== null && typeof record[key] !== 'string') return false
75
115
  }
76
116
  return true
77
117
  }
@@ -79,17 +119,19 @@ export function isValidMemberShape(member) {
79
119
  /**
80
120
  * Whether the v2 member list is internally consistent with its parent entry.
81
121
  *
82
- * @param {any} entry index entry carrying `members`.
122
+ * @param {unknown} entry index entry carrying `members`.
83
123
  * @returns {boolean} whether the list is acceptable.
84
124
  */
85
125
  export function isValidMemberList(entry) {
86
126
  if (entry === null || typeof entry !== 'object') return false
87
- if (!Array.isArray(entry.members) || entry.members.length === 0 || entry.members.length > 64) return false
127
+ const record = /** @type {Record<string, any>} */ (entry)
128
+ const members = record.members
129
+ if (!Array.isArray(members) || members.length === 0 || members.length > 64) return false
88
130
  const seen = new Set()
89
- for (const member of entry.members) {
131
+ for (const member of members) {
90
132
  if (!isValidMemberShape(member)) return false
91
133
  if (seen.has(member.sessionId)) return false
92
134
  seen.add(member.sessionId)
93
135
  }
94
- return entry.members[0].sessionId === entry.sessionId
136
+ return members[0].sessionId === record.sessionId
95
137
  }
package/lib/state.js CHANGED
@@ -48,18 +48,60 @@ async function directoryExists(directory) {
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * @typedef {object} TrashConfig
53
+ * @property {number} retentionDays
54
+ * @property {number} sweepIntervalHours
55
+ * @property {boolean} enableAutoCleanup
56
+ */
57
+
58
+ /**
59
+ * @typedef {object} CleanupDiagnostics
60
+ * @property {boolean} enabled
61
+ * @property {boolean} timerBacked
62
+ * @property {number | null} lastAttemptAt
63
+ * @property {number | null} lastSuccessAt
64
+ * @property {number | null} nextSweepAt
65
+ * @property {number | null} retryAt
66
+ * @property {number} consecutiveFailures
67
+ * @property {object | null} lastRun
68
+ */
69
+
70
+ /**
71
+ * @typedef {object} StateEntry
72
+ * @property {string} sessionId
73
+ * @property {string | undefined} title
74
+ * @property {number | undefined} trashedAt
75
+ * @property {number | null} expiresAt
76
+ * @property {string | null} originalCwd
77
+ * @property {boolean | null} workspaceExistsAtDeletion
78
+ * @property {boolean | null} workspaceNowExists
79
+ * @property {number | null} sizeBytes
80
+ * @property {number | null} fileCount
81
+ * @property {string | null} formatStatus
82
+ * @property {string | undefined} state
83
+ * @property {boolean} canRestore
84
+ * @property {boolean} canPurge
85
+ * @property {boolean} canForget
86
+ * @property {boolean} requiresOrphanRestore
87
+ * @property {string | null} lastError
88
+ * @property {string | null} lastErrorCode
89
+ * @property {number} subagentCount
90
+ * @property {{ sessionId: string, origin: 'subagent' | null, sizeBytes: number | null }[]} members
91
+ */
92
+
51
93
  /**
52
94
  * Build the read-only `/state` projection from the committed index.
53
95
  *
54
96
  * @param {{
55
97
  * store: import('./store.js').TrashStore,
56
- * config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
98
+ * config: TrashConfig,
57
99
  * trashRootDisplay: string,
58
100
  * nextSweepAt?: () => number | null,
59
- * cleanup?: () => any,
60
- * directoryExists?: (directory?: any) => Promise<boolean>,
101
+ * cleanup?: () => CleanupDiagnostics | null,
102
+ * directoryExists?: (directory?: string | null) => Promise<boolean>,
61
103
  * }} deps state inputs.
62
- * @returns {{ snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> }} the state service.
104
+ * @returns {{ snapshot: () => Promise<object>, describe: (entry: import('./members.js').TrashEntry) => Promise<StateEntry> }} the state service.
63
105
  */
64
106
  export function createStateService({
65
107
  store,
@@ -70,13 +112,13 @@ export function createStateService({
70
112
  directoryExists: probeDirectory = directoryExists,
71
113
  }) {
72
114
  /**
73
- * @returns {any} cleanup diagnostics, or a neutral fallback.
115
+ * @returns {CleanupDiagnostics} cleanup diagnostics, or a neutral fallback.
74
116
  */
75
117
  function cleanupDiagnostics() {
76
118
  if (typeof cleanup === 'function') {
77
119
  try {
78
120
  const value = cleanup()
79
- if (value !== null && typeof value === 'object') return value
121
+ if (value !== null && typeof value === 'object') return /** @type {CleanupDiagnostics} */ (value)
80
122
  } catch {
81
123
  // Diagnostics must never break the read-only state projection.
82
124
  }
@@ -94,10 +136,10 @@ export function createStateService({
94
136
  }
95
137
 
96
138
  /**
97
- * @param {any} entry committed index entry.
139
+ * @param {import('./members.js').TrashEntry} entry committed index entry.
98
140
  * @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
99
141
  * started for this snapshot, so entries sharing a cwd probe it once.
100
- * @returns {Promise<any>} the wire entry.
142
+ * @returns {Promise<StateEntry>} the wire entry.
101
143
  */
102
144
  async function describeEntry(entry, probeCache = new Map()) {
103
145
  const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
@@ -140,12 +182,12 @@ export function createStateService({
140
182
 
141
183
  return {
142
184
  /**
143
- * @param {any} entry committed index entry.
144
- * @returns {Promise<any>} the wire entry.
185
+ * @param {import('./members.js').TrashEntry} entry committed index entry.
186
+ * @returns {Promise<StateEntry>} the wire entry.
145
187
  */
146
188
  describe: describeEntry,
147
189
  /**
148
- * @returns {Promise<any>} the complete `/state` response body.
190
+ * @returns {Promise<object>} the complete `/state` response body.
149
191
  */
150
192
  async snapshot() {
151
193
  const probeCache = new Map()
@@ -1036,6 +1036,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1036
1036
 
1037
1037
  const plan = Array.isArray(verdict.members) ? verdict.members : []
1038
1038
  if (plan.length === 0) throw new TrashError('SESSION_NOT_FOUND', 'This session is not in session storage.')
1039
+ /** @type {import('./members.js').Member[]} */
1039
1040
  const members = []
1040
1041
  let parentSnapshot
1041
1042
  for (let index = 0; index < plan.length; index += 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxiaocuo/dsh-session-trash",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "DeepSeek Harness plugin: move cold sessions into a recoverable trash, restore them within the retention window, or delete their logs permanently.",
5
5
  "license": "MIT",
6
6
  "author": "wangxiaocuo",