dsh-sessions-manager 3.7.7 → 3.7.8

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.7.7",
4
+ "version": "3.7.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -10,8 +10,8 @@
10
10
  * @module dsh-sessions-manager/client
11
11
  */
12
12
 
13
- import React, { useEffect, useMemo, useRef, useState } from 'react'
14
- import { aliveSessionCount, applyTagFilter, canDropOnWorkspace, countRenderedVisible, dotStateFor, effectiveTitleOf, filterShapeFromSaved, filterSnapshotOf, foldBranches, foldSubagents, noticeToastPlan, openSubagentToast, overflowPlan, pathTail, remainingRows, sessionForNodes, shortId, starredOf, tagDeleteConfirm, titleBackfillDecision, toastDurationFor, workspaceForNodes } from './logic.js'
13
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
14
+ import { aliveSessionCount, applyTagFilter, canDropOnWorkspace, collapsedVisibleTotal, countRenderedVisible, dotStateFor, effectiveTitleOf, filterShapeFromSaved, filterSnapshotOf, foldBranches, foldOrphanSubagents, foldSubagents, noticeToastPlan, openSubagentToast, overflowPlan, pathTail, remainingRows, sessionForNodes, shortId, starredOf, tagDeleteConfirm, titleBackfillDecision, toastDurationFor, workspaceForNodes } from './logic.js'
15
15
 
16
16
  export const inject = ['slots']
17
17
 
@@ -193,6 +193,10 @@ body[data-ds-dark-theme] .archv{color-scheme:dark}
193
193
  .dlg{width:min(420px,92vw);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:14px;padding:18px;box-shadow:0 16px 48px rgb(0 0 0/.28);display:flex;flex-direction:column;gap:12px}
194
194
  .dlg-title{font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary);margin:0}
195
195
  .dlg-text{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary);margin:0;word-break:break-all}
196
+ /* issue #18 第 4 条:删除确认框里的「连带子代理」勾选行。与面板其它勾选项
197
+ 同一套视觉语言(13px / 中性标签色 / 8px 间距),不自造按钮样式。 */
198
+ .dlg-check{display:flex;align-items:flex-start;gap:8px;font-size:13px;line-height:1.55;color:var(--dsw-alias-label-secondary);cursor:pointer}
199
+ .dlg-check input{margin:2px 0 0;flex:none}
196
200
  .dlg-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}
197
201
  /* ---- T4 标签编辑/管理 sheet 与筛选保存控件(全部走 --dsw token) ---- */
198
202
  .dsm-taglist{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto}
@@ -499,6 +503,10 @@ function SessionPanel({ workspacesSvc }) {
499
503
  const [sortBy, setSortBy] = useState(() => ['newest', 'oldest', 'title'].includes(initialPrefs.sortBy) ? initialPrefs.sortBy : 'newest')
500
504
  const [selected, setSelected] = useState({})
501
505
  const [delTarget, setDelTarget] = useState(null)
506
+ // issue #18 第 4 条:删除父会话默认连带回收它的子代理(生命周期绑定),
507
+ // 取消勾选则保留——保留的子代理会落进「未归属子代理」卡片,仍可单独处理。
508
+ const [cascadeSub, setCascadeSub] = useState(true)
509
+ useEffect(() => { if (delTarget) setCascadeSub(true) }, [delTarget])
502
510
  const [confirmBatch, setConfirmBatch] = useState(false)
503
511
  const [batchMoveOpen, setBatchMoveOpen] = useState(false)
504
512
  const [busy, setBusy] = useState(null)
@@ -577,6 +585,9 @@ function SessionPanel({ workspacesSvc }) {
577
585
  return next
578
586
  })
579
587
  const [groupByLineage, setGroupByLineage] = useState(() => initialPrefs.groupByLineage !== false)
588
+ // issue #18 第 1 条:父会话不在列表里的子代理收进一张「未归属子代理」卡片,
589
+ // 展开态不持久化——默认收起,避免又一次把列表撑长。
590
+ const [openOrphans, setOpenOrphans] = useState(false)
580
591
 
581
592
  // —— T4(3.7.0)标签 + 已存筛选 ——
582
593
  // 数据源纪律:卡片 chip、编辑 sheet 勾选、管理行用量、筛选下拉,一律以本
