dsh-sessions-manager 3.4.2 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.4.2",
4
+ "version": "3.5.1",
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
 
@@ -219,6 +219,7 @@ function installSettingsNavIcons(ctx) {
219
219
  function SessionPanel({ workspacesSvc }) {
220
220
  const [sessions, setSessions] = useState(null)
221
221
  const [workspaces, setWorkspaces] = useState([])
222
+ const [capabilities, setCapabilities] = useState(null)
222
223
  const initialPrefs = useRef(loadPanelPrefs()).current
223
224
  // 注意:'storage' 已从可持久化取值中移除(它不再是视图),旧版残留的
224
225
  // filter='storage' 会自动回落到 'all',避免落到一个已不存在的界面。
@@ -276,14 +277,16 @@ function SessionPanel({ workspacesSvc }) {
276
277
  Promise.all([
277
278
  postJSON('/archived-sessions/sessions', {}),
278
279
  postJSON('/archived-sessions/workspaces', {}),
280
+ postJSON('/archived-sessions/capabilities', {}),
279
281
  ])
280
- .then(([s, works]) => {
282
+ .then(([s, works, caps]) => {
281
283
  // Permanently-purged sessions must never re-appear here, even if DSH's
282
284
  // in-memory session index still lists them after the file was unlinked.
283
285
  const purged = dsmLoadPurged()
284
286
  const visible = (s.items || []).filter((x) => !purged.has(String(x.sessionId)))
285
287
  setSessions(visible)
286
288
  setWorkspaces(works.items || [])
289
+ setCapabilities(caps || null)
287
290
  setSelected({})
288
291
  setDelTarget(null)
289
292
  setConfirmBatch(false)
@@ -393,6 +396,9 @@ function SessionPanel({ workspacesSvc }) {
393
396
  const w = workspaces.find((x) => x.workspaceId === id)
394
397
  return w ? w.path : ''
395
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')
396
402
 
397
403
  const archivedList = sessions ? sessions.filter((x) => x.archived) : []
398
404
  const activeList = sessions ? sessions.filter((x) => !x.archived) : []
@@ -473,7 +479,12 @@ function SessionPanel({ workspacesSvc }) {
473
479
  const verifyTrash = () => {
474
480
  setTrashBusy('__verify')
475
481
  postJSON('/archived-sessions/trash/verify', {})
476
- .then((r) => { setTrashBusy(null); setTrashCheck(r); showToast(r.missing ? `发现 ${r.missing} 条日志缺失` : '回收站校验通过') })
482
+ .then((r) => {
483
+ setTrashBusy(null); setTrashCheck(r)
484
+ if (r.missing) showToast(`发现 ${r.missing} 条日志缺失`)
485
+ else if (r.unverified) showToast(`${r.unverified} 条日志位置由当前 Runtime 管理,无法直接核验`)
486
+ else showToast('回收站校验通过')
487
+ })
477
488
  .catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
478
489
  }
479
490
 
@@ -554,6 +565,7 @@ function SessionPanel({ workspacesSvc }) {
554
565
  }
555
566
 
556
567
  const doMove = (it) => {
568
+ if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
557
569
  const targetPath = moveMode === 'new' ? newPath.trim() : wsPath(targetWs)
558
570
  if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
559
571
  setBusy(it.sessionId)
@@ -591,6 +603,7 @@ function SessionPanel({ workspacesSvc }) {
591
603
  }
592
604
 
593
605
  const openMoveFor = (it) => {
606
+ if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
594
607
  if (openMove === it.sessionId) { setOpenMove(null); return }
595
608
  setTargetWs(workspaces.length ? (targetWs || workspaces[0].workspaceId) : '')
596
609
  setMoveMode('existing')
@@ -663,15 +676,20 @@ function SessionPanel({ workspacesSvc }) {
663
676
  >⋯</button>
664
677
  {openMenu === it.sessionId && (
665
678
  <div className="more-menu" role="menu">
666
- {items.map(([id, label]) => (
679
+ {items.map(([id, label]) => {
680
+ const blocked = id === 'move' && !canMove.available
681
+ return (
667
682
  <button
668
683
  key={id}
669
684
  type="button"
670
685
  role="menuitem"
671
686
  className={'more-item' + (id === 'delete' ? ' more-item-danger' : '')}
687
+ disabled={blocked}
688
+ title={blocked ? canMove.reason : undefined}
672
689
  onClick={() => runMenu(id, it)}
673
690
  >{label}</button>
674
- ))}
691
+ )
692
+ })}
675
693
  </div>
676
694
  )}
677
695
  </div>
@@ -1034,9 +1052,10 @@ function SessionPanel({ workspacesSvc }) {
1034
1052
  </select>
1035
1053
  </div>
1036
1054
  <div className="sess-field"><label>数据检查</label><button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={verifyTrash}>{trashBusy === '__verify' ? '校验中…' : '校验日志完整性'}</button></div>
1037
- <div className="sess-field"><label>永久清理</label><button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !trash.length} onClick={() => setPurgeTarget('__all')}>清空回收站</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>
1038
1056
  </div>
1039
- {trashCheck && <div className={trashCheck.missing ? 'archv-err' : 'archv-empty'} role="status">校验完成:{trashCheck.healthy} 条正常,{trashCheck.missing} 条日志缺失。</div>}
1057
+ {!canPurge.available && <div className="archv-empty" role="status">{canPurge.reason}</div>}
1058
+ {trashCheck && <div className={trashCheck.missing ? 'archv-err' : 'archv-empty'} role="status">校验完成:{trashCheck.healthy} 条正常,{trashCheck.missing} 条日志缺失,{trashCheck.unverified || 0} 条无法直接核验。</div>}
1040
1059
  {trash.length === 0 ? (
1041
1060
  <div className="dsm-trash-empty">回收站为空。删除的会话会先进入这里,可恢复或彻底删除。</div>
1042
1061
  ) : (
@@ -1047,7 +1066,7 @@ function SessionPanel({ workspacesSvc }) {
1047
1066
  <span className="dsm-trash-date">{fmtDate(t.deletedAt)}</span>
1048
1067
  <span className="dsm-trash-actions">
1049
1068
  <button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={() => restoreTrash(t.sessionId)}>恢复</button>
1050
- <button type="button" className="archv-btn archv-del" disabled={trashBusy !== null} onClick={() => setPurgeTarget(t.sessionId)}>彻底删除</button>
1069
+ <button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !canPurge.available} title={!canPurge.available ? canPurge.reason : undefined} onClick={() => setPurgeTarget(t.sessionId)}>彻底删除</button>
1051
1070
  </span>
1052
1071
  </div>
1053
1072
  ))}
@@ -1110,7 +1129,7 @@ const SIDEBAR_AUG_CSS = `
1110
1129
  .dsm-sub-cur{font-size:11px;color:var(--dsw-alias-state-business-primary);flex:none;margin-left:8px}
1111
1130
  .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}
1112
1131
  .dsm-dot-unread-manual{background:var(--dsw-alias-state-business-primary)}
1113
- .dsm-dot-waiting{background:var(--dsw-alias-state-warning-primary)}
1132
+ .dsm-dot-waiting{background:var(--dsw-alias-state-warn-primary,#F59E0B)}
1114
1133
  .dsm-dot-unread{background:var(--dsw-alias-state-success-primary)}
1115
1134
  .dsm-drag-source{opacity:.48}
1116
1135
  .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}
@@ -1169,6 +1188,14 @@ let dsmTrashIds = null
1169
1188
  let dsmServerPurgedIds = new Set()
1170
1189
  let dsmAuthoritativeTitles = new Map()
1171
1190
  let dsmTrashTick = 0
1191
+ let dsmCapabilities = null
1192
+ async function dsmLoadCapabilities() {
1193
+ try { dsmCapabilities = await postJSON('/archived-sessions/capabilities', {}) } catch (e) { /* unknown stays safely unavailable */ }
1194
+ return dsmCapabilities
1195
+ }
1196
+ function dsmActionCapability(name) {
1197
+ return dsmCapabilities && dsmCapabilities.actions && dsmCapabilities.actions[name]
1198
+ }
1172
1199
  async function dsmLoadTrashIds() {
1173
1200
  try {
1174
1201
  const r = await postJSON('/archived-sessions/sidebar-state', {})
@@ -1385,6 +1412,11 @@ function installSidebarSessionMenuAug() {
1385
1412
  const move = mk('移动会话', ICON_MOVE, false)
1386
1413
  const del = mk('删除会话', ICON_DEL, true)
1387
1414
  const mark = mk(dsmLoadManual().has(info.id) ? '标记已读' : '标记未读', ICON_UNREAD, false)
1415
+ const moveCapability = dsmActionCapability('move')
1416
+ if (!moveCapability || !moveCapability.available) {
1417
+ move.btn.disabled = true
1418
+ move.btn.title = (moveCapability && moveCapability.reason) || '正在检查当前版本的移动能力'
1419
+ }
1388
1420
  if (mark.btn.firstChild) mark.btn.firstChild.style.color = 'var(--dsw-alias-state-business-primary)'
1389
1421
  const chev = document.createElement('span')
1390
1422
  chev.style.cssText = 'margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:1'
@@ -1433,7 +1465,7 @@ function installSidebarSessionMenuAug() {
1433
1465
  // Left-side per-session status dot (DOM shim). The color is driven by DSH's
1434
1466
  // REAL session status, read from the row's own StateDot ([data-state]):
1435
1467
  // 手动标记未读(蓝) = 用户在 ⋯ 菜单或点击圆点手动标记,localStorage 持久化(最高优先级)
1436
- // 工作中(黄) = DSH state 'running'
1468
+ // 工作中(黄) = DSH state 'ongoing'(旧版兼容 'running')
1437
1469
  // 需用户反馈(琥珀) = DSH state 'warning'(有追问需用户反馈)
1438
1470
  // 完成后未读(绿) = DSH state 'done' 且用户尚未读过(read 集合)
1439
1471
  // 完成已读(不显示) = DSH state 'done' 且已读过(read 集合持久化)
@@ -1471,7 +1503,7 @@ function installSidebarStatusDots() {
1471
1503
  }
1472
1504
  // Dot colors per the user's scheme. We read DSH's REAL session status from the
1473
1505
  // row's own StateDot (its [data-state] attr) and recolor it:
1474
- // running → 黄 工作中
1506
+ // ongoing/running → 黄 工作中
1475
1507
  // warning → 琥珀 有追问需用户反馈
1476
1508
  // done → 绿 完成后未读(读过则不再显示)
1477
1509
  // error → 红 出错/需关注(与 DSH 自带 StateDot 一致)
@@ -1479,11 +1511,12 @@ function installSidebarStatusDots() {
1479
1511
  const COLOR = {
1480
1512
  manual: 'var(--dsw-alias-state-business-primary)', // 蓝 手动标记未读
1481
1513
  running: '#EAB308', // 黄 工作中 (DSH running)
1482
- feedback: 'var(--dsw-alias-state-warning-primary)', // 琥珀 需用户反馈 (DSH warning)
1514
+ feedback: 'var(--dsw-alias-state-warn-primary, #F59E0B)', // 琥珀 需用户反馈 (DSH warning)
1483
1515
  done: 'var(--dsw-alias-state-success-primary)', // 绿 完成后未读 (DSH done)
1484
1516
  error: 'var(--dsw-alias-state-error-primary)', // 红 出错/需关注 (DSH error)
1485
1517
  }
1486
1518
  const manualUnread = dsmLoadManual()
1519
+ const titleInitializedRows = new WeakSet()
1487
1520
  const paint = () => {
1488
1521
  // Recompute the active session on every paint so the active row is never
1489
1522
  // shown from a stale `curActive` (the click that changes aria-selected
@@ -1517,12 +1550,20 @@ function installSidebarStatusDots() {
1517
1550
  // Session is opened. Paint the latest log-folded title from the host
1518
1551
  // authority without materializing the Session or changing its log.
1519
1552
  const authoritativeTitle = dsmAuthoritativeTitles.get(id)
1520
- if (authoritativeTitle) {
1553
+ if (!titleInitializedRows.has(row)) {
1521
1554
  const node = rowNode(row) || {}
1522
1555
  const expected = new Set([node.title, node.displayTitle, node.name].filter((value) => typeof value === 'string'))
1523
1556
  const spans = [...row.children].filter((el) => el.tagName === 'SPAN' && !el.querySelector('[data-state]') && (el.textContent || '').trim())
1524
1557
  const titleEl = spans.find((el) => expected.has((el.textContent || '').trim())) || spans[0]
1525
- if (titleEl && titleEl.textContent !== authoritativeTitle) titleEl.textContent = authoritativeTitle
1558
+ if (titleEl) {
1559
+ const correction = authoritativeTitleForFirstPaint({
1560
+ firstPaint: true,
1561
+ rendered: titleEl.textContent || '',
1562
+ authoritative: authoritativeTitle || '',
1563
+ })
1564
+ if (correction) titleEl.textContent = correction
1565
+ titleInitializedRows.add(row)
1566
+ }
1526
1567
  }
1527
1568
  // Read DSH's REAL session status from the row's own StateDot and recolor
1528
1569
  // it with the user's scheme (we also hide DSH's dot so only ours shows).
@@ -1647,6 +1688,8 @@ function installSidebarWorkspaceDrag() {
1647
1688
  }, true)
1648
1689
 
1649
1690
  document.addEventListener('dragstart', (event) => {
1691
+ const moveCapability = dsmActionCapability('move')
1692
+ if (!moveCapability || !moveCapability.available) return
1650
1693
  const row = eventRow(event)
1651
1694
  const item = row && sessionForRow(row)
1652
1695
  if (!item || moving) return
@@ -1729,6 +1772,7 @@ function installSidebarWorkspaceDrag() {
1729
1772
  }
1730
1773
 
1731
1774
  export function apply(ctx) {
1775
+ dsmLoadCapabilities()
1732
1776
  installSettingsNavIcons(ctx)
1733
1777
  installSidebarSessionMenuAug()
1734
1778
  installSidebarStatusDots()
@@ -10,6 +10,7 @@
10
10
  export function dotStateFor({ manualUnread = false, dataState = '', isActive = false } = {}) {
11
11
  if (manualUnread) return 'manual'
12
12
  switch (dataState) {
13
+ case 'ongoing':
13
14
  case 'running': return 'running'
14
15
  case 'warning': return 'feedback'
15
16
  case 'error': return 'error'
@@ -18,6 +19,13 @@ export function dotStateFor({ manualUnread = false, dataState = '', isActive = f
18
19
  }
19
20
  }
20
21
 
22
+ // 权威标题只修正新出现 DOM 行的冷态标题。行一旦完成首次 paint,后续文本
23
+ // 变化属于 DSH Core 的实时更新(例如重命名),插件不得再用轮询快照覆盖。
24
+ export function authoritativeTitleForFirstPaint({ firstPaint = false, rendered = '', authoritative = '' } = {}) {
25
+ if (!firstPaint || !authoritative || rendered === authoritative) return null
26
+ return authoritative
27
+ }
28
+
21
29
  // 拖拽迁移前置校验:同工作区拦截(workspacePath 相等即拒绝)。
22
30
  // 无 workspacePath 的会话(如侧栏 live 行尚未同步)放行,由 host 最终裁决。
23
31
  export function canDropOnWorkspace(item, target) {
@@ -0,0 +1,34 @@
1
+ function action(available, reason = null) {
2
+ return { available: !!available, reason: available ? null : reason }
3
+ }
4
+
5
+ export function detectCapabilities({ persistence, workspaceRegistry }) {
6
+ const handleApi = !!(persistence && typeof persistence.open === 'function')
7
+ const legacyRead = !!(persistence && typeof persistence.readFrom === 'function')
8
+ const legacyLocate = !!(persistence && (typeof persistence.locate === 'function'
9
+ || (persistence.backend && typeof persistence.backend.locate === 'function')))
10
+ const workspaceInternals = !!(workspaceRegistry
11
+ && workspaceRegistry.headers && workspaceRegistry.sessionPaths
12
+ && typeof workspaceRegistry.replaceHeaderIndex === 'function')
13
+
14
+ return {
15
+ 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
+ },
24
+ }
25
+ }
26
+
27
+ export function requireCapability(capabilities, name) {
28
+ const value = capabilities && capabilities.actions && capabilities.actions[name]
29
+ if (value && value.available) return
30
+ const error = new Error((value && value.reason) || `当前环境不支持 ${name}`)
31
+ error.status = 409
32
+ error.code = 'DSM_CAPABILITY_UNAVAILABLE'
33
+ throw error
34
+ }
@@ -0,0 +1,72 @@
1
+ // Compatibility boundary for the two DSH persistence generations supported by
2
+ // dsh-sessions-manager. Business code consumes normalized headers and complete
3
+ // inspections; it never needs to know whether DSH returned a legacy header or
4
+ // a handle-era SessionPersistenceSnapshot.
5
+
6
+ function asHeader(value) {
7
+ if (!value || typeof value !== 'object') return null
8
+ const candidate = value.header && typeof value.header === 'object' ? value.header : value
9
+ return candidate.id == null ? null : candidate
10
+ }
11
+
12
+ export function normalizePersistenceEntry(value) {
13
+ const header = asHeader(value)
14
+ if (!header) return null
15
+ const snapshot = value && value.header === header ? value : null
16
+ return {
17
+ header,
18
+ snapshot,
19
+ id: String(header.id),
20
+ sizeBytes: snapshot && Number.isFinite(snapshot.sizeBytes) ? Number(snapshot.sizeBytes) : null,
21
+ eventCount: snapshot && Number.isSafeInteger(snapshot.eventCount) ? snapshot.eventCount : null,
22
+ revision: snapshot ? snapshot.revision : null,
23
+ }
24
+ }
25
+
26
+ export function normalizePersistenceList(values) {
27
+ if (!Array.isArray(values)) return []
28
+ return values.map(normalizePersistenceEntry).filter(Boolean)
29
+ }
30
+
31
+ export function createPersistenceAdapter(service) {
32
+ if (!service || typeof service.list !== 'function') throw new TypeError('sessionPersistence.list is required')
33
+
34
+ async function listEntries(options) {
35
+ return normalizePersistenceList(await service.list(options))
36
+ }
37
+
38
+ async function readSession(id, offset = 0) {
39
+ if (typeof service.readFrom === 'function') {
40
+ const result = await service.readFrom(id, offset)
41
+ return {
42
+ meta: result && result.meta ? result.meta : null,
43
+ inheritedEventCount: result && Number.isSafeInteger(result.inheritedEventCount) ? result.inheritedEventCount : 0,
44
+ events: result && Array.isArray(result.events) ? result.events : [],
45
+ }
46
+ }
47
+ if (typeof service.open !== 'function') throw new Error('当前 DSH 持久化服务不支持读取会话')
48
+ const handle = await service.open(id, 'read')
49
+ if (!handle || typeof handle.read !== 'function' || typeof handle.close !== 'function') {
50
+ try { if (handle && typeof handle.close === 'function') await handle.close() } catch {}
51
+ throw new Error('DSH 返回了无效的 SessionHandle')
52
+ }
53
+ try {
54
+ const events = await handle.read(offset)
55
+ return {
56
+ meta: handle.header || handle.meta || null,
57
+ inheritedEventCount: Number.isSafeInteger(handle.inheritedEventCount) ? handle.inheritedEventCount : 0,
58
+ events: Array.isArray(events) ? events : [...(events || [])],
59
+ }
60
+ } finally {
61
+ await handle.close()
62
+ }
63
+ }
64
+
65
+ function locate(header) {
66
+ if (typeof service.locate === 'function') return service.locate(header)
67
+ return null
68
+ }
69
+
70
+ const kind = typeof service.open === 'function' ? 'session-handle' : 'legacy'
71
+ return { kind, listEntries, readSession, locate }
72
+ }