dsh-sessions-manager 3.5.1 → 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.1",
4
+ "version": "3.5.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -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>
@@ -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
@@ -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 = () => {
@@ -1518,9 +1569,10 @@ function installSidebarStatusDots() {
1518
1569
  const manualUnread = dsmLoadManual()
1519
1570
  const titleInitializedRows = new WeakSet()
1520
1571
  const paint = () => {
1572
+ if (sidebarAdapter.disabled) return
1521
1573
  // Recompute the active session on every paint so the active row is never
1522
1574
  // shown from a stale `curActive` (the click that changes aria-selected
1523
- // mutates the DOM and triggers paint immediately, before the 1.2s tick()
1575
+ // mutates the DOM and triggers paint immediately, before the tick()
1524
1576
  // would have run — without this, the clicked session flashed green).
1525
1577
  const activeId = activeRowId()
1526
1578
  // Clicking into a manually-marked session auto-clears the manual unread
@@ -1591,15 +1643,29 @@ function installSidebarStatusDots() {
1591
1643
  })
1592
1644
  }
1593
1645
  dsmRepaintDots = paint
1594
- const tick = () => { if (++dsmTrashTick % 8 === 0) dsmLoadTrashIds(); paint() }
1646
+ // MutationObserver 增量驱动为主(childList + 属性 + 文本),彻底替代高频率
1647
+ // 全表定时扫描;保留一个低频兜底 tick 处理无 DOM 变化的状态迁移,且后台
1648
+ // 标签页完全不跑。回收站/墓碑集合的刷新也挂在这个兜底上(每 8 拍 ≈ 32s)。
1595
1649
  let raf = 0
1596
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
+ }
1597
1657
  tick()
1598
1658
  dsmLoadTrashIds()
1599
1659
  paint()
1600
1660
  const obs = new MutationObserver(schedulePaint)
1601
- obs.observe(document.body, { childList: true, subtree: true })
1602
- 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)
1603
1669
  }
1604
1670
 
1605
1671
  // Cross-workspace drag/drop for the native DSH sidebar. DSH does not expose a
@@ -1612,31 +1678,18 @@ function installSidebarWorkspaceDrag() {
1612
1678
  let dragging = null
1613
1679
  let moving = false
1614
1680
 
1615
- const findFiber = (el) => {
1616
- const key = Object.keys(el).find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'))
1617
- return key ? el[key] : null
1618
- }
1619
- const fiberNodes = (el) => {
1620
- const out = []
1621
- let fiber = findFiber(el)
1622
- let guard = 0
1623
- while (fiber && guard++ < 300) {
1624
- const props = fiber.memoizedProps
1625
- if (props && props.node && typeof props.node === 'object') out.push(props.node)
1626
- // DSH's SessionNodeItem receives `node`, while ProjectRowItem (the
1627
- // workspace heading) receives `group`. Without the latter every session
1628
- // can start dragging but no workspace can ever become a valid drop zone.
1629
- if (props && props.group && typeof props.group === 'object') out.push(props.group)
1630
- fiber = fiber.return
1631
- }
1632
- 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)
1633
1686
  }
1634
- const sessionForRow = (row) => sessionForNodes(fiberNodes(row), sessions)
1635
1687
  const workspaceForRow = (row) => {
1636
1688
  // A session row's fiber chain also contains its parent workspace node.
1637
1689
  // Reject it explicitly so only the visible workspace header is a drop zone.
1638
1690
  if (sessionForRow(row)) return null
1639
- return workspaceForNodes(fiberNodes(row))
1691
+ const { group } = sidebarAdapter.recognize(row)
1692
+ return group ? workspaceForNodes([group]) : null
1640
1693
  }
1641
1694
  const eventRow = (event) => {
1642
1695
  for (const item of event.composedPath ? event.composedPath() : []) {
@@ -1656,6 +1709,7 @@ function installSidebarWorkspaceDrag() {
1656
1709
  document.querySelectorAll('.dsm-drag-source,.dsm-drop-target').forEach((el) => el.classList.remove('dsm-drag-source', 'dsm-drop-target'))
1657
1710
  }
1658
1711
  const decorateRows = () => {
1712
+ if (sidebarAdapter.disabled) return
1659
1713
  document.querySelectorAll('[role="treeitem"]').forEach((row) => {
1660
1714
  const session = sessionForRow(row)
1661
1715
  const ws = workspaceForRow(row)
@@ -1688,7 +1742,8 @@ function installSidebarWorkspaceDrag() {
1688
1742
  }, true)
1689
1743
 
1690
1744
  document.addEventListener('dragstart', (event) => {
1691
- const moveCapability = dsmActionCapability('move')
1745
+ if (sidebarAdapter.disabled) return
1746
+ const moveCapability = dsmActionCapability('relocateSession') || dsmActionCapability('move')
1692
1747
  if (!moveCapability || !moveCapability.available) return
1693
1748
  const row = eventRow(event)
1694
1749
  const item = row && sessionForRow(row)
@@ -7,12 +7,16 @@
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) {
13
15
  case 'ongoing':
14
16
  case 'running': return 'running'
15
- case 'warning': return 'feedback'
17
+ case 'warning':
18
+ case 'waiting':
19
+ case 'needs-attention': return 'feedback'
16
20
  case 'error': return 'error'
17
21
  case 'done': return isActive ? null : 'done'
18
22
  default: return null
@@ -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