@@ -1121,18 +1132,38 @@ function SessionPanel({ workspacesSvc }) {
1121
1132
  const canMove = actionCapability('relocateSession', 'move')
1122
1133
  const canRestoreTrash = actionCapability('restoreIndexedSession', 'restoreTrash')
1123
1134
 
1124
- const archivedList = sessions ? sessions.filter((x) => x.archived) : []
1125
- const activeList = sessions ? sessions.filter((x) => !x.archived) : []
1126
- const starredList = starredOf(sessions)
1127
- // issue #6 之三:空白会话单分一类。空白判定来自宿主血缘快照(sizeBytes 阈值
1128
- // 实测校准),与侧栏「空白」标记同源——两处永远不会各说各话。
1129
- const emptyList = useMemo(() => {
1130
- if (!sessions) return []
1131
- return sessions.filter((x) => { const li = lineage[String(x.sessionId)]; return !!(li && li.empty) })
1132
- }, [sessions, lineage])
1135
+ // issue #18 第 1 条:子代理会话不是「一条可管理的会话」,而是父会话的下属。
1136
+ // 先前它们以顶层行的形式混进「活动 / 全部」,一个重度的多代理工作日能攒出
1137
+ // 上百个「(无标题)」——计数、全选、批量操作全被它们污染。这里统一判定,
1138
+ // 表格计数与顶层行都用它排除;子代理只出现在父会话的「N 子代理」里。
1139
+ const isSubagentItem = useCallback((item) => {
1140
+ const li = item ? lineage[String(item.sessionId)] : null
1141
+ return !!(li && li.origin === 'subagent')
1142
+ }, [lineage])
1143
+ // 列表管线仍要拿到子代理行(折叠判定要用它们挂到父会话下),所以保留一份
1144
+ // 未过滤的集合;只有「计数 / 顶层行 / 全选」用 topOnly。
1145
+ const { archivedAll, activeAll, starredAll, emptyAll } = useMemo(() => ({
1146
+ archivedAll: sessions ? sessions.filter((x) => x.archived) : [],
1147
+ activeAll: sessions ? sessions.filter((x) => !x.archived) : [],
1148
+ starredAll: starredOf(sessions),
1149
+ // issue #6 之三:空白会话单分一类。空白判定来自宿主血缘快照(sizeBytes 阈值
1150
+ // 实测校准),与侧栏「空白」标记同源——两处永远不会各说各话。
1151
+ emptyAll: sessions ? sessions.filter((x) => { const li = lineage[String(x.sessionId)]; return !!(li && li.empty) }) : [],
1152
+ }), [sessions, lineage])
1153
+ // 血缘分组开着时,计数只算「能作为一行被操作的会话」。平铺模式保持原样
1154
+ // (用户显式选择与 DSH 原生一致的平铺视图)。
1155
+ const topOnly = useCallback((arr) => (groupByLineage ? arr.filter((x) => !isSubagentItem(x)) : arr), [groupByLineage, isSubagentItem])
1156
+ const archivedList = useMemo(() => topOnly(archivedAll), [topOnly, archivedAll])
1157
+ const activeList = useMemo(() => topOnly(activeAll), [topOnly, activeAll])
1158
+ const starredList = useMemo(() => topOnly(starredAll), [topOnly, starredAll])
1159
+ const emptyList = useMemo(() => topOnly(emptyAll), [topOnly, emptyAll])
1160
+ // 「全部」计数:血缘分组下同样只算能作为一行的会话(子代理在父行徽标里)。
1161
+ const totalTopCount = useMemo(() => topOnly(sessions || []).length, [topOnly, sessions])
1133
1162
  const list = useMemo(() => {
1134
1163
  if (filter === 'trash') return []
1135
- const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : filter === 'starred' ? starredList : filter === 'empty' ? emptyList : sessions || []
1164
+ // 注意用 *All(含子代理):列表管线要靠它们把子代理挂到父会话下;排除子代理
1165
+ // 发生在折叠之后的顶层行与计数(见 isSubagentItem / topOnly)。
1166
+ const base = filter === 'archived' ? archivedAll : filter === 'active' ? activeAll : filter === 'starred' ? starredAll : filter === 'empty' ? emptyAll : sessions || []
1136
1167
  // T4 标签筛选(单选,v1 不追多选 AND——理由见 logic.applyTagFilter):在
1137
1168
  // 搜索/工作区之前先按标签收敛;''/未就绪时谓词原样返回,行为与旧版一致。
1138
1169
  const tagPassed = applyTagFilter(base, tagsReady ? tagFilter : '')
@@ -1149,7 +1180,7 @@ function SessionPanel({ workspacesSvc }) {
1149
1180
  if (sortBy === 'title') return effectiveTitleOf(a, dsmAuthoritativeTitles).localeCompare(effectiveTitleOf(b, dsmAuthoritativeTitles), 'zh-CN')
1150
1181
  return Number(b.createdAt || 0) - Number(a.createdAt || 0)
1151
1182
  })
1152
- }, [sessions, filter, query, workspaceFilter, sortBy, emptyList, starredList, tagFilter, tagsReady])
1183
+ }, [sessions, archivedAll, activeAll, starredAll, emptyAll, filter, query, workspaceFilter, sortBy, tagFilter, tagsReady])
1153
1184
  const selIds = Object.keys(selected).filter((k) => selected[k])
1154
1185
  // 「回收站」是独立视图,不共用会话列表。
1155
1186
  const showSessionList = filter !== 'trash'
