dsh-sessions-manager 3.5.0 → 3.5.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sessions-manager",
3
3
  "description": "DSH 设置面板会话管理器:归档 / 恢复 / 彻底删除 / 移动到其他工作区,带工作区标签与会话日期;统一「会话管理」面板。Session manager for the DeepSeek Harness settings panel — archive / restore / permanently delete / move sessions across workspaces, with workspace tags & session dates in one unified panel.",
4
- "version": "3.5.0",
4
+ "version": "3.5.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useEffect, useMemo, useRef, useState } from 'react'
14
- import { canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
14
+ import { authoritativeTitleForFirstPaint, canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
15
15
 
16
16
  export const inject = ['slots']
17
17
 
@@ -21,12 +21,12 @@ function loadPanelPrefs() {
21
21
  }
22
22
 
23
23
  const CSS = `
24
- .archv{--dsm-radius-tag:4px;display:flex;flex-direction:column;gap:4px;max-width:800px;padding:8px 2px 28px}
24
+ .archv{--dsm-radius-tag:4px;--dsm-radius-ctl:9px;--dsm-radius-sheet:10px;--dsm-radius-card:12px;display:flex;flex-direction:column;gap:4px;max-width:800px;padding:8px 2px 28px}
25
25
  .archv-head{display:flex;align-items:center;gap:10px;margin:0 0 2px}
26
26
  .archv-title{font-size:16px;font-weight:650;color:var(--dsw-alias-label-primary);letter-spacing:-0.01em;margin:0}
27
27
  .archv-count{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;flex:none}
28
28
  .archv-sub{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-tertiary);margin:0 0 12px;max-width:64ch}
29
- .archv-err{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);border-radius:10px;color:var(--dsw-alias-state-error-primary);font-size:12px;margin-bottom:10px}
29
+ .archv-err{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);border-radius:var(--dsm-radius-sheet);color:var(--dsw-alias-state-error-primary);font-size:12px;margin-bottom:10px}
30
30
  .archv-errretry{appearance:none;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent);background:transparent;color:inherit;border-radius:8px;padding:4px 10px;font-size:11px;cursor:pointer;flex:none}
31
31
  .sess-filter{display:flex;align-items:center;gap:6px;margin:0 0 12px}
32
32
  .sess-fbtn{appearance:none;min-height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:999px;font-size:12px;font-weight:500;cursor:pointer}
@@ -35,13 +35,13 @@ const CSS = `
35
35
  .sess-tools{display:grid;grid-template-columns:minmax(180px,1fr) minmax(140px,.7fr) minmax(130px,.55fr);gap:8px;margin:0 0 8px}
36
36
  .sess-field{display:flex;flex-direction:column;gap:5px;min-width:0}
37
37
  .sess-field label{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary)}
38
- .sess-field input,.sess-field select{box-sizing:border-box;width:100%;min-height:36px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
38
+ .sess-field input,.sess-field select{box-sizing:border-box;width:100%;min-height:36px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-ctl);background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
39
39
  .sess-results{font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0 0 4px}
40
40
  .archv button:focus-visible,.archv input:focus-visible,.archv select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
41
41
  .sess-batch{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 2px 4px;margin-bottom:4px}
42
42
  .sess-btntext{font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none}
43
43
  .archv-list{display:flex;flex-direction:column;gap:8px}
44
- .archv-card{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-fill-elevated);transition:border-color .15s ease,background-color .15s ease}
44
+ .archv-card{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-card);background:var(--dsw-alias-fill-elevated);transition:border-color .15s ease,background-color .15s ease}
45
45
  .archv-card:hover{border-color:var(--dsw-alias-border-l4)}
46
46
  .archv-card-exp{border-color:var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1)}
47
47
  .archv-row{display:flex;align-items:center;gap:14px;width:100%;min-width:0}
@@ -62,16 +62,16 @@ const CSS = `
62
62
  .archv-star-on svg{fill:currentColor}
63
63
  .archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
64
64
  .archv-actions{display:flex;gap:8px;flex:none;flex-wrap:nowrap;justify-content:flex-end}
65
- .archv-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:9px;font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:6px;text-align:center;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
65
+ .archv-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:var(--dsm-radius-ctl);font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:6px;text-align:center;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
66
66
  .archv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
67
67
  .archv-btn:disabled{opacity:.5;cursor:default}
68
68
  .archv-del{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent)}
69
69
  .archv-del:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
70
70
  .archv-go{color:var(--dsw-alias-state-business-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,transparent)}
71
71
  .archv-go:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
72
- .archv-empty{display:flex;align-items:center;gap:10px;padding:20px 14px;border:1px dashed var(--dsw-alias-border-l3);border-radius:12px;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
72
+ .archv-empty{display:flex;align-items:center;gap:10px;padding:20px 14px;border:1px dashed var(--dsw-alias-border-l3);border-radius:var(--dsm-radius-card);color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
73
73
  .archv-skel{display:flex;flex-direction:column;gap:8px}
74
- .archv-skel-card{height:58px;border-radius:12px;background:var(--dsw-alias-fill-subtle);position:relative;overflow:hidden}
74
+ .archv-skel-card{height:58px;border-radius:var(--dsm-radius-card);background:var(--dsw-alias-fill-subtle);position:relative;overflow:hidden}
75
75
  .archv-skel-card::after{content:'';position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--dsw-alias-fill-elevated) 75%,transparent),transparent);animation:archv-shimmer 1.4s infinite}
76
76
  .archv-status{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:60;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgb(0 0 0/.18);display:flex;align-items:center;gap:8px;animation:archv-pop .18s ease-out;max-width:min(90vw,420px)}
77
77
  .archv-spin{width:12px;height:12px;border:2px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent);border-top-color:var(--dsw-alias-label-secondary);border-radius:50%;animation:archv-rot .8s linear infinite;flex:none}
@@ -80,24 +80,24 @@ const CSS = `
80
80
  @keyframes archv-pop{from{opacity:0;transform:translateX(-50%) translateY(10px)}}
