@wangxiaocuo/dsh-session-trash 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -32,6 +32,19 @@ window.__ModuleLoader__.load({
32
32
  * Preflight refusals the user can act on themselves. Those keep a visible
33
33
  * menu entry that explains the refusal instead of hiding it.
34
34
  */
35
+ /** Host `lastErrorCode` values mapped to owner-facing messages. */
36
+ const LAST_ERROR_KEYS = {
37
+ TRASH_COPY_MISSING: 'lastErrorTrashCopyMissing',
38
+ TRASH_COPY_MISSING_MEMBERS: 'lastErrorTrashCopyMissingMembers',
39
+ TRASH_OBJECT_MISSING: 'lastErrorTrashObjectMissing',
40
+ PURGE_FAILED: 'lastErrorPurgeFailed',
41
+ RESTORE_INVISIBLE: 'lastErrorRestoreInvisible',
42
+ MOVE_UNSETTLED: 'lastErrorMoveUnsettled',
43
+ SOURCE_CONFLICT: 'lastErrorSourceConflict',
44
+ INDEX_WRITE_FAILED: 'lastErrorIndexWriteFailed',
45
+ IO_ERROR: 'lastErrorIo',
46
+ }
47
+
35
48
  const MOVE_BLOCKED_KEYS = {
36
49
  SESSION_LIVE: { label: 'moveBlockedLive', toast: 'blockedLiveToast' },
37
50
  SESSION_ACTIVE: { label: 'moveBlockedActive', toast: 'blockedActiveToast' },
@@ -77,7 +90,8 @@ window.__ModuleLoader__.load({
77
90
  retry: '重试',
78
91
  cleanupExpired: '清理已过期',
79
92
  cleaning: '正在清理…',
80
- statsCount: '{count} 个会话',
93
+ statsCountOne: '{count} 个会话',
94
+ statsCountOther: '{count} 个会话',
81
95
  statsSize: '占用 {size}',
82
96
  searchLabel: '搜索回收站',
83
97
  searchPlaceholder: '搜索标题、Session ID 或原始工作区',
@@ -127,7 +141,8 @@ window.__ModuleLoader__.load({
127
141
  badgeMigration: '需要迁移',
128
142
  badgeExpiring: '即将到期',
129
143
  badgeExpired: '已过期',
130
- badgeSubagents: '含 {count} 个子会话',
144
+ badgeSubagentsOne: '含 {count} 个子会话',
145
+ badgeSubagentsOther: '含 {count} 个子会话',
131
146
  stateTrashing: '正在移入…',
132
147
  stateRestoring: '正在恢复…',
133
148
  statePurging: '正在删除…',
@@ -135,7 +150,7 @@ window.__ModuleLoader__.load({
135
150
  actionRestoreFor: '恢复「{title}」',
136
151
  actionMoreFor: '「{title}」的更多操作',
137
152
  actionRestoreOrphan: '恢复为孤儿会话',
138
- actionReveal: '在 Finder 中显示',
153
+ actionReveal: '在文件管理器中显示',
139
154
  actionRevealDisabled: '原工作区已不存在',
140
155
  actionCopyId: '复制 Session ID',
141
156
  actionForget: '从索引移除',
@@ -152,23 +167,37 @@ window.__ModuleLoader__.load({
152
167
  purgeAllTitle: '永久删除回收站中的全部 {count} 个会话?',
153
168
  purgeAllBody: '此操作不可撤销。附件、图片和搜索索引等衍生物不会被删除。',
154
169
  purgeAllConfirm: '全部永久删除',
155
- purgeAllDone: '已永久删除 {count} 个会话',
170
+ purgeAllDoneOne: '已永久删除 {count} 个会话',
171
+ purgeAllDoneOther: '已永久删除 {count} 个会话',
156
172
  purgeAllNone: '回收站中没有可删除的会话',
157
- purgeAllPartial: '已永久删除 {removed} 个会话,{failed} 个未能删除',
173
+ purgeAllPartial: '批量删除完成:成功 {removed} 个,失败 {failed} 个。',
158
174
  forgetTitle: '从回收站列表移除这条缺失记录?',
159
175
  forgetBody: '不会删除任何文件;仅移除索引中的这条记录。',
160
176
  forgetConfirm: '从索引移除',
161
- cleanupDone: '已清理 {count} 个过期会话',
177
+ cleanupDoneOne: '已清理 {count} 个过期会话',
178
+ cleanupDoneOther: '已清理 {count} 个过期会话',
162
179
  cleanupNone: '没有需要清理的过期会话',
163
- cleanupFailedBanner: '上次自动清理有 {count} 个会话未能删除。',
164
- cleanupErrorBanner: '上次自动清理失败:{message}',
180
+ cleanupFailedBannerOne: '上次自动清理有 {count} 个会话未能删除。',
181
+ cleanupFailedBannerOther: '上次自动清理有 {count} 个会话未能删除。',
182
+ cleanupErrorBanner: '上次自动清理失败,请稍后在本页重试。',
183
+ cleanupFailed: '清理失败:还有 {failed} 个过期会话没有删除。',
184
+ cleanupPartial: '已清理 {purged} 个过期会话,{failed} 个没有删除。',
165
185
  cleanupRetryAt: '将在 {time} 自动重试。',
166
186
  cleanupRetryNow: '立即重试',
167
187
  copySuccess: '已复制 Session ID',
168
188
  copyFailed: '无法复制 Session ID,请手动复制',
169
- revealSuccess: '已在 Finder 中显示原工作区',
189
+ revealSuccess: '已在文件管理器中显示原工作区',
170
190
  revealFailed: '无法打开原工作区',
171
191
  lastErrorLabel: '上次错误:{message}',
192
+ lastErrorTrashCopyMissing: '回收站里的会话副本缺失。',
193
+ lastErrorTrashCopyMissingMembers: '回收站里的部分子会话副本缺失。',
194
+ lastErrorTrashObjectMissing: '回收站对象目录缺失。',
195
+ lastErrorPurgeFailed: '会话日志删除失败。',
196
+ lastErrorRestoreInvisible: '恢复后会话存储没有识别到该会话。',
197
+ lastErrorMoveUnsettled: '会话已移动,但会话存储仍报告它存在。',
198
+ lastErrorSourceConflict: '原会话目录与回收站副本同时存在,两者都已保留。',
199
+ lastErrorIndexWriteFailed: '回收站索引写入失败。',
200
+ lastErrorIo: '文件操作失败。',
172
201
  footnote: '回收站目录:{path}',
173
202
  move: '移入回收站',
174
203
  moveBlockedLive: '移入回收站(会话已打开)',
@@ -177,7 +206,8 @@ window.__ModuleLoader__.load({
177
206
  undo: '撤销',
178
207
  undoFailed: '撤销失败',
179
208
  trashSuccess: '已将「{title}」移入回收站',
180
- trashSuccessTree: '「{title}」及其 {count} 个子会话已移入回收站',
209
+ trashSuccessTreeOne: '「{title}」及其 {count} 个子会话已移入回收站',
210
+ trashSuccessTreeOther: '「{title}」及其 {count} 个子会话已移入回收站',
181
211
  blockedLiveToast: '该会话已在本机打开。DSH 目前不会释放已打开的会话,请重启 DSH 后再移入回收站。',
182
212
  blockedActiveToast: '该会话还有正在运行的任务或子代理,等它停下来后再移入回收站。',
183
213
  blockedDescendantsToast: '该会话包含可见的子会话,只能使用 DSH 自带的「归档」整棵会话树。',
@@ -202,7 +232,7 @@ window.__ModuleLoader__.load({
202
232
  errorIndexCorrupt: '回收站索引损坏,已进入保护模式。',
203
233
  errorIndexOk: '回收站索引可正常读取,无需重建。',
204
234
  errorForgetNotAllowed: '只有文件缺失的记录可以从索引移除。',
205
- errorRevealUnavailable: '当前环境无法打开 Finder。',
235
+ errorRevealUnavailable: '当前环境无法打开文件管理器。',
206
236
  errorRevealFailed: '无法打开原工作区。',
207
237
  errorIo: '文件操作失败,请稍后重试。',
208
238
  }
@@ -215,7 +245,8 @@ window.__ModuleLoader__.load({
215
245
  retry: 'Retry',
216
246
  cleanupExpired: 'Delete expired',
217
247
  cleaning: 'Cleaning…',
218
- statsCount: '{count} sessions',
248
+ statsCountOne: '{count} session',
249
+ statsCountOther: '{count} sessions',
219
250
  statsSize: '{size} used',
220
251
  searchLabel: 'Search the trash',
221
252
  searchPlaceholder: 'Search title, Session ID, or original workspace',
@@ -265,7 +296,8 @@ window.__ModuleLoader__.load({
265
296
  badgeMigration: 'Migration required',
266
297
  badgeExpiring: 'Expiring soon',
267
298
  badgeExpired: 'Expired',
268
- badgeSubagents: '{count} sub-sessions',
299
+ badgeSubagentsOne: '{count} sub-session',
300
+ badgeSubagentsOther: '{count} sub-sessions',
269
301
  stateTrashing: 'Moving…',
270
302
  stateRestoring: 'Restoring…',
271
303
  statePurging: 'Deleting…',
@@ -273,7 +305,7 @@ window.__ModuleLoader__.load({
273
305
  actionRestoreFor: 'Restore “{title}”',
274
306
  actionMoreFor: 'More actions for “{title}”',
275
307
  actionRestoreOrphan: 'Restore as orphan',
276
- actionReveal: 'Show in Finder',
308
+ actionReveal: 'Show in file manager',
277
309
  actionRevealDisabled: 'The original workspace no longer exists',
278
310
  actionCopyId: 'Copy Session ID',
279
311
  actionForget: 'Remove from index',
@@ -290,23 +322,37 @@ window.__ModuleLoader__.load({
290
322
  purgeAllTitle: 'Permanently delete all {count} sessions in the trash?',
291
323
  purgeAllBody: 'This cannot be undone. Attachments, images, and search index rows are not deleted.',
292
324
  purgeAllConfirm: 'Delete all permanently',
293
- purgeAllDone: 'Permanently deleted {count} sessions',
325
+ purgeAllDoneOne: 'Permanently deleted {count} session',
326
+ purgeAllDoneOther: 'Permanently deleted {count} sessions',
294
327
  purgeAllNone: 'No sessions to delete',
295
- purgeAllPartial: 'Permanently deleted {removed} sessions; {failed} could not be deleted',
328
+ purgeAllPartial: 'Bulk delete finished: {removed} removed, {failed} failed.',
296
329
  forgetTitle: 'Remove this missing record from the trash list?',
297
330
  forgetBody: 'No file is deleted; only this index record is removed.',
298
331
  forgetConfirm: 'Remove from index',
299
- cleanupDone: 'Deleted {count} expired sessions',
332
+ cleanupDoneOne: 'Deleted {count} expired session',
333
+ cleanupDoneOther: 'Deleted {count} expired sessions',
300
334
  cleanupNone: 'No expired sessions to delete',
301
- cleanupFailedBanner: 'The last automatic cleanup could not delete {count} session(s).',
302
- cleanupErrorBanner: 'The last automatic cleanup failed: {message}',
335
+ cleanupFailedBannerOne: 'The last automatic cleanup could not delete {count} expired session.',
336
+ cleanupFailedBannerOther: 'The last automatic cleanup could not delete {count} expired sessions.',
337
+ cleanupErrorBanner: 'The last automatic cleanup failed. Retry from this page later.',
338
+ cleanupFailed: 'Cleanup could not delete {failed} of the expired sessions.',
339
+ cleanupPartial: 'Cleanup deleted {purged}; {failed} could not be deleted.',
303
340
  cleanupRetryAt: 'Next retry: {time}.',
304
341
  cleanupRetryNow: 'Retry now',
305
342
  copySuccess: 'Session ID copied',
306
343
  copyFailed: 'Could not copy the Session ID. Copy it manually.',
307
- revealSuccess: 'Revealed the original workspace in Finder',
344
+ revealSuccess: 'Revealed the original workspace in the file manager',
308
345
  revealFailed: 'Could not open the original workspace',
309
346
  lastErrorLabel: 'Last error: {message}',
347
+ lastErrorTrashCopyMissing: 'The stored copy of this session is missing from the trash.',
348
+ lastErrorTrashCopyMissingMembers: 'Some sub-session copies are missing from the trash.',
349
+ lastErrorTrashObjectMissing: 'The trash object directory is missing.',
350
+ lastErrorPurgeFailed: 'The session log could not be deleted.',
351
+ lastErrorRestoreInvisible: 'Session storage did not report the restored session.',
352
+ lastErrorMoveUnsettled: 'The session was moved, but session storage still reports it.',
353
+ lastErrorSourceConflict: 'The original session directory and the trash copy both exist; both were kept.',
354
+ lastErrorIndexWriteFailed: 'The trash index could not be written.',
355
+ lastErrorIo: 'A file operation failed.',
310
356
  footnote: 'Trash directory: {path}',
311
357
  move: 'Move to trash',
312
358
  moveBlockedLive: 'Move to trash (session is open)',
@@ -315,7 +361,8 @@ window.__ModuleLoader__.load({
315
361
  undo: 'Undo',
316
362
  undoFailed: 'Could not undo',
317
363
  trashSuccess: 'Moved “{title}” to the trash',
318
- trashSuccessTree: '“{title}” and its {count} sub-sessions were moved to the trash',
364
+ trashSuccessTreeOne: '“{title}” and its single sub-session were moved to the trash',
365
+ trashSuccessTreeOther: '“{title}” and its {count} sub-sessions were moved to the trash',
319
366
  blockedLiveToast: 'This session is open in DSH. DSH does not release opened sessions yet; restart DSH, then move it to the trash.',
320
367
  blockedActiveToast: 'This session still has running work; wait for it to stop, then move it to the trash.',
321
368
  blockedDescendantsToast: 'This session has visible child sessions; use the built-in Archive action for the whole tree instead.',
@@ -340,7 +387,7 @@ window.__ModuleLoader__.load({
340
387
  errorIndexCorrupt: 'The trash index is damaged, so protection mode is on.',
341
388
  errorIndexOk: 'The trash index is readable and does not need a rebuild.',
342
389
  errorForgetNotAllowed: 'Only records whose log is missing can be removed from the index.',
343
- errorRevealUnavailable: 'This environment cannot open Finder.',
390
+ errorRevealUnavailable: 'This environment cannot open a file manager.',
344
391
  errorRevealFailed: 'Could not open the original workspace.',
345
392
  errorIo: 'A file operation failed. Try again.',
346
393
  }
@@ -359,11 +406,26 @@ window.__ModuleLoader__.load({
359
406
  }
360
407
 
361
408
  /**
362
- * @param {number} bytes raw byte count.
363
- * @returns {string} a compact human-readable size.
409
+ * Pick the grammatical singular/plural locale form for a count.
410
+ *
411
+ * @param {(key: string) => string} t bound translator.
412
+ * @param {string} key base locale key (without the One/Other suffix).
413
+ * @param {number} count item count.
414
+ * @param {Record<string, unknown>} [values] extra placeholders.
415
+ * @returns {string} the formatted text.
416
+ */
417
+ function countText(t, key, count, values = {}) {
418
+ const template = t(`${key}${count === 1 ? 'One' : 'Other'}`)
419
+ return format(template, { ...values, count })
420
+ }
421
+
422
+ /**
423
+ * @param {number | null | undefined} bytes raw byte count.
424
+ * @returns {string} a compact human-readable size, or an em dash when unknown.
364
425
  */
365
426
  function formatBytes(bytes) {
366
- if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B'
427
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return '—'
428
+ if (bytes <= 0) return '0 B'
367
429
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
368
430
  let value = bytes
369
431
  let unit = 0
@@ -827,8 +889,14 @@ window.__ModuleLoader__.load({
827
889
  if (wrapRef.current !== null && !wrapRef.current.contains(event.target)) setOpen(false)
828
890
  }
829
891
  const onKeyDown = (event) => {
892
+ if (event.key === 'Tab') {
893
+ // Let focus leave the menu naturally; just stop leaving it open.
894
+ setOpen(false)
895
+ return
896
+ }
830
897
  if (event.key !== 'Escape') return
831
898
  event.preventDefault()
899
+ event.stopPropagation()
832
900
  setOpen(false)
833
901
  if (buttonRef.current !== null) buttonRef.current.focus()
834
902
  }
@@ -930,9 +998,11 @@ window.__ModuleLoader__.load({
930
998
  },
931
999
  items.map((item, index) =>
932
1000
  h(
933
- 'div',
1001
+ React.Fragment,
934
1002
  { key: item.key },
935
- item.separatorBefore === true && index > 0 ? h('div', { className: 'dsh-trash__menuSeparator' }) : null,
1003
+ item.separatorBefore === true && index > 0
1004
+ ? h('div', { className: 'dsh-trash__menuSeparator', role: 'separator' })
1005
+ : null,
936
1006
  h(
937
1007
  'button',
938
1008
  {
@@ -972,7 +1042,7 @@ window.__ModuleLoader__.load({
972
1042
  * }} props dialog content and handlers.
973
1043
  * @returns {import('react').ReactElement} the dialog overlay.
974
1044
  */
975
- function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm }) {
1045
+ function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm, fallbackRef }) {
976
1046
  const dialogRef = React.useRef(null)
977
1047
  const cancelRef = React.useRef(null)
978
1048
  const titleId = `dsh-trash-dialog-title-${kind}`
@@ -1006,7 +1076,12 @@ window.__ModuleLoader__.load({
1006
1076
  document.addEventListener('keydown', onKeyDown, true)
1007
1077
  return () => {
1008
1078
  document.removeEventListener('keydown', onKeyDown, true)
1009
- if (previous !== null && typeof previous.focus === 'function') previous.focus()
1079
+ if (previous !== null && typeof previous.focus === 'function' && previous.isConnected !== false) {
1080
+ previous.focus()
1081
+ return
1082
+ }
1083
+ const fallback = fallbackRef === null || fallbackRef === undefined ? null : fallbackRef.current
1084
+ if (fallback !== null && typeof fallback.focus === 'function') fallback.focus()
1010
1085
  }
1011
1086
  }, [onCancel])
1012
1087
 
@@ -1090,6 +1165,7 @@ window.__ModuleLoader__.load({
1090
1165
  const [rebuilding, setRebuilding] = React.useState(false)
1091
1166
  const [purgingAll, setPurgingAll] = React.useState(false)
1092
1167
  const [announcement, setAnnouncement] = React.useState('')
1168
+ const [clock, setClock] = React.useState(() => Date.now())
1093
1169
  const [query, setQuery] = React.useState('')
1094
1170
  const [filter, setFilter] = React.useState('all')
1095
1171
  const [page, setPage] = React.useState(1)
@@ -1149,6 +1225,19 @@ window.__ModuleLoader__.load({
1149
1225
  })
1150
1226
  }
1151
1227
 
1228
+ /**
1229
+ * @param {string} sessionId row whose owner-facing error is resolved.
1230
+ * @returns {void}
1231
+ */
1232
+ function clearRowError(sessionId) {
1233
+ setRowErrors((previous) => {
1234
+ if (!(sessionId in previous)) return previous
1235
+ const next = { ...previous }
1236
+ delete next[sessionId]
1237
+ return next
1238
+ })
1239
+ }
1240
+
1152
1241
  /**
1153
1242
  * Open an abort scope for one write. It is cancelled by a hard timeout
1154
1243
  * and by the unmount cleanup, so a stuck request can never wedge the
@@ -1283,6 +1372,15 @@ window.__ModuleLoader__.load({
1283
1372
  }
1284
1373
  }, [load])
1285
1374
 
1375
+ // Keep relative expiry text honest on a page left open for a long time.
1376
+ React.useEffect(() => {
1377
+ if (typeof window === 'undefined' || typeof window.setInterval !== 'function') return undefined
1378
+ const handle = window.setInterval(() => setClock(Date.now()), 60_000)
1379
+ return () => {
1380
+ if (typeof window.clearInterval === 'function') window.clearInterval(handle)
1381
+ }
1382
+ }, [])
1383
+
1286
1384
  const data = view.data
1287
1385
  const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
1288
1386
  entries.sort((left, right) => (right.trashedAt ?? 0) - (left.trashedAt ?? 0))
@@ -1323,7 +1421,7 @@ window.__ModuleLoader__.load({
1323
1421
  const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
1324
1422
  const recovered = data !== null && data.index !== undefined && data.index.status === 'recovered'
1325
1423
  const unindexed = data !== null && data.index !== undefined && typeof data.index.unindexed === 'number' ? data.index.unindexed : 0
1326
- const now = Date.now()
1424
+ const now = clock
1327
1425
  const normalizedQuery = query.trim().toLowerCase()
1328
1426
 
1329
1427
  /**
@@ -1356,6 +1454,9 @@ window.__ModuleLoader__.load({
1356
1454
  const filteredEntries = entries.filter((entry) => matchesQuery(entry) && matchesFilter(entry))
1357
1455
  const pageCount = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
1358
1456
  const currentPage = Math.min(Math.max(1, page), pageCount)
1457
+ React.useEffect(() => {
1458
+ if (page > pageCount) setPage(pageCount)
1459
+ }, [page, pageCount])
1359
1460
  const pageStart = (currentPage - 1) * PAGE_SIZE
1360
1461
  const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
1361
1462
  const filtersActive = normalizedQuery !== '' || filter !== 'all'
@@ -1444,6 +1545,7 @@ window.__ModuleLoader__.load({
1444
1545
  session !== null && session !== undefined && typeof session.title === 'string' && session.title !== ''
1445
1546
  ? session.title
1446
1547
  : entry.title
1548
+ clearRowError(entry.sessionId)
1447
1549
  const message = format(t(orphan ? 'restoreOrphanSuccess' : 'restoreSuccess'), { title })
1448
1550
  bumpRevision()
1449
1551
  ctx.contextMenus.notify(message, true)
@@ -1472,6 +1574,7 @@ window.__ModuleLoader__.load({
1472
1574
  fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1473
1575
  return
1474
1576
  }
1577
+ clearRowError(entry.sessionId)
1475
1578
  const message = format(t('purgeSuccess'), { title: entry.title })
1476
1579
  bumpRevision()
1477
1580
  ctx.contextMenus.notify(message, true)
@@ -1500,6 +1603,7 @@ window.__ModuleLoader__.load({
1500
1603
  fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1501
1604
  return
1502
1605
  }
1606
+ clearRowError(entry.sessionId)
1503
1607
  bumpRevision()
1504
1608
  ctx.contextMenus.notify(t('forgetSuccess'), true)
1505
1609
  announce(t('forgetSuccess'))
@@ -1527,6 +1631,7 @@ window.__ModuleLoader__.load({
1527
1631
  fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1528
1632
  return
1529
1633
  }
1634
+ clearRowError(entry.sessionId)
1530
1635
  ctx.contextMenus.notify(t('revealSuccess'), true)
1531
1636
  announce(t('revealSuccess'))
1532
1637
  } finally {
@@ -1563,13 +1668,15 @@ window.__ModuleLoader__.load({
1563
1668
  ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1564
1669
  return
1565
1670
  }
1566
- const purged =
1567
- outcome.payload !== null && outcome.payload !== undefined && typeof outcome.payload.purged === 'number'
1568
- ? outcome.payload.purged
1569
- : 0
1570
- const message = purged > 0 ? format(t('cleanupDone'), { count: purged }) : t('cleanupNone')
1671
+ const payload = outcome.payload
1672
+ const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
1673
+ const failed = payload !== null && payload !== undefined && typeof payload.failed === 'number' ? payload.failed : 0
1674
+ let message
1675
+ if (failed > 0 && purged > 0) message = format(t('cleanupPartial'), { purged, failed })
1676
+ else if (failed > 0) message = format(t('cleanupFailed'), { failed })
1677
+ else message = purged > 0 ? countText(t, 'cleanupDone', purged) : t('cleanupNone')
1571
1678
  bumpRevision()
1572
- ctx.contextMenus.notify(message, true)
1679
+ ctx.contextMenus.notify(message, failed === 0)
1573
1680
  announce(message)
1574
1681
  } finally {
1575
1682
  endWrite()
@@ -1606,7 +1713,7 @@ window.__ModuleLoader__.load({
1606
1713
  failed > 0
1607
1714
  ? format(t('purgeAllPartial'), { removed, failed })
1608
1715
  : removed > 0
1609
- ? format(t('purgeAllDone'), { count: removed })
1716
+ ? countText(t, 'purgeAllDone', removed)
1610
1717
  : t('purgeAllNone')
1611
1718
  bumpRevision()
1612
1719
  ctx.contextMenus.notify(message, failed === 0)
@@ -1741,7 +1848,7 @@ window.__ModuleLoader__.load({
1741
1848
  if (subagentCount > 0) {
1742
1849
  badges.push({
1743
1850
  key: 'subagents',
1744
- label: format(t('badgeSubagents'), { count: subagentCount }),
1851
+ label: countText(t, 'badgeSubagents', subagentCount),
1745
1852
  tone: 'info',
1746
1853
  })
1747
1854
  }
@@ -1827,9 +1934,7 @@ window.__ModuleLoader__.load({
1827
1934
  const retryText =
1828
1935
  cleanupRetryAt !== null ? format(t('cleanupRetryAt'), { time: formatDate(cleanupRetryAt, locale, true) }) : ''
1829
1936
  const cleanupMessage =
1830
- cleanupFailed > 0
1831
- ? format(t('cleanupFailedBanner'), { count: cleanupFailed })
1832
- : format(t('cleanupErrorBanner'), { message: cleanupError })
1937
+ cleanupFailed > 0 ? countText(t, 'cleanupFailedBanner', cleanupFailed) : t('cleanupErrorBanner')
1833
1938
  banners.push(
1834
1939
  h(
1835
1940
  'div',
@@ -1837,7 +1942,7 @@ window.__ModuleLoader__.load({
1837
1942
  h(TrashGlyph, { size: 16 }),
1838
1943
  h(
1839
1944
  'span',
1840
- { className: 'dsh-trash__bannerText' },
1945
+ { className: 'dsh-trash__bannerText', title: cleanupError ?? undefined },
1841
1946
  cleanupMessage,
1842
1947
  retryText === '' ? null : ` ${retryText}`,
1843
1948
  ),
@@ -1882,7 +1987,7 @@ window.__ModuleLoader__.load({
1882
1987
  h(
1883
1988
  'span',
1884
1989
  { className: 'dsh-trash__stats' },
1885
- format(t('statsCount'), { count: entries.length }),
1990
+ countText(t, 'statsCount', entries.length),
1886
1991
  h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
1887
1992
  format(t('statsSize'), { size: formatBytes(totalBytes) }),
1888
1993
  h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
@@ -2047,7 +2152,14 @@ window.__ModuleLoader__.load({
2047
2152
  { className: 'dsh-trash__list', 'aria-label': t('listLabel') },
2048
2153
  visibleEntries.map((entry) => {
2049
2154
  const rowBusy = pendingRows.has(entry.sessionId)
2050
- const error = rowErrors[entry.sessionId] ?? entry.lastError
2155
+ const rowError = rowErrors[entry.sessionId]
2156
+ const lastErrorCode = typeof entry.lastErrorCode === 'string' ? entry.lastErrorCode : null
2157
+ const lastErrorText =
2158
+ typeof entry.lastError === 'string' && entry.lastError !== ''
2159
+ ? t(LAST_ERROR_KEYS[lastErrorCode] ?? 'lastErrorIo')
2160
+ : null
2161
+ const error = rowError ?? lastErrorText
2162
+ const errorTitle = rowError === undefined && typeof entry.lastError === 'string' ? entry.lastError : undefined
2051
2163
  const title = entry.title !== undefined && entry.title !== '' ? entry.title : entry.sessionId
2052
2164
  return h(
2053
2165
  'li',
@@ -2087,7 +2199,11 @@ window.__ModuleLoader__.load({
2087
2199
  h('span', null, formatBytes(entry.sizeBytes)),
2088
2200
  ),
2089
2201
  error !== null && error !== undefined && error !== ''
2090
- ? h('div', { className: 'dsh-trash__rowError', role: 'status' }, format(t('lastErrorLabel'), { message: error }))
2202
+ ? h(
2203
+ 'div',
2204
+ { className: 'dsh-trash__rowError', role: 'status', title: errorTitle },
2205
+ format(t('lastErrorLabel'), { message: error }),
2206
+ )
2091
2207
  : null,
2092
2208
  ),
2093
2209
  h(
@@ -2166,6 +2282,7 @@ window.__ModuleLoader__.load({
2166
2282
  className: 'dsh-trash__panel',
2167
2283
  key: 'panel',
2168
2284
  ref: panelRef,
2285
+ tabIndex: -1,
2169
2286
  'aria-busy': readsBusy ? 'true' : 'false',
2170
2287
  },
2171
2288
  panelBody,
@@ -2186,6 +2303,7 @@ window.__ModuleLoader__.load({
2186
2303
  count: dialog.count,
2187
2304
  t,
2188
2305
  onCancel: closeDialog,
2306
+ fallbackRef: panelRef,
2189
2307
  onConfirm: () => {
2190
2308
  const current = dialog
2191
2309
  setDialog(null)
@@ -2252,7 +2370,7 @@ window.__ModuleLoader__.load({
2252
2370
  : 0
2253
2371
  const message =
2254
2372
  subagentCount > 0
2255
- ? format(t('trashSuccessTree'), { title, count: subagentCount })
2373
+ ? countText(t, 'trashSuccessTree', subagentCount, { title })
2256
2374
  : format(t('trashSuccess'), { title })
2257
2375
  ctx.contextMenus.notify(message, true, {
2258
2376
  label: t('undo'),
package/index.js CHANGED
@@ -34,7 +34,7 @@ function createLogger(ctx) {
34
34
  const prefix = 'dsh-session-trash'
35
35
  let sink = null
36
36
  try {
37
- const factory = ctx.get('logger')
37
+ const factory = /** @type {any} */ (ctx.get('logger'))
38
38
  if (typeof factory === 'function') {
39
39
  const instance = factory(prefix)
40
40
  if (instance !== null && instance !== undefined && typeof instance.info === 'function') sink = instance
@@ -65,7 +65,7 @@ function createLogger(ctx) {
65
65
  * recovery, and the retention sweep.
66
66
  *
67
67
  * @param {import('@deepseek-ai/cordis').Context} ctx plugin context.
68
- * @param {object} config validated plugin configuration.
68
+ * @param {any} config validated plugin configuration.
69
69
  * @returns {void}
70
70
  */
71
71
  export function apply(ctx, config) {
package/lib/cleanup.js CHANGED
@@ -14,7 +14,7 @@ export const CLEANUP_RETRY_MAX_MS = 60 * 60 * 1000
14
14
  const STOPPED_RESULT = Object.freeze({ purged: 0, failed: 0, settled: 0 })
15
15
 
16
16
  /**
17
- * @param {unknown} error arbitrary failure.
17
+ * @param {any} error arbitrary failure.
18
18
  * @returns {string} a concise message.
19
19
  */
20
20
  function messageOf(error) {
@@ -22,7 +22,7 @@ function messageOf(error) {
22
22
  }
23
23
 
24
24
  /**
25
- * @param {unknown} error arbitrary failure.
25
+ * @param {any} error arbitrary failure.
26
26
  * @returns {string} a bounded diagnostic message for `/state`.
27
27
  */
28
28
  function errorText(error) {
@@ -60,18 +60,18 @@ function resolveTimer(ctx) {
60
60
  *
61
61
  * @param {{
62
62
  * ctx: import('@deepseek-ai/cordis').Context,
63
- * trashService: { sweep: (options: { allowPurge: boolean, now: number, reason: string }) => Promise<object> },
63
+ * trashService: { sweep: (options: { allowPurge: boolean, now: number, reason: string }) => Promise<any> },
64
64
  * config: { sweepIntervalHours: number, enableAutoCleanup: boolean },
65
65
  * logger: { info: Function, warn: Function, error: Function, debug: Function },
66
66
  * }} deps host services and the configured cadence.
67
67
  * @returns {{
68
68
  * start: () => void,
69
69
  * stop: () => void,
70
- * runNow: (options?: { manual?: boolean, reason?: string }) => Promise<object>,
70
+ * runNow: (options?: { manual?: boolean, reason?: string }) => Promise<any>,
71
71
  * maybeSweepOnPageOpen: () => void,
72
72
  * nextSweepAt: () => number | null,
73
73
  * lastSweepAt: () => number,
74
- * diagnostics: () => object,
74
+ * diagnostics: () => any,
75
75
  * }} the cleanup service.
76
76
  */
77
77
  export function createCleanupService({ ctx, trashService, config, logger }) {
@@ -91,7 +91,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
91
91
  let disposeRetry = null
92
92
 
93
93
  /**
94
- * @param {unknown} error sweep failure.
94
+ * @param {any} error sweep failure.
95
95
  * @returns {void}
96
96
  */
97
97
  function report(error) {
@@ -148,7 +148,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
148
148
  * because an automatic sweep may have been started with `allowPurge: false`.
149
149
  *
150
150
  * @param {{ manual?: boolean, reason?: string }} [options] manual trigger and diagnostic label.
151
- * @returns {Promise<object>} the sweep summary.
151
+ * @returns {Promise<any>} the sweep summary.
152
152
  */
153
153
  async function runNow(options = {}) {
154
154
  if (stopped) return { ...STOPPED_RESULT }
@@ -324,7 +324,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
324
324
  /**
325
325
  * Read-only cleanup diagnostics for `/state` and the settings page.
326
326
  *
327
- * @returns {object} a JSON-safe diagnostic snapshot.
327
+ * @returns {any} a JSON-safe diagnostic snapshot.
328
328
  */
329
329
  diagnostics() {
330
330
  return {
@@ -7,14 +7,14 @@ export const PREFLIGHT_TTL_MS = 2000
7
7
 
8
8
  /**
9
9
  * @typedef {{ sessionId: string, header: object }} DescendantRef
10
- * @typedef {{ sessionId: string, header: object, location: { path: string }, formatStatus?: string }} PlannedMember
10
+ * @typedef {{ sessionId: string, header: any, location: { path: string }, formatStatus?: string }} PlannedMember
11
11
  * @typedef {{ eligible: true, members: PlannedMember[] } | { eligible: false, code: string, message: string, details?: object }} PreflightResult
12
12
  */
13
13
 
14
14
  /**
15
15
  * @param {string} code refusal code.
16
16
  * @param {string} message owner-facing explanation.
17
- * @param {object} [details] optional structured context.
17
+ * @param {any} [details] optional structured context.
18
18
  * @returns {{ eligible: false, code: string, message: string, details?: object }} a refusal.
19
19
  */
20
20
  function refuse(code, message, details) {
@@ -24,7 +24,7 @@ function refuse(code, message, details) {
24
24
  /**
25
25
  * Collect every stored session whose lineage descends from `sessionId`.
26
26
  *
27
- * @param {import('@deepseek-ai/dsh-session-persistence').SessionPersistence} persistence persistence service.
27
+ * @param {any} persistence persistence service.
28
28
  * @param {string} sessionId root session.
29
29
  * @returns {Promise<DescendantRef[]>} descendant identity and stored header.
30
30
  */
@@ -59,7 +59,7 @@ async function descendantsFromListing(persistence, sessionId) {
59
59
  * A grandchild whose direct parent is no longer stored still proves that the
60
60
  * root session owns a lineage the trash must not split.
61
61
  *
62
- * @param {readonly object[]} nodes descendant nodes from the trace.
62
+ * @param {readonly any[]} nodes descendant nodes from the trace.
63
63
  * @returns {DescendantRef[]} every descendant session identity and header.
64
64
  */
65
65
  function collectTraceDescendants(nodes) {
@@ -123,7 +123,7 @@ export function createEligibility({ ctx, store, logger }) {
123
123
 
124
124
  /**
125
125
  * @param {string} sessionId descendant session to validate.
126
- * @returns {Promise<object>} either `{ member }` with the planned member or `{ refusal }`.
126
+ * @returns {Promise<any>} either `{ member }` with the planned member or `{ refusal }`.
127
127
  */
128
128
  async function planDescendant(sessionId) {
129
129
  let snapshot