@@ -1160,11 +1191,16 @@ function SessionPanel({ workspacesSvc }) {
1160
1191
  // 3.7.0 分支聚拢:先子代理折叠、后分支聚拢(foldBranches 的输入契约——顺序反了
1161
1192
  // 会让分支父行的子代理变孤儿弹回顶层)。链式分支拍平进同一组;缺失父出
1162
1193
  // 「来源:<短ID>」合成头;「一行不丢」由纯函数守恒不变量保证。
1163
- const { topList, kidsOf, foldedCount, branchGroupsOf, branchFolded, branchGroupCount } = useMemo(() => {
1164
- if (!groupByLineage) return { topList: list, kidsOf: new Map(), foldedCount: 0, branchGroupsOf: new Map(), branchFolded: 0, branchGroupCount: 0 }
1194
+ const { topList, kidsOf, foldedCount, branchGroupsOf, branchFolded, branchGroupCount, orphanSubagents } = useMemo(() => {
1195
+ if (!groupByLineage) return { topList: list, kidsOf: new Map(), foldedCount: 0, branchGroupsOf: new Map(), branchFolded: 0, branchGroupCount: 0, orphanSubagents: [] }
1165
1196
  const kids = foldSubagents(list, lineage)
1166
1197
  const br = foldBranches(kids.topList, lineage)
1167
- return { topList: br.topList, kidsOf: kids.kidsOf, foldedCount: kids.foldedCount, branchGroupsOf: br.branchGroupsOf, branchFolded: br.foldedCount, branchGroupCount: br.groupCount }
1198
+ // issue #18 第 1 条:分支聚拢不会动子代理(branchParentOf 明确挡掉),所以
1199
+ // 走到这里还留在顶层的子代理 = 父会话不在当前列表(已删除 / 已归档 / 被筛选)。
1200
+ // 它们先前就地平铺成「(无标题)」行;现在收进一张「未归属子代理」卡片,
1201
+ // 既不污染列表,也不会丢(展开仍可打开 / 归档 / 删除)。
1202
+ const { rows, orphans } = foldOrphanSubagents(br.topList, lineage)
1203
+ return { topList: rows, kidsOf: kids.kidsOf, foldedCount: kids.foldedCount, branchGroupsOf: br.branchGroupsOf, branchFolded: br.foldedCount, branchGroupCount: br.groupCount, orphanSubagents: orphans }
1168
1204
  }, [list, lineage, groupByLineage])
1169
1205
  // D5:query 命中折叠组内成员(或子代理)→ 渲染层强制展开——「看得见才搜得到」。
1170
1206
  const matchIds = useMemo(() => {
@@ -1177,6 +1213,9 @@ function SessionPanel({ workspacesSvc }) {
1177
1213
  }, [sessions, query])
1178
1214
  const kidsHit = (parentId) => !!(matchIds && (kidsOf.get(String(parentId)) || []).some((k) => matchIds.has(String(k.sessionId))))
1179
1215
  const branchHit = (rootId) => !!(matchIds && (branchGroupsOf.get(String(rootId)) || []).some((m) => matchIds.has(String(m.sessionId))))
1216
+ // 搜索命中未归属子代理 → 强制展开那张卡片(「看得见才搜得到」同样适用)。
1217
+ const orphanHit = !!(matchIds && orphanSubagents.some((k) => matchIds.has(String(k.sessionId))))
1218
+ const orphanOpen = openOrphans || orphanHit
1180
1219
  const syntheticCount = groupByLineage ? topList.reduce((n, it) => n + (it && it.syntheticRoot ? 1 : 0), 0) : 0
1181
1220
 
1182
1221
  // 子代理折叠分两个部件,各自贴在它该在的位置:
@@ -1205,6 +1244,29 @@ function SessionPanel({ workspacesSvc }) {
1205
1244
  // 分支标记 hover 说明来源;空白标记提示去「空白」分类清理。
1206
1245
  // 父会话标题查找走一次性 Map(sessions 会到几百条,每卡片 find 是 O(n²))。
1207
1246
  const sessionsById = useMemo(() => new Map((sessions || []).map((x) => [String(x.sessionId), x])), [sessions])
1247
+ // issue #18 第 4 条:删除确认框里的「连同 N 个子代理」。按官方血缘字段递归数
1248
+ // 一遍(只有对话框打开时才计算一次,列表渲染路径不承担这个成本)。
1249
+ const delCascadeCount = useMemo(() => {
1250
+ if (!delTarget) return 0
1251
+ const childMap = new Map()
1252
+ for (const [id, li] of Object.entries(lineage || {})) {
1253
+ if (!li || li.origin !== 'subagent' || !li.parentSession) continue
1254
+ const p = String(li.parentSession)
1255
+ if (!childMap.has(p)) childMap.set(p, [])
1256
+ childMap.get(p).push(String(id))
1257
+ }
1258
+ const seen = new Set([String(delTarget.sessionId)])
1259
+ const queue = [String(delTarget.sessionId)]
1260
+ let n = 0
1261
+ while (queue.length) {
1262
+ const cur = queue.shift()
1263
+ for (const kid of childMap.get(cur) || []) {
1264
+ if (seen.has(kid)) continue
1265
+ seen.add(kid); n++; queue.push(kid)
1266
+ }
1267
+ }
1268
+ return n
1269
+ }, [delTarget, lineage])
1208
1270
  const branchBadge = (sessionId) => {
1209
1271
  const li = lineage[String(sessionId)]
1210
1272
  if (!li || li.origin === 'subagent' || !li.parentSession) return null
@@ -1319,9 +1381,13 @@ function SessionPanel({ workspacesSvc }) {
1319
1381
 
1320
1382
  const toggle = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }))
1321
1383
  const clearSel = () => setSelected({})
1384
+ // issue #18 第 3 条:全选只选「当前列表里真正占据一行、且能单独勾选」的会话。
1385
+ // 旧实现遍历的是过滤后的原始 list(含被折叠的子代理、被聚拢的分支成员、
1386
+ // 合成头),于是全选会在界面上看不出任何变化的情况下选中一批不可见会话——
1387
+ // 用户只能靠「取消全选」撤销,批量归档/删除会打到自己没看见的东西。
1322
1388
  const selectAll = () => {
1323
1389
  const o = {}
1324
- list.forEach((x) => { o[x.sessionId] = true })
1390
+ topList.forEach((x) => { if (x && x.sessionId != null && !x.syntheticRoot) o[x.sessionId] = true })
1325
1391
  setSelected(o)
1326
1392
  }
1327
1393
 
@@ -1343,15 +1409,17 @@ function SessionPanel({ workspacesSvc }) {
1343
1409
  const doDeleteConfirmed = () => {
1344
1410
  if (!delTarget || busy) return
1345
1411
  setBusy(delTarget.sessionId)
1346
- postJSON('/archived-sessions/delete', { sessionId: delTarget.sessionId })
1347
- .then(() => {
1412
+ postJSON('/archived-sessions/delete', { sessionId: delTarget.sessionId, cascadeSubagents: cascadeSub })
1413
+ .then((r) => {
1348
1414
  setBusy(null)
1349
1415
  const n = delTarget.title || delTarget.sessionId
1350
- showToast(`已删除 ${n}(已移入回收站)`)
1351
- const sid = String(delTarget.sessionId)
1416
+ const cascaded = Number((r && r.cascaded) || 0)
1417
+ showToast(`已删除 ${n}${cascaded ? `(连同 ${cascaded} 个子代理会话)` : ''}(已移入回收站)`)
1418
+ const gone = new Set([String(delTarget.sessionId), ...((r && r.cascadedIds) || []).map(String)])
1352
1419
  setDelTarget(null)
1353
- // 删除进入回收站:本地移除该行 + 单刷回收站,不整表 refresh。
1354
- setSessions((s) => s && s.filter((x) => String(x.sessionId) !== sid))
1420
+ // 删除进入回收站:本地移除这些行(含被连带回收的子代理)+ 单刷回收站,
1421
+ // 不整表 refresh——refresh 会清空多选状态。
1422
+ setSessions((s) => s && s.filter((x) => !gone.has(String(x.sessionId))))
1355
1423
  loadTrash()
1356
1424
  dsmLoadTrashIds()
1357
1425
  })
@@ -1430,7 +1498,10 @@ function SessionPanel({ workspacesSvc }) {
1430
1498
  postJSON('/archived-sessions/trash/restore', { sessionId: sid })
1431
1499
  .then((r) => {
1432
1500
  setTrashBusy(null)
1433
- showToast(r && r.workspaceGone ? '已恢复会话(原工作区已删除,会话暂归「未分组」)' : '已恢复会话')
1501
+ // 子代理随父会话一起回来时如实说明,避免用户以为只恢复了一条。
1502
+ const cascaded = Number((r && r.cascaded) || 0)
1503
+ const base = r && r.workspaceGone ? '已恢复会话(原工作区已删除,会话暂归「未分组」)' : '已恢复会话'
1504
+ showToast(base + (cascaded ? `,连同 ${cascaded} 个子代理会话` : ''))
1434
1505
  loadTrash()
1435
1506
  refresh()
1436
1507
  })
@@ -1513,7 +1584,9 @@ function SessionPanel({ workspacesSvc }) {
1513
1584
  .then((r) => {
1514
1585
  setBusy(null)
1515
1586
  const n = (r && (r.archived || r.restored || r.deleted)) || selIds.length
1516
- showToast(`已处理 ${n} 个会话`)
1587
+ // 批量删默认也级联子代理:如实说明连带回收了多少,避免「数量对不上」。
1588
+ const cascaded = Number((r && r.cascaded) || 0)
1589
+ showToast(`已处理 ${n} 个会话${cascaded ? `(连同 ${cascaded} 个子代理会话)` : ''}`)
1517
1590
  refresh()
1518
1591
  })
1519
1592
  .catch((e) => { setBusy(null); setConfirmBatch(false); setError(String((e && e.message) || e)) })
@@ -1699,7 +1772,7 @@ function SessionPanel({ workspacesSvc }) {
1699
1772
  onClick={copyBuildStamp}
1700
1773
  >v{String(capabilities.buildStamp).split('+')[0]}</span>
1701
1774
  : null}
1702
- {sessions !== null && <span className="archv-count" aria-label={`${sessions.length} 个会话`}>{sessions.length}</span>}
1775
+ {sessions !== null && <span className="archv-count" title={groupByLineage ? '子代理会话折叠在父会话下,不计入此数' : undefined} aria-label={`${totalTopCount} 个会话`}>{totalTopCount}</span>}
1703
1776
  </div>
1704
1777
  <p className="archv-sub">
1705
1778
  统一管理全部会话:归档 / 恢复 / 移动到其他工作区 / 会话详情 / 批量操作,删除会先进入回收站,可在回收站内恢复或彻底清理。
@@ -1727,7 +1800,7 @@ function SessionPanel({ workspacesSvc }) {
1727
1800
  <>
1728
1801
  <div className={'sess-fwrap' + (moreOpen ? ' sess-fmore-open' : '') + (filter === 'starred' || filter === 'empty' || filter === 'trash' ? ' sess-fsec-on' : '')}>
1729
1802
  <div className="sess-filter" role="tablist" aria-label="会话筛选">
1730
- <button type="button" role="tab" aria-selected={filter === 'all'} className={'sess-fbtn' + (filter === 'all' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('all'); clearSel(); setConfirmBatch(false) }}>全部 ({sessions.length})</button>
1803
+ <button type="button" role="tab" aria-selected={filter === 'all'} className={'sess-fbtn' + (filter === 'all' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('all'); clearSel(); setConfirmBatch(false) }}>全部 ({totalTopCount})</button>
1731
1804
  <button type="button" role="tab" aria-selected={filter === 'active'} className={'sess-fbtn' + (filter === 'active' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('active'); clearSel(); setConfirmBatch(false) }}>活动 ({activeList.length})</button>
1732
1805
  <button type="button" role="tab" aria-selected={filter === 'archived'} className={'sess-fbtn' + (filter === 'archived' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('archived'); clearSel(); setConfirmBatch(false) }}>已归档 ({archivedList.length})</button>
1733
1806
  <span className="sess-fmore" role="tablist" aria-label="更多会话筛选">
@@ -1998,7 +2071,7 @@ function SessionPanel({ workspacesSvc }) {
1998
2071
  {foldedCount || branchFolded
1999
2072
  ? `,另有 ${[foldedCount ? `${foldedCount} 个子代理折叠在父会话下` : '', branchFolded ? `${branchFolded} 个分支聚成 ${branchGroupCount} 组` : ''].filter(Boolean).join('、')}`
2000
2073
  : query || workspaceFilter !== 'all' || tagFilter
2001
- ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : filter === 'empty' ? emptyList.length : sessions.length} 个`
2074
+ ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : filter === 'empty' ? emptyList.length : totalTopCount} 个`
2002
2075
  : (topList.length !== list.length ? `,共 ${list.length} 个` : '')}
2003
2076
  </span>
2004
2077
  {/* T4:计数行右侧的保存筛选控件组。保存 = 抓 {view,workspace,
@@ -2054,10 +2127,12 @@ function SessionPanel({ workspacesSvc }) {
2054
2127
  </>
2055
2128
  )}
2056
2129
 
2057
- {showSessionList && list.length > 0 && (
2130
+ {showSessionList && (topList.length > 0 || orphanSubagents.length > 0) && (
2058
2131
  <div className={'sess-batch' + (selIds.length > 0 ? ' sess-batch-pin' : '')}>
2059
- <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : filter === 'starred' ? `共 ${starredList.length} 个收藏会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
2060
- <button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
2132
+ <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : filter === 'starred' ? `共 ${starredList.length} 个收藏会话` : `共 ${totalTopCount} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
2133
+ {/* issue #18 第 3 条:全选只覆盖当前列表里能单独勾选的行;折叠中的
2134
+ 子代理 / 分支成员与合成头不参与,避免「选中了看不见的东西」。 */}
2135
+ <button type="button" className="archv-btn" disabled={topList.length === 0} title={groupByLineage ? '选中当前列表的会话行(折叠中的子代理与分支成员不会被选中)' : undefined} onClick={selectAll}>全选</button>
2061
2136
  {selIds.length > 0 && (
2062
2137
  <>
2063
2138
  {filter === 'archived' && (
@@ -2137,7 +2212,7 @@ function SessionPanel({ workspacesSvc }) {
2137
2212
  </div>
2138
2213
  )}
2139
2214
 
2140
- {showSessionList && list.length === 0 ? (
2215
+ {showSessionList && topList.length === 0 && orphanSubagents.length === 0 ? (
2141
2216
  <div className="archv-empty">{query || workspaceFilter !== 'all' || tagFilter ? '没有匹配的会话。请调整搜索词、标签或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : filter === 'empty' ? '没有空白会话。新开会话还没产生内容时会归到这里,侧栏会自动隐藏它们。' : '暂无可管理的会话。'}</div>
2142
2217
  ) : showSessionList ? (
2143
2218
  <div className="archv-list" role="list">
@@ -2150,18 +2225,25 @@ function SessionPanel({ workspacesSvc }) {
2150
2225
  const open = !!openBranches[root] || branchHit(root)
2151
2226
  const srcTitle = dsmAuthoritativeTitles.get(root)
2152
2227
  return (
2153
- <div key={it.sessionId} className="dsm-src-head" role="listitem">
2154
- <button
2155
- type="button"
2156
- className="dsm-kids-toggle"
2157
- aria-expanded={open}
2158
- title={(open ? '收起 ' : '展开 ') + members.length + ' 个分支会话(来源会话不在当前列表)'}
2159
- onClick={() => toggleBranch(root)}
2160
- >
2161
- {open ? '▾' : '▸'} {members.length} 分支
2162
- </button>
2163
- <span className="dsm-src-name" title={root}>{'来源:' + (srcTitle || shortId(root))}</span>
2164
- </div>
2228
+ // issue #18 第 2 条:合成头先前在这里提前 return,只渲染按钮与
2229
+ // 「来源:」,展开时什么都不会出现(成员列表只挂在普通卡片的
2230
+ // renderBranchGroup 上)。这里补上同一个成员渲染器——按钮、
2231
+ // 展开状态、搜索命中展开都复用分支组的既有语义。
2232
+ <React.Fragment key={it.sessionId}>
2233
+ <div className="dsm-src-head" role="listitem">
2234
+ <button
2235
+ type="button"
2236
+ className="dsm-kids-toggle"
2237
+ aria-expanded={open}
2238
+ title={(open ? '收起 ' : '展开 ') + members.length + ' 个分支会话(来源会话不在当前列表)'}
2239
+ onClick={() => toggleBranch(root)}
2240
+ >
2241
+ {open ? '▾' : '▸'} {members.length} 分支
2242
+ </button>
2243
+ <span className="dsm-src-name" title={root}>{'来源:' + (srcTitle || shortId(root))}</span>
2244
+ </div>
2245
+ {renderBranchGroup(root)}
2246
+ </React.Fragment>
2165
2247
  )
2166
2248
  }
2167
2249
  const date = fmtDate(it.createdAt)
@@ -2375,6 +2457,63 @@ function SessionPanel({ workspacesSvc }) {
2375
2457
  </div>
2376
2458
  )
2377
2459
  })}
2460
+ {/* issue #18 第 1 条:父会话不在当前列表(已删除 / 已归档 / 被筛选)
2461
+ 的子代理不再平铺成上百个「(无标题)」行,统一收进这一张卡片:
2462
+ 默认收起不占版面,展开后逐条可打开 / 归档 / 删除,一条都不丢。 */}
2463
+ {groupByLineage && orphanSubagents.length > 0 && (
2464
+ <div className="archv-card dsm-orphan-card" role="listitem">
2465
+ <div className="archv-row">
2466
+ <div className="archv-body">
2467
+ <div className="archv-main">
2468
+ <div className="archv-titlerow">
2469
+ <button
2470
+ type="button"
2471
+ className="dsm-kids-toggle"
2472
+ aria-expanded={orphanOpen}
2473
+ title={(orphanOpen ? '收起 ' : '展开 ') + orphanSubagents.length + ' 个未归属的子代理会话'}
2474
+ onClick={() => setOpenOrphans(!openOrphans)}
2475
+ >
2476
+ {orphanOpen ? '▾' : '▸'} {orphanSubagents.length} 子代理
2477
+ </button>
2478
+ <span className="archv-name">未归属的子代理会话</span>
2479
+ </div>
2480
+ <div className="archv-meta">
2481
+ <span className="archv-wtag archv-wgone">父会话不在当前列表</span>
2482
+ <span className="archv-dot">·</span>
2483
+ <span className="archv-date">不再混进上面的会话列表;展开后可打开 / 归档 / 删除</span>
2484
+ </div>
2485
+ </div>
2486
+ </div>
2487
+ </div>
2488
+ {orphanOpen && (
2489
+ <div className="dsm-kids" aria-label="未归属子代理会话">
2490
+ {orphanSubagents.map((k) => {
2491
+ const li = lineage[String(k.sessionId)]
2492
+ return (
2493
+ <div className="dsm-kid" key={k.sessionId}>
2494
+ <div className="dsm-kid-row">
2495
+ <span className="dsm-kid-name" title={k.title || k.sessionId}>{k.title || '(无标题)'}</span>
2496
+ {emptyBadge(k.sessionId)}
2497
+ {tagChips(k.sessionId)}
2498
+ <span className="dsm-kid-meta">{k.archived ? '已归档' : '活动'}{fmtDate(k.createdAt) ? ` · ${fmtDate(k.createdAt)}` : ''}</span>
2499
+ <span className="dsm-kid-acts">
2500
+ <button type="button" className="archv-btn" disabled={busy !== null} title="切换到这个子代理会话(设置面板挡着会话区,关掉即可看到)" onClick={async () => {
2501
+ const name = k.title || (String(k.sessionId).slice(0, 8) + '…')
2502
+ const res = await dsmOpenSessionById(k.sessionId, (li && li.parentSession) || null)
2503
+ const msg = openSubagentToast(res, name, 'panel')
2504
+ showToast(msg.text, msg.kind)
2505
+ }}>打开</button>
2506
+ <button type="button" className="archv-btn" disabled={busy !== null} onClick={() => act(k.archived ? 'restore' : 'archive', k)}>{k.archived ? '恢复' : '归档'}</button>
2507
+ <button type="button" className="archv-btn archv-del" disabled={busy !== null} title="移入回收站,可在回收站恢复" onClick={() => setDelTarget(k)}>删除</button>
2508
+ </span>
2509
+ </div>
2510
+ </div>
2511
+ )
2512
+ })}
2513
+ </div>
2514
+ )}
2515
+ </div>
2516
+ )}
2378
2517
  </div>
2379
2518
  ) : null}
2380
2519
  </>
@@ -2385,6 +2524,12 @@ function SessionPanel({ workspacesSvc }) {
2385
2524
  <div ref={dialogRef} className="dlg" role="alertdialog" aria-modal="true" aria-label="删除会话" onClick={(e) => e.stopPropagation()}>
2386
2525
  <h3 className="dlg-title">删除会话</h3>
2387
2526
  <p className="dlg-text">确认删除「{delTarget.title || delTarget.sessionId}」?将移入回收站,可在本页底部「回收站」中恢复或彻底删除。</p>
2527
+ {delCascadeCount > 0 && (
2528
+ <label className="dlg-check">
2529
+ <input type="checkbox" checked={cascadeSub} disabled={busy !== null} onChange={(e) => setCascadeSub(e.target.checked)} />
2530
+ <span>同时删除它的 {delCascadeCount} 个子代理会话(子代理生命周期跟随父会话)。取消勾选则保留,它们会收进「未归属的子代理会话」卡片,仍可单独处理。</span>
2531
+ </label>
2532
+ )}
2388
2533
  <div className="dlg-actions">
2389
2534
  <button type="button" className="archv-btn" disabled={busy !== null} onClick={() => setDelTarget(null)}>取消</button>
2390
2535
  <button type="button" className="archv-btn archv-del" disabled={busy !== null} onClick={doDeleteConfirmed}>移入回收站</button>
@@ -2588,6 +2733,9 @@ function dsmToggleManual(id) {
2588
2733
  // up under DSH's "未分组" group. Loaded from the backend on a throttle and
2589
2734
  // updated optimistically when our own ⋯-menu deletes a session.
2590
2735
  let dsmTrashIds = null
2736
+ // issue #16:回收站按 cwd 的条数(host 随 sidebar-state 下发)。折叠分组下
2737
+ // 上游把 group.sessions 收回成空数组,展开计数只能靠它来扣掉插件会隐藏的行。
2738
+ let dsmTrashCountByCwd = null
2591
2739
  let dsmServerPurgedIds = new Set()
2592
2740
  let dsmAuthoritativeTitles = new Map()
2593
2741
  let dsmTrashTick = 0
@@ -2648,6 +2796,11 @@ async function dsmLoadTrashIds() {
2648
2796
  try {
2649
2797
  const r = await postJSON('/archived-sessions/sidebar-state', {})
2650
2798
  dsmTrashIds = new Set(((r && r.trashedSessionIds) || []).map(String))
2799
+ // issue #16:折叠分组的展开计数要用「组内总数 − 本组回收站条数」,
2800
+ // 这份按 cwd 的统计由 host 计算后随同一响应下发(老 host 没有该字段 → null)。
2801
+ dsmTrashCountByCwd = r && r.trashedCountByCwd && typeof r.trashedCountByCwd === 'object'
2802
+ ? new Map(Object.entries(r.trashedCountByCwd).map(([k, v]) => [String(k), Number(v) || 0]))
2803
+ : null
2651
2804
  dsmServerPurgedIds = new Set(((r && r.purgedSessionIds) || []).map(String))
2652
2805
  // 服务器已确认的乐观标记随即清除;未被确认的标记最多存活到下次刷新,
2653
2806
  // 绝不永久压住后来同 id 的新会话。
@@ -3187,6 +3340,58 @@ function installSidebarStatusDots() {
3187
3340
  }
3188
3341
  return true
3189
3342
  }
3343
+ // issue #16 补丁:本组「会话行」的收集器。
3344
+ //
3345
+ // 原先各处一律按 `scope.children` 找会话行,隐含假设「上游把 `[role=treeitem]`
3346
+ // 直接放在分组容器下」。在 0.2.1-rc.2 的侧栏里这个假设不成立:上游把每个会话行
3347
+ // 包进一层 `<span class="_root_*">` 再放进 `groupSection`,于是 `children` 拿到
3348
+ // 的全是 SPAN(没有 role、没有 data-row-key)。
3349
+ //
3350
+ // 后果不是「少算几个数」,而是整段校准被短路:`rendered` 恒为空 → 命中
3351
+ // 「DOM 里没有会话行」的保护分支直接 return → 按钮上的 data-dsm-* 一个都不写、
3352
+ // 计数永远不校准。用户看到的就是「点了展开没反应」或数字与实际不符。
3353
+ //
3354
+ // 因此改为:先按直接子节点找;一个都没找到时,再向下找(wrapper),
3355
+ // 仍要求命中 `[role=treeitem][data-row-key^="session:"]`,且不跨越嵌套子分组。
3356
+ const groupSessionRows = (scope, btn) => {
3357
+ const out = []
3358
+ if (!scope || !scope.children) return out
3359
+ for (const el of scope.children) {
3360
+ if (el === btn || !el.getAttribute) continue
3361
+ const k = el.getAttribute('data-row-key') || ''
3362
+ if (el.getAttribute('role') === 'treeitem' && k.startsWith('session:')) out.push(el)
3363
+ }
3364
+ if (out.length) return out
3365
+ // wrapper 结构兜底:只在「直接子节点没有会话行」时启用,避免与上一种形态重复计数。
3366
+ let rows = null
3367
+ try {
3368
+ rows = scope.querySelectorAll('[role="treeitem"][data-row-key^="session:"]')
3369
+ } catch (e) { return out }
3370
+ for (const el of rows) {
3371
+ // 跳过属于嵌套子分组的行:它最近的分组容器不是本 scope。
3372
+ let owner = el.parentElement
3373
+ let ok = true
3374
+ while (owner && owner !== scope) {
3375
+ if (owner.getAttribute && owner.getAttribute('role') === 'group') { ok = false; break }
3376
+ owner = owner.parentElement
3377
+ }
3378
+ if (ok) out.push(el)
3379
+ }
3380
+ return out
3381
+ }
3382
+ // 分组容器定位:从工作区标题行向上找「无 role 的普通容器」(顶层分组的
3383
+ // groupSection)。上游可能把标题行包在 wrapper span 里,因此不能只取
3384
+ // parentElement —— 那会拿到 SPAN,使空分组判定整段失效。
3385
+ const groupContainerOf = (row) => {
3386
+ let el = row ? row.parentElement : null
3387
+ for (let depth = 0; el && depth < 4; depth++) {
3388
+ if (typeof el.getAttribute !== 'function') return null
3389
+ // role="group" 是子分组容器,role="tree" 是列表根,都不能当顶层组容器。
3390
+ if (!el.getAttribute('role')) return el
3391
+ el = el.parentElement
3392
+ }
3393
+ return null
3394
+ }
3190
3395
  function dsmSyncEmptyGroups(purgedNow) {
3191
3396
  let rows = null
3192
3397
  try { rows = document.querySelectorAll('[role="treeitem"][data-row-key^="workspace:"]') } catch (e) { return }
@@ -3204,17 +3409,17 @@ function installSidebarStatusDots() {
3204
3409
  }
3205
3410
  rows.forEach((row) => {
3206
3411
  try {
3207
- const scope = row.parentElement
3208
- if (!scope || scope === document.body) return
3209
3412
  // 容器必须是无 role 的普通元素(顶层分组)。子分组容器 role="group"
3210
3413
  // 里装的是整段子分组,不能当组容器用——拿它当容器会把整段子分组藏掉。
3211
- if (typeof scope.getAttribute !== 'function' || scope.getAttribute('role')) return
3414
+ // 上游把标题行包在 wrapper span 里时 parentElement 不是组容器,
3415
+ // 因此向上找到真正的 groupSection(见 groupContainerOf)。
3416
+ const scope = groupContainerOf(row)
3417
+ if (!scope || scope === document.body) return
3212
3418
  // 组内还有没被我们藏起来的会话行 → 绝不藏(也顺手还原)。
3419
+ // 行可能被上游包在 wrapper span 里(见 groupSessionRows 的说明),
3420
+ // 因此用收集器而不是直接遍历 scope.children。
3213
3421
  let rendered = 0
3214
- for (const el of scope.children) {
3215
- if (el === row || !el.getAttribute) continue
3216
- if (el.getAttribute('role') !== 'treeitem') continue
3217
- if (!(el.getAttribute('data-row-key') || '').startsWith('session:')) continue
3422
+ for (const el of groupSessionRows(scope, row)) {
3218
3423
  if (el.hasAttribute(DSM_HIDDEN_ATTR)) continue
3219
3424
  rendered++
3220
3425
  }
@@ -3261,12 +3466,10 @@ function installSidebarStatusDots() {
3261
3466
  let n = 0
3262
3467
  let firstKey = ''
3263
3468
  let lastKey = ''
3264
- for (const el of scope.children) {
3265
- if (el === btn || !el.getAttribute) continue
3266
- const k = el.getAttribute('data-row-key') || ''
3267
- if (!k.startsWith('session:')) continue
3469
+ for (const el of groupSessionRows(scope, btn)) {
3268
3470
  if (el.hasAttribute(DSM_HIDDEN_ATTR)) continue
3269
3471
  n++
3472
+ const k = el.getAttribute('data-row-key') || ''
3270
3473
  if (!firstKey) firstKey = k
3271
3474
  lastKey = k
3272
3475
  }
@@ -3287,9 +3490,17 @@ function installSidebarStatusDots() {
3287
3490
  const sig = overflowSignature(scope, btn, purgedNow)
3288
3491
  if (OVERFLOW_SIGS.get(btn) === sig) return
3289
3492
  const key = (btn.getAttribute('data-row-key') || '').slice('overflow:'.length)
3493
+ // 工作区标题行:先按直接子节点找;上游 0.2.1-rc.2 把它也包进 wrapper 时
3494
+ // 再向下找一层(与 groupSessionRows 同一套兜底思路)。
3495
+ const wsKey = 'workspace:' + key
3290
3496
  let wsRow = null
3291
3497
  for (const el of scope.children) {
3292
- if (el.getAttribute && el.getAttribute('data-row-key') === 'workspace:' + key) { wsRow = el; break }
3498
+ if (el.getAttribute && el.getAttribute('data-row-key') === wsKey) { wsRow = el; break }
3499
+ }
3500
+ if (!wsRow) {
3501
+ try {
3502
+ wsRow = scope.querySelector('[data-row-key="' + wsKey.replace(/"/g, '\\"') + '"]')
3503
+ } catch (e) { wsRow = null }
3293
3504
  }
3294
3505
  const group = wsRow ? sidebarAdapter.recognizeGroupQuiet(wsRow) : null
3295
3506
  const nodes = group && Array.isArray(group.sessions) ? group.sessions : null
@@ -3299,9 +3510,19 @@ function installSidebarStatusDots() {
3299
3510
  OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3300
3511
  return
3301
3512
  }
3513
+ // 分组折叠时上游把 `sessions` 收回成空数组(`expanded ? [...].map(...) : []`),
3514
+ // 所以「空数组」不等于「这组没有会话」。若照旧走下面的空集保护,折叠态的
3515
+ // 分组永远不会被校准 —— 而展开按钮本身就只在折叠态出现,等于整段逻辑永久失效。
3516
+ //
3517
+ // 上游同时给了 `sessionCount`(= 组内会话总数,与展开状态无关),用它来判定:
3518
+ // 到底是真的没会话,还是只是还没展开。注意 `sessionCount` 已过上游的
3519
+ // `sessionVisible` 过滤(排除 archived 与空白会话),但仍可能包含本插件
3520
+ // 软删到回收站、尚未从会话列表摘掉的条目 —— 所以算可见数时要再扣一次。
3521
+ const groupCount = group && typeof group.sessionCount === 'number' ? group.sessionCount : null
3522
+ const collapsed = !nodes.length && groupCount != null && groupCount > 0
3302
3523
  // 本组「上游会列出、且不会被插件藏起来」的会话集合。
3303
3524
  const ids = new Set()
3304
- for (const node of nodes) {
3525
+ for (const node of (nodes && nodes.length ? nodes : [])) {
3305
3526
  if (!node || node.blank) continue
3306
3527
  const id = node.id != null ? String(node.id) : ''
3307
3528
  if (!id) continue
@@ -3310,25 +3531,54 @@ function installSidebarStatusDots() {
3310
3531
  if (li && li.empty) continue
3311
3532
  ids.add(id)
3312
3533
  }
3313
- // 已渲染进 DOM 的可见行:只认本组直接子节点里不在隐藏标记中的会话行。
3534
+ // 已渲染进 DOM 的可见行:本组会话行里不在隐藏标记中的那些。
3314
3535
  // 用 `data-dsm-hidden` 而非 style.display 判定——上游折叠父级时行自身
3315
3536
  // 的 display 仍是空串,按 style 判会把看不见的行算成已渲染。
3316
3537
  const rendered = []
3317
- for (const el of scope.children) {
3318
- if (el === btn || !el.getAttribute) continue
3319
- if (el.getAttribute('role') !== 'treeitem') continue
3538
+ for (const el of groupSessionRows(scope, btn)) {
3320
3539
  const k = el.getAttribute('data-row-key') || ''
3321
- if (!k.startsWith('session:')) continue
3322
3540
  rendered.push({ id: k.slice('session:'.length), pluginHidden: el.hasAttribute(DSM_HIDDEN_ATTR) })
3323
3541
  }
3324
3542
  // 空集/口径不匹配的保护:数据层说「一条都没有」但 DOM 里明明有会话行,
3325
3543
  // 说明 group.sessions 的语义变了(懒加载、上游又换了过滤条件)。此时按
3326
3544
  // 未知处理,绝不能算出 0 把按钮藏掉 —— 那是把可见会话变成不可达。
3327
- if (!nodes.length || (!ids.size && rendered.length)) {
3545
+ // 折叠态(collapsed)不算「空集」:那是上游把 sessions 收回的正常表现,
3546
+ // 继续走下面的计数分支,否则展开按钮永远得不到校准。
3547
+ if (!nodes.length && !collapsed) {
3328
3548
  restoreOverflowButton(btn)
3329
3549
  OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3330
3550
  return
3331
3551
  }
3552
+ if (!ids.size && rendered.length) {
3553
+ restoreOverflowButton(btn)
3554
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3555
+ return
3556
+ }
3557
+ // 折叠态:上游把 `group.sessions` 收回成空数组,而展开按钮正是这时候
3558
+ // 出现的 —— 也就是说「数据层拿不到成员」和「按钮需要校准」同时发生,
3559
+ // 旧实现于是在这里直接 return,展开计数永远得不到校准(issue #16 的
3560
+ // 「点开没反应 / 数字不动」就是这么来的)。
3561
+ //
3562
+ // 折叠时不再依赖成员 id,改用两条与展开状态无关的信息:
3563
+ // 1) `group.sessionCount` —— 组内会话总数(上游按工作区统计);
3564
+ // 2) `dsmTrashCountByCwd` —— host 下发的「本工作区回收站条数」。
3565
+ // 两者相减即为展开后实际会出现的行数;折叠时 DOM 里没有会话行,
3566
+ // 已渲染可见数按 0 计。
3567
+ if (collapsed) {
3568
+ const wsCwd = group && typeof group.cwd === 'string' ? group.cwd : null
3569
+ const trashInGroup = wsCwd != null && dsmTrashCountByCwd ? (dsmTrashCountByCwd.get(wsCwd) || 0) : null
3570
+ const visibleTotal = collapsedVisibleTotal(groupCount, trashInGroup)
3571
+ if (visibleTotal == null) {
3572
+ // 老 host / 拿不到 cwd:不猜数字,保持上游原文案。
3573
+ restoreOverflowButton(btn)
3574
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3575
+ return
3576
+ }
3577
+ const more = remainingRows(visibleTotal, 0)
3578
+ applyOverflowPlan(btn, more, '折叠 总数 ' + groupCount + ' / 回收站 ' + trashInGroup)
3579
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3580
+ return
3581
+ }
3332
3582
  const renderedVisible = countRenderedVisible(rendered, ids)
3333
3583
  const more = remainingRows(ids.size, renderedVisible)
3334
3584
  applyOverflowPlan(btn, more, '可见 ' + ids.size + ' / 已渲染 ' + renderedVisible)