81
81
  @media (prefers-reduced-motion:reduce){.archv-skel-card::after{animation:none}.archv-card,.archv-btn{transition:none}.archv-status,.archv-spin{animation:none}}
82
82
  @media (max-width:640px){.archv-card{flex-direction:column;align-items:stretch;gap:10px}.archv-actions{justify-content:flex-end}.sess-tools{grid-template-columns:1fr}.sess-fbtn,.archv-btn{min-height:40px}}
83
- .mv-sheet{width:100%;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-fill-subtle)}
83
+ .mv-sheet{width:100%;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-sheet);background:var(--dsw-alias-fill-subtle)}
84
84
  .mv-sheet-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
85
85
  .mv-sheet-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}
86
86
  .mv-sheet-close{appearance:none;width:26px;height:26px;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-radius:7px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:16px;line-height:1}
87
87
  .mv-sheet-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
88
- .mv-seg{display:flex;gap:2px;padding:2px;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;width:100%}
88
+ .mv-seg{display:flex;gap:2px;padding:2px;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-sheet);width:100%}
89
89
  .mv-segbtn{appearance:none;flex:1;min-height:30px;padding:0 12px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px}
90
90
  .mv-segbtn:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
91
91
  .mv-segbtn-on{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgb(0 0 0/.08)}
92
92
  .mv-field{display:flex;flex-direction:column;gap:6px}
93
93
  .mv-field label.mv-field-label{font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary)}
94
- .mv-field select,.mv-field input[type=text]{box-sizing:border-box;appearance:none;width:100%;min-height:34px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit}
94
+ .mv-field select,.mv-field input[type=text]{box-sizing:border-box;appearance:none;width:100%;min-height:34px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-ctl);background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit}
95
95
  .mv-field select:focus-visible,.mv-field input[type=text]:focus-visible,.mv-sheet-close:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
96
96
  .mv-browse-row{display:flex;align-items:center;gap:8px}
97
97
  .mv-browse-row input[type=text]{flex:1;min-width:0}
98
98
  .mv-foot{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:2px}
99
99
  @media (max-width:640px){.archv-row{flex-wrap:wrap}.mv-sheet{padding:12px}}
100
- .dtl-sheet{margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-fill-subtle)}
100
+ .dtl-sheet{margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-sheet);background:var(--dsw-alias-fill-subtle)}
101
101
  .dtl-sheet-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
102
102
  .dtl-sheet-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}
103
103
  .dtl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:10px}
@@ -118,7 +118,7 @@ const CSS = `
118
118
  .more-wrap{position:relative;flex:none}
119
119
  .more-btn{appearance:none;width:28px;height:28px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;line-height:1}
120
120
  .more-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
121
- .more-menu{position:absolute;top:calc(100% + 4px);right:0;z-index:60;min-width:160px;padding:5px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:10px;box-shadow:0 10px 32px rgb(0 0 0/.24);display:flex;flex-direction:column;gap:1px}
121
+ .more-menu{position:absolute;top:calc(100% + 4px);right:0;z-index:60;min-width:160px;padding:5px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:var(--dsm-radius-sheet);box-shadow:0 10px 32px rgb(0 0 0/.24);display:flex;flex-direction:column;gap:1px}
122
122
  .more-item{appearance:none;display:flex;align-items:center;gap:8px;width:100%;padding:7px 10px;border:none;background:transparent;color:var(--dsw-alias-label-primary);border-radius:7px;font-size:12.5px;cursor:pointer;white-space:nowrap;text-align:left}
123
123
  .more-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
124
124
  .more-item-danger{color:var(--dsw-alias-state-error-primary)}
@@ -396,9 +396,14 @@ function SessionPanel({ workspacesSvc }) {
396
396
  const w = workspaces.find((x) => x.workspaceId === id)
397
397
  return w ? w.path : ''
398
398
  }
399
- const actionCapability = (name) => (capabilities && capabilities.actions && capabilities.actions[name]) || { available: false, reason: '正在检查当前 DSH 的兼容能力…' }
400
- const canPurge = actionCapability('purge')
401
- const canMove = actionCapability('move')
399
+ // 能力读取:优先 v3.5.2 规范键名,回退旧键名(旧 host / 缓存响应兼容)。
400
+ const actionCapability = (name, legacy) => {
401
+ const source = capabilities && capabilities.actions
402
+ return (source && (source[name] || (legacy && source[legacy]))) || { available: false, reason: '正在检查当前 DSH 的兼容能力…' }
403
+ }
404
+ const canPurge = actionCapability('physicalPurge', 'purge')
405
+ const canMove = actionCapability('relocateSession', 'move')
406
+ const canRestoreTrash = actionCapability('restoreIndexedSession', 'restoreTrash')
402
407
 
403
408
  const archivedList = sessions ? sessions.filter((x) => x.archived) : []
404
409
  const activeList = sessions ? sessions.filter((x) => !x.archived) : []
@@ -533,7 +538,12 @@ function SessionPanel({ workspacesSvc }) {
533
538
  if (trashBusy) return
534
539
  setTrashBusy(sid)
535
540
  postJSON('/archived-sessions/trash/restore', { sessionId: sid })
536
- .then(() => { setTrashBusy(null); showToast('已恢复会话'); loadTrash(); refresh() })
541
+ .then((r) => {
542
+ setTrashBusy(null)
543
+ showToast(r && r.workspaceGone ? '已恢复会话(原工作区已删除,会话暂归「未分组」)' : '已恢复会话')
544
+ loadTrash()
545
+ refresh()
546
+ })
537
547
  .catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
538
548
  }
539
549
  const purgeTrash = (sid) => {
@@ -1052,9 +1062,14 @@ function SessionPanel({ workspacesSvc }) {
1052
1062
  </select>
1053
1063
  </div>
1054
1064
  <div className="sess-field"><label>数据检查</label><button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={verifyTrash}>{trashBusy === '__verify' ? '校验中…' : '校验日志完整性'}</button></div>
1055
- <div className="sess-field"><label>永久清理</label><button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !trash.length || !canPurge.available} title={!canPurge.available ? canPurge.reason : undefined} onClick={() => setPurgeTarget('__all')}>清空回收站</button></div>
1065
+ <div className="sess-field"><label>永久清理</label><button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !trash.length || !canPurge.available} aria-disabled={!canPurge.available} title={!canPurge.available ? canPurge.reason : '物理删除这些日志,释放磁盘空间'} onClick={() => setPurgeTarget('__all')}>清空回收站</button></div>
1056
1066
  </div>
1057
- {!canPurge.available && <div className="archv-empty" role="status">{canPurge.reason}</div>}
1067
+ {!canPurge.available && (
1068
+ <div className="archv-empty" role="status">
1069
+ {canPurge.reason}。回收站条目会一直保留,可随时恢复;当前 DSH 版本下移入回收站<b>不会释放磁盘空间</b>,会话日志仍完整保留在原工作区目录。
1070
+ </div>
1071
+ )}
1072
+ <div className="dtl-note">回收站是软删除:日志仍留在原工作区目录,「彻底删除」才是真正释放磁盘空间的一步(当前版本未支持时会保持禁用)。</div>
1058
1073
  {trashCheck && <div className={trashCheck.missing ? 'archv-err' : 'archv-empty'} role="status">校验完成:{trashCheck.healthy} 条正常,{trashCheck.missing} 条日志缺失,{trashCheck.unverified || 0} 条无法直接核验。</div>}
1059
1074
  {trash.length === 0 ? (
1060
1075
  <div className="dsm-trash-empty">回收站为空。删除的会话会先进入这里,可恢复或彻底删除。</div>
@@ -1065,7 +1080,7 @@ function SessionPanel({ workspacesSvc }) {
1065
1080
  <span className="dsm-trash-name" title={t.sessionId}>{t.title || t.sessionId}</span>
1066
1081
  <span className="dsm-trash-date">{fmtDate(t.deletedAt)}</span>
1067
1082
  <span className="dsm-trash-actions">
1068
- <button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={() => restoreTrash(t.sessionId)}>恢复</button>
1083
+ <button type="button" className="archv-btn" disabled={trashBusy !== null || !canRestoreTrash.available} title={!canRestoreTrash.available ? canRestoreTrash.reason : '恢复到删除前的位置与归档状态'} onClick={() => restoreTrash(t.sessionId)}>恢复</button>
1069
1084
  <button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !canPurge.available} title={!canPurge.available ? canPurge.reason : undefined} onClick={() => setPurgeTarget(t.sessionId)}>彻底删除</button>
1070
1085
  </span>
1071
1086
  </div>
@@ -1077,7 +1092,7 @@ function SessionPanel({ workspacesSvc }) {
1077
1092
  <div className="dlg-backdrop" onClick={() => setPurgeTarget(null)}>
1078
1093
  <div ref={dialogRef} className="dlg" role="alertdialog" aria-modal="true" aria-labelledby="dsm-purge-title" onClick={(e) => e.stopPropagation()}>
1079
1094
  <h3 id="dsm-purge-title" className="dlg-title">永久删除{purgeTarget === '__all' ? '全部回收站会话' : '这个会话'}?</h3>
1080
- <p className="dlg-text">此操作会物理删除日志,无法恢复。归档、筛选和重新安装插件都不能找回这些数据。</p>
1095
+ <p className="dlg-text">此操作会物理删除日志并释放磁盘空间,无法恢复。归档、筛选和重新安装插件都不能找回这些数据。</p>
1081
1096
  <div className="dlg-actions">
1082
1097
  <button type="button" className="archv-btn" onClick={() => setPurgeTarget(null)}>取消</button>
1083
1098
  <button type="button" className="archv-btn archv-del" onClick={() => { const target = purgeTarget; setPurgeTarget(null); target === '__all' ? purgeAllTrash() : purgeTrash(target) }}>确认永久删除</button>
@@ -1129,7 +1144,7 @@ const SIDEBAR_AUG_CSS = `
1129
1144
  .dsm-sub-cur{font-size:11px;color:var(--dsw-alias-state-business-primary);flex:none;margin-left:8px}
1130
1145
  .dsm-dot{position:absolute;left:6px;top:50%;transform:translateY(-50%);width:8px;height:8px;border-radius:50%;box-sizing:border-box;cursor:pointer;pointer-events:auto;z-index:1}
1131
1146
  .dsm-dot-unread-manual{background:var(--dsw-alias-state-business-primary)}
1132
- .dsm-dot-waiting{background:var(--dsw-alias-state-warning-primary)}
1147
+ .dsm-dot-waiting{background:var(--dsw-alias-state-warn-primary,#F59E0B)}
1133
1148
  .dsm-dot-unread{background:var(--dsw-alias-state-success-primary)}
1134
1149
  .dsm-drag-source{opacity:.48}
1135
1150
  .dsm-drop-target{position:relative;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)!important;outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px;border-radius:8px}
@@ -1193,41 +1208,91 @@ async function dsmLoadCapabilities() {
1193
1208
  try { dsmCapabilities = await postJSON('/archived-sessions/capabilities', {}) } catch (e) { /* unknown stays safely unavailable */ }
1194
1209
  return dsmCapabilities
1195
1210
  }
1196
- function dsmActionCapability(name) {
1197
- return dsmCapabilities && dsmCapabilities.actions && dsmCapabilities.actions[name]
1211
+ function dsmActionCapability(name, legacy) {
1212
+ const source = dsmCapabilities && dsmCapabilities.actions
1213
+ return source ? (source[name] || (legacy && source[legacy]) || null) : null
1198
1214
  }
1199
1215
  async function dsmLoadTrashIds() {
1200
1216
  try {
1201
1217
  const r = await postJSON('/archived-sessions/sidebar-state', {})
1202
1218
  dsmTrashIds = new Set(((r && r.trashedSessionIds) || []).map(String))
1203
1219
  dsmServerPurgedIds = new Set(((r && r.purgedSessionIds) || []).map(String))
1220
+ // 服务器已确认的乐观标记随即清除;未被确认的标记最多存活到下次刷新,
1221
+ // 绝不永久压住后来同 id 的新会话。
1222
+ for (const id of [...dsmPendingPurged]) {
1223
+ if (dsmServerPurgedIds.has(id)) dsmPendingPurged.delete(id)
1224
+ }
1204
1225
  dsmAuthoritativeTitles = new Map(Object.entries((r && r.titles) || {}).map(([id, title]) => [String(id), String(title)]))
1205
1226
  if (dsmRepaintDots) dsmRepaintDots()
1206
1227
  } catch (e) { /* keep last known set */ }
1207
1228
  return dsmTrashIds
1208
1229
  }
1209
1230
 
1210
- // Permanently-hidden set: sessions the user hard-purged from the 回收站. Unlike
1211
- // dsmTrashIds (which the backend poll refreshes and can drop), these are GONE for
1212
- // good, so we keep them hidden forever — otherwise DSH re-surfaces the orphan
1213
- // under a "未分组" group after the trash poll drops it from dsmTrashIds.
1214
- const DSM_KEY_PURGED = 'dsm-purged-v1'
1215
- let dsmPurgedIds = null
1231
+ // Permanently-hidden set: sessions the user hard-purged from the 回收站. The
1232
+ // SERVER is the single source of truth: sidebar-state only reports tombstones
1233
+ // whose ids are NOT currently present, so a same-id session recreated later is
1234
+ // unhidden automatically (the tombstone must never permanently suppress a new
1235
+ // session). dsmPendingPurged only carries this tab's optimistic marks until
1236
+ // the next server fetch acks them — no localStorage persistence, so a stale
1237
+ // tombstone can never outlive its server record. (dsmServerPurgedIds is
1238
+ // declared with the trash-state group above.)
1239
+ const dsmPendingPurged = new Set()
1216
1240
  function dsmLoadPurged() {
1217
- if (!dsmPurgedIds) {
1218
- try { dsmPurgedIds = new Set(JSON.parse(localStorage.getItem(DSM_KEY_PURGED) || '[]')) } catch (e) { dsmPurgedIds = new Set() }
1219
- }
1220
- dsmServerPurgedIds.forEach((id) => dsmPurgedIds.add(id))
1221
- return dsmPurgedIds
1241
+ return new Set([...dsmServerPurgedIds, ...dsmPendingPurged])
1222
1242
  }
1223
- function dsmSavePurged() { try { localStorage.setItem(DSM_KEY_PURGED, JSON.stringify([...dsmLoadPurged()])) } catch (e) {} }
1224
1243
  function dsmMarkPurged(ids) {
1225
- const s = dsmLoadPurged()
1226
- ids.forEach((id) => s.add(String(id)))
1227
- dsmSavePurged()
1244
+ ids.forEach((id) => dsmPendingPurged.add(String(id)))
1228
1245
  if (dsmRepaintDots) dsmRepaintDots()
1229
1246
  }
1230
1247
 
1248
+ // ---- Sidebar injection adapter (versioned, safe-degrading) ----------------
1249
+ // 每一处脆弱的 DOM / React fiber 识别都收敛到这个 adapter:识别规则集中、
1250
+ // 可版本化(上游改版时 bump version 并只改这里),并且**可安全降级**——
1251
+ // 识别连续落空说明上游内部结构变了,此时 adapter 自动停用全部注入,
1252
+ // 绝不把半残行为强加到官方侧栏上(拖拽 / 标题同步 / 状态圆点一并停用)。
1253
+ const SIDEBAR_ADAPTER_VERSION = 3
1254
+ const SIDEBAR_ADAPTER_MAX_MISSES = 200
1255
+
1256
+ function createSidebarAdapter() {
1257
+ let disabled = false
1258
+ let misses = 0
1259
+ const findFiber = (el) => {
1260
+ const k = Object.keys(el).find((kk) => kk.startsWith('__reactFiber') || kk.startsWith('__reactInternalInstance'))
1261
+ return k ? el[k] : null
1262
+ }
1263
+ // 识别一行侧栏树节点:返回 { node, group }(会话行 / 工作区标题行)。
1264
+ // 两种都没有 = 识别落空,累计到阈值即整体停用。
1265
+ const recognize = (row) => {
1266
+ const out = { node: null, group: null }
1267
+ if (disabled || !row || typeof row !== 'object') return out
1268
+ let f = findFiber(row)
1269
+ let guard = 0
1270
+ while (f && guard++ < 300) {
1271
+ const props = f.memoizedProps
1272
+ if (props && props.node && typeof props.node === 'object') out.node = props.node
1273
+ // DSH 的会话行接收 `node`,工作区标题行(ProjectRowItem)接收 `group`。
1274
+ if (props && props.group && typeof props.group === 'object') out.group = props.group
1275
+ f = f.return
1276
+ }
1277
+ if (!out.node && !out.group) {
1278
+ if (++misses >= SIDEBAR_ADAPTER_MAX_MISSES) {
1279
+ disabled = true
1280
+ try { console.warn('[dsh-sessions-manager] 侧栏注入 adapter v' + SIDEBAR_ADAPTER_VERSION + ' 已停用:未识别到已知的 DSH 侧栏节点结构(上游可能已改版)') } catch (e) {}
1281
+ }
1282
+ } else if (misses > 0) {
1283
+ misses = 0
1284
+ }
1285
+ return out
1286
+ }
1287
+ return {
1288
+ version: SIDEBAR_ADAPTER_VERSION,
1289
+ get disabled() { return disabled },
1290
+ findFiber,
1291
+ recognize,
1292
+ }
1293
+ }
1294
+ const sidebarAdapter = createSidebarAdapter()
1295
+
1231
1296
  function installSidebarSessionMenuAug() {
1232
1297
  if (typeof document === 'undefined') return
1233
1298
  const AUG = 'data-dsm-aug'
@@ -1235,14 +1300,13 @@ function installSidebarSessionMenuAug() {
1235
1300
  let activeSubClose = null
1236
1301
  let hoverTimer = null
1237
1302
 
1238
- const findFiber = (el) => {
1239
- const k = Object.keys(el).find((kk) => kk.startsWith('__reactFiber') || kk.startsWith('__reactInternalInstance'))
1240
- return k ? el[k] : null
1241
- }
1242
1303
  // Walk the React fiber chain from the portalled menu element up to the
1243
1304
  // SessionNodeItem component, which carries `node.id` (the session id).
1305
+ // Portalled menus are one-off surfaces: fiber walking here does NOT count
1306
+ // toward the row adapter's miss budget (a non-session menu is normal, not a
1307
+ // sign of upstream drift).
1244
1308
  const sessionInfoFromMenu = (menuEl) => {
1245
- let f = findFiber(menuEl)
1309
+ let f = sidebarAdapter.findFiber(menuEl)
1246
1310
  let guard = 0
1247
1311
  while (f && guard++ < 300) {
1248
1312
  const p = f.memoizedProps
@@ -1412,7 +1476,7 @@ function installSidebarSessionMenuAug() {
1412
1476
  const move = mk('移动会话', ICON_MOVE, false)
1413
1477
  const del = mk('删除会话', ICON_DEL, true)
1414
1478
  const mark = mk(dsmLoadManual().has(info.id) ? '标记已读' : '标记未读', ICON_UNREAD, false)
1415
- const moveCapability = dsmActionCapability('move')
1479
+ const moveCapability = dsmActionCapability('relocateSession') || dsmActionCapability('move')
1416
1480
  if (!moveCapability || !moveCapability.available) {
1417
1481
  move.btn.disabled = true
1418
1482
  move.btn.title = (moveCapability && moveCapability.reason) || '正在检查当前版本的移动能力'
@@ -1450,6 +1514,7 @@ function installSidebarSessionMenuAug() {
1450
1514
 
1451
1515
  const seen = new WeakSet()
1452
1516
  const obs = new MutationObserver(() => {
1517
+ if (sidebarAdapter.disabled) return
1453
1518
  const menus = document.querySelectorAll('body > [role="menu"]')
1454
1519
  menus.forEach((menuEl) => {
1455
1520
  if (seen.has(menuEl)) return
@@ -1465,7 +1530,7 @@ function installSidebarSessionMenuAug() {
1465
1530
  // Left-side per-session status dot (DOM shim). The color is driven by DSH's
1466
1531
  // REAL session status, read from the row's own StateDot ([data-state]):
1467
1532
  // 手动标记未读(蓝) = 用户在 ⋯ 菜单或点击圆点手动标记,localStorage 持久化(最高优先级)
1468
- // 工作中(黄) = DSH state 'running'
1533
+ // 工作中(黄) = DSH state 'ongoing'(旧版兼容 'running')
1469
1534
  // 需用户反馈(琥珀) = DSH state 'warning'(有追问需用户反馈)
1470
1535
  // 完成后未读(绿) = DSH state 'done' 且用户尚未读过(read 集合)
1471
1536
  // 完成已读(不显示) = DSH state 'done' 且已读过(read 集合持久化)
@@ -1480,21 +1545,7 @@ function installSidebarStatusDots() {
1480
1545
  s.textContent = SIDEBAR_AUG_CSS
1481
1546
  document.head.appendChild(s)
1482
1547
  }
1483
- const findFiber = (el) => {
1484
- const k = Object.keys(el).find((kk) => kk.startsWith('__reactFiber') || kk.startsWith('__reactInternalInstance'))
1485
- return k ? el[k] : null
1486
- }
1487
- const fiberProp = (el, pred) => {
1488
- let f = findFiber(el)
1489
- let g = 0
1490
- while (f && g++ < 300) {
1491
- const p = f.memoizedProps
1492
- if (p && pred(p)) return pred(p)
1493
- f = f.return
1494
- }
1495
- return null
1496
- }
1497
- const rowNode = (row) => fiberProp(row, (p) => (p.node && typeof p.node.id === 'string' && p.node.id ? p.node : null))
1548
+ const rowNode = (row) => sidebarAdapter.recognize(row).node
1498
1549
  const rowId = (row) => { const node = rowNode(row); return node ? node.id : null }
1499
1550
  let curActive = null
1500
1551
  const activeRowId = () => {
@@ -1503,7 +1554,7 @@ function installSidebarStatusDots() {
1503
1554
  }
1504
1555
  // Dot colors per the user's scheme. We read DSH's REAL session status from the
1505
1556
  // row's own StateDot (its [data-state] attr) and recolor it:
1506
- // running → 黄 工作中
1557
+ // ongoing/running → 黄 工作中
1507
1558
  // warning → 琥珀 有追问需用户反馈
1508
1559
  // done → 绿 完成后未读(读过则不再显示)
1509
1560
  // error → 红 出错/需关注(与 DSH 自带 StateDot 一致)
@@ -1511,15 +1562,17 @@ function installSidebarStatusDots() {
1511
1562
  const COLOR = {
1512
1563
  manual: 'var(--dsw-alias-state-business-primary)', // 蓝 手动标记未读
1513
1564
  running: '#EAB308', // 黄 工作中 (DSH running)
1514
- feedback: 'var(--dsw-alias-state-warning-primary)', // 琥珀 需用户反馈 (DSH warning)
1565
+ feedback: 'var(--dsw-alias-state-warn-primary, #F59E0B)', // 琥珀 需用户反馈 (DSH warning)
1515
1566
  done: 'var(--dsw-alias-state-success-primary)', // 绿 完成后未读 (DSH done)
1516
1567
  error: 'var(--dsw-alias-state-error-primary)', // 红 出错/需关注 (DSH error)
1517
1568
  }
1518
1569
  const manualUnread = dsmLoadManual()
1570
+ const titleInitializedRows = new WeakSet()
1519
1571
  const paint = () => {
1572
+ if (sidebarAdapter.disabled) return
1520
1573
  // Recompute the active session on every paint so the active row is never
1521
1574
  // shown from a stale `curActive` (the click that changes aria-selected
1522
- // mutates the DOM and triggers paint immediately, before the 1.2s tick()
1575
+ // mutates the DOM and triggers paint immediately, before the tick()
1523
1576
  // would have run — without this, the clicked session flashed green).
1524
1577
  const activeId = activeRowId()
1525
1578
  // Clicking into a manually-marked session auto-clears the manual unread
@@ -1549,12 +1602,20 @@ function installSidebarStatusDots() {
1549
1602
  // Session is opened. Paint the latest log-folded title from the host
1550
1603
  // authority without materializing the Session or changing its log.
1551
1604
  const authoritativeTitle = dsmAuthoritativeTitles.get(id)
1552
- if (authoritativeTitle) {
1605
+ if (!titleInitializedRows.has(row)) {
1553
1606
  const node = rowNode(row) || {}
1554
1607
  const expected = new Set([node.title, node.displayTitle, node.name].filter((value) => typeof value === 'string'))
1555
1608
  const spans = [...row.children].filter((el) => el.tagName === 'SPAN' && !el.querySelector('[data-state]') && (el.textContent || '').trim())
1556
1609
  const titleEl = spans.find((el) => expected.has((el.textContent || '').trim())) || spans[0]
1557
- if (titleEl && titleEl.textContent !== authoritativeTitle) titleEl.textContent = authoritativeTitle
1610
+ if (titleEl) {
1611
+ const correction = authoritativeTitleForFirstPaint({
1612
+ firstPaint: true,
1613
+ rendered: titleEl.textContent || '',
1614
+ authoritative: authoritativeTitle || '',
1615
+ })
1616
+ if (correction) titleEl.textContent = correction
1617
+ titleInitializedRows.add(row)
1618
+ }
1558
1619
  }
1559
1620
  // Read DSH's REAL session status from the row's own StateDot and recolor
1560
1621
  // it with the user's scheme (we also hide DSH's dot so only ours shows).
@@ -1582,15 +1643,29 @@ function installSidebarStatusDots() {
1582
1643
  })
1583
1644
  }
1584
1645
  dsmRepaintDots = paint
1585
- const tick = () => { if (++dsmTrashTick % 8 === 0) dsmLoadTrashIds(); paint() }
1646
+ // MutationObserver 增量驱动为主(childList + 属性 + 文本),彻底替代高频率
1647
+ // 全表定时扫描;保留一个低频兜底 tick 处理无 DOM 变化的状态迁移,且后台
1648
+ // 标签页完全不跑。回收站/墓碑集合的刷新也挂在这个兜底上(每 8 拍 ≈ 32s)。
1586
1649
  let raf = 0
1587
1650
  const schedulePaint = () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; paint() }) }
1651
+ const FALLBACK_TICK_MS = 4000
1652
+ const tick = () => {
1653
+ if (typeof document !== 'undefined' && document.hidden) return
1654
+ if (++dsmTrashTick % 8 === 0) dsmLoadTrashIds()
1655
+ paint()
1656
+ }
1588
1657
  tick()
1589
1658
  dsmLoadTrashIds()
1590
1659
  paint()
1591
1660
  const obs = new MutationObserver(schedulePaint)
1592
- obs.observe(document.body, { childList: true, subtree: true })
1593
- setInterval(tick, 1200)
1661
+ obs.observe(document.body, {
1662
+ childList: true,
1663
+ subtree: true,
1664
+ attributes: true,
1665
+ attributeFilter: ['data-state', 'aria-selected', 'class', 'style'],
1666
+ characterData: true,
1667
+ })
1668
+ setInterval(tick, FALLBACK_TICK_MS)
1594
1669
  }
1595
1670
 
1596
1671
  // Cross-workspace drag/drop for the native DSH sidebar. DSH does not expose a
@@ -1603,31 +1678,18 @@ function installSidebarWorkspaceDrag() {
1603
1678
  let dragging = null
1604
1679
  let moving = false
1605
1680
 
1606
- const findFiber = (el) => {
1607
- const key = Object.keys(el).find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'))
1608
- return key ? el[key] : null
1609
- }
1610
- const fiberNodes = (el) => {
1611
- const out = []
1612
- let fiber = findFiber(el)
1613
- let guard = 0
1614
- while (fiber && guard++ < 300) {
1615
- const props = fiber.memoizedProps
1616
- if (props && props.node && typeof props.node === 'object') out.push(props.node)
1617
- // DSH's SessionNodeItem receives `node`, while ProjectRowItem (the
1618
- // workspace heading) receives `group`. Without the latter every session
1619
- // can start dragging but no workspace can ever become a valid drop zone.
1620
- if (props && props.group && typeof props.group === 'object') out.push(props.group)
1621
- fiber = fiber.return
1622
- }
1623
- return out
1681
+ // 识别统一走 sidebarAdapter(可版本化、可整体降级);adapter 停用后拖拽
1682
+ // 行为整体退出,不残留在官方侧栏上。
1683
+ const sessionForRow = (row) => {
1684
+ const { node, group } = sidebarAdapter.recognize(row)
1685
+ return sessionForNodes(group ? [node, group].filter(Boolean) : [node], sessions)
1624
1686
  }
1625
- const sessionForRow = (row) => sessionForNodes(fiberNodes(row), sessions)
1626
1687
  const workspaceForRow = (row) => {
1627
1688
  // A session row's fiber chain also contains its parent workspace node.
1628
1689
  // Reject it explicitly so only the visible workspace header is a drop zone.
1629
1690
  if (sessionForRow(row)) return null
1630
- return workspaceForNodes(fiberNodes(row))
1691
+ const { group } = sidebarAdapter.recognize(row)
1692
+ return group ? workspaceForNodes([group]) : null
1631
1693
  }
1632
1694
  const eventRow = (event) => {
1633
1695
  for (const item of event.composedPath ? event.composedPath() : []) {
@@ -1647,6 +1709,7 @@ function installSidebarWorkspaceDrag() {
1647
1709
  document.querySelectorAll('.dsm-drag-source,.dsm-drop-target').forEach((el) => el.classList.remove('dsm-drag-source', 'dsm-drop-target'))
1648
1710
  }
1649
1711
  const decorateRows = () => {
1712
+ if (sidebarAdapter.disabled) return
1650
1713
  document.querySelectorAll('[role="treeitem"]').forEach((row) => {
1651
1714
  const session = sessionForRow(row)
1652
1715
  const ws = workspaceForRow(row)
@@ -1679,7 +1742,8 @@ function installSidebarWorkspaceDrag() {
1679
1742
  }, true)
1680
1743
 
1681
1744
  document.addEventListener('dragstart', (event) => {
1682
- const moveCapability = dsmActionCapability('move')
1745
+ if (sidebarAdapter.disabled) return
1746
+ const moveCapability = dsmActionCapability('relocateSession') || dsmActionCapability('move')
1683
1747
  if (!moveCapability || !moveCapability.available) return
1684
1748
  const row = eventRow(event)
1685
1749
  const item = row && sessionForRow(row)
@@ -7,17 +7,29 @@
7
7
  // 状态点语义:manual 未读最高优先;done 在当前查看的行上不亮绿
8
8
  // (读过即视为已读)。返回逻辑态名,颜色映射留在 UI 层。
9
9
  // 历史回归:DSH 曾把 running 报为 ongoing(9766476),上游枚举变化要盯这里。
10
+ // issue #4:上游还存在 waiting / needs-attention 等同义词,这里收敛到同一
11
+ // 逻辑态,颜色映射仍由 UI 层统一处理(issue 保持 open 直到上游确认)。
10
12
  export function dotStateFor({ manualUnread = false, dataState = '', isActive = false } = {}) {
11
13
  if (manualUnread) return 'manual'
12
14
  switch (dataState) {
15
+ case 'ongoing':
13
16
  case 'running': return 'running'
14
- case 'warning': return 'feedback'
17
+ case 'warning':
18
+ case 'waiting':
19
+ case 'needs-attention': return 'feedback'
15
20
  case 'error': return 'error'
16
21
  case 'done': return isActive ? null : 'done'
17
22
  default: return null
18
23
  }
19
24
  }
20
25
 
26
+ // 权威标题只修正新出现 DOM 行的冷态标题。行一旦完成首次 paint,后续文本
27
+ // 变化属于 DSH Core 的实时更新(例如重命名),插件不得再用轮询快照覆盖。
28
+ export function authoritativeTitleForFirstPaint({ firstPaint = false, rendered = '', authoritative = '' } = {}) {
29
+ if (!firstPaint || !authoritative || rendered === authoritative) return null
30
+ return authoritative
31
+ }
32
+
21
33
  // 拖拽迁移前置校验:同工作区拦截(workspacePath 相等即拒绝)。
22
34
  // 无 workspacePath 的会话(如侧栏 live 行尚未同步)放行,由 host 最终裁决。
23
35
  export function canDropOnWorkspace(item, target) {
@@ -1,3 +1,29 @@
1
+ // Capability matrix for the two DSH persistence generations. Every user-visible
2
+ // action gets its own availability flag plus a Chinese reason, so the UI can
3
+ // disable buttons honestly and the host routes can refuse with a stable error.
4
+ //
5
+ // Guiding rule (revised 2026-09-06 by explicit user decision): the official
6
+ // SessionHandle-era contract (dsh-v0.1.3-alpha.1) exposes NO delete and NO
7
+ // relocation — but legacy implementations were never pure-public either (they
8
+ // used the official `locate` to find the path, then acted on the filesystem
9
+ // directly). The user chose to keep that semi-official approach in the handle
10
+ // era: private-path operations ARE allowed, but only through the guarded
11
+ // derivation in src/handle-era-paths.js (backend root instance field → session
12
+ // directory structure → id ownership) plus handle-era-ops.js (writer probe,
13
+ // backup + rollback). Any derivation failure degrades to "unavailable".
14
+ //
15
+ // Action vocabulary (canonical):
16
+ // readInspection read/list/stat the stored log (read-only)
17
+ // archive archive/unarchive via workspaceRegistry (a marking op)
18
+ // softTrash move a session into the plugin recycle bin (log stays put)
19
+ // restoreIndexedSession restore a trashed session after verifying the
20
+ // underlying stored session still exists
21
+ // physicalPurge irreversibly delete the stored log (guarded fs rm)
22
+ // relocateSession move a session across workspaces (changes header cwd)
23
+ //
24
+ // Legacy aliases (read / trash / restoreTrash / purge / move) are kept so the
25
+ // client and older routes keep working during the transition.
26
+
1
27
  function action(available, reason = null) {
2
28
  return { available: !!available, reason: available ? null : reason }
3
29
  }
@@ -7,20 +33,50 @@ export function detectCapabilities({ persistence, workspaceRegistry }) {
7
33
  const legacyRead = !!(persistence && typeof persistence.readFrom === 'function')
8
34
  const legacyLocate = !!(persistence && (typeof persistence.locate === 'function'
9
35
  || (persistence.backend && typeof persistence.backend.locate === 'function')))
36
+ // handle 时代:root 必须直接读自后端实例字段(session-persistence-jsonl 的
37
+ // `root`),拿不到整体降级——绝不猜路径。
38
+ const handleEraRoot = !!(handleApi && persistence
39
+ && typeof persistence.root === 'string' && persistence.root.length > 0)
40
+ const canVerifyExistence = !!(handleApi || legacyRead
41
+ || (persistence && typeof persistence.stat === 'function'))
42
+ const readOk = legacyRead || handleApi
10
43
  const workspaceInternals = !!(workspaceRegistry
11
44
  && workspaceRegistry.headers && workspaceRegistry.sessionPaths
12
45
  && typeof workspaceRegistry.replaceHeaderIndex === 'function')
13
46
 
47
+ const matrix = {
48
+ readInspection: action(readOk, '当前 DSH 未提供可识别的会话读取接口'),
49
+ archive: action(!!(workspaceRegistry && typeof workspaceRegistry.archiveSession === 'function'), '当前 DSH 未提供归档接口'),
50
+ softTrash: action(readOk, '当前 DSH 无法读取会话,不能安全移入回收站'),
51
+ // 恢复不再无条件宣称可用:必须能校验底层会话仍存在(stat 或 list),
52
+ // 否则恢复只会制造一条指向已消失日志的僵尸条目。
53
+ restoreIndexedSession: action(canVerifyExistence, '当前 DSH 无法校验底层会话是否存在,不能安全恢复'),
54
+ physicalPurge: action(
55
+ (!handleApi && legacyLocate) || (handleApi && handleEraRoot && canVerifyExistence),
56
+ handleApi && !handleEraRoot
57
+ ? '无法从当前 DSH 后端确认会话存储根目录,已停止物理删除以保护数据安全'
58
+ : '当前 DSH 版本尚未提供经过验证的安全永久删除能力;移入回收站不会释放磁盘空间',
59
+ ),
60
+ relocateSession: action(
61
+ (!handleApi && legacyRead && legacyLocate && workspaceInternals)
62
+ || (handleApi && handleEraRoot && readOk && workspaceInternals),
63
+ handleApi && !workspaceInternals
64
+ ? '当前 DSH 未提供工作区注册表内部结构,跨工作区移动后无法即时刷新分组'
65
+ : handleApi && !handleEraRoot
66
+ ? '无法从当前 DSH 后端确认会话存储根目录,已停止移动以保护数据安全'
67
+ : '当前 DSH 版本尚未提供经过验证的跨工作区迁移能力',
68
+ ),
69
+ }
70
+ // Legacy aliases for existing client/routes/tests.
71
+ matrix.read = matrix.readInspection
72
+ matrix.trash = matrix.softTrash
73
+ matrix.restoreTrash = matrix.restoreIndexedSession
74
+ matrix.purge = matrix.physicalPurge
75
+ matrix.move = matrix.relocateSession
76
+
14
77
  return {
15
78
  persistence: handleApi ? 'session-handle' : 'legacy',
16
- actions: {
17
- read: action(legacyRead || handleApi, '当前 DSH 未提供可识别的会话读取接口'),
18
- archive: action(!!(workspaceRegistry && typeof workspaceRegistry.archiveSession === 'function'), '当前 DSH 未提供归档接口'),
19
- trash: action(legacyRead || handleApi, '当前 DSH 无法读取会话,不能安全移入回收站'),
20
- restoreTrash: action(true),
21
- purge: action(!handleApi && legacyLocate, '当前 DSH 版本尚未提供经过验证的安全永久删除能力'),
22
- move: action(!handleApi && legacyRead && legacyLocate && workspaceInternals, '当前 DSH 版本尚未提供经过验证的跨工作区迁移能力'),
23
- },
79
+ actions: matrix,
24
80
  }
25
81
  }
26
82