dsh-sessions-manager 3.7.6 → 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.
@@ -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 { applyTagFilter, canDropOnWorkspace, dotStateFor, effectiveTitleOf, filterShapeFromSaved, filterSnapshotOf, foldBranches, foldSubagents, noticeToastPlan, openSubagentToast, pathTail, 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 的新会话。
@@ -2726,6 +2879,24 @@ function dsmTeardownSidebarAug() {
2726
2879
  for (const sel of selectors) {
2727
2880
  try { document.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) { /* best-effort */ }
2728
2881
  }
2882
+ try {
2883
+ // issue #16:被隐藏的会话行与展开按钮在卸载时还原——adapter 停用(上游
2884
+ // 改版)或插件停用后,不能把「看不见的会话」留在官方侧栏上。
2885
+ document.querySelectorAll('[data-dsm-hidden]').forEach((row) => {
2886
+ row.style.display = ''
2887
+ row.removeAttribute('data-dsm-hidden')
2888
+ })
2889
+ document.querySelectorAll('[data-dsm-overflow-hidden], [data-dsm-overflow-base]').forEach((btn) => {
2890
+ // 文案可能被我们改写过:dataset 里存着上游原文,卸载时连文案一起还原。
2891
+ const base = btn.getAttribute('data-dsm-overflow-base')
2892
+ if (base != null && btn.textContent !== base) btn.textContent = base
2893
+ btn.style.display = ''
2894
+ btn.removeAttribute('data-dsm-overflow-hidden')
2895
+ btn.removeAttribute('data-dsm-overflow-base')
2896
+ btn.removeAttribute('data-dsm-overflow-write')
2897
+ btn.removeAttribute('data-dsm-overflow-ver')
2898
+ })
2899
+ } catch (e) { /* best-effort */ }
2729
2900
  try {
2730
2901
  document.querySelectorAll('[data-dsm-group-hidden]').forEach((owner) => {
2731
2902
  owner.style.display = owner.dataset.dsmPrevDisplay || ''
@@ -2770,11 +2941,26 @@ function createSidebarAdapter() {
2770
2941
  }
2771
2942
  return out
2772
2943
  }
2944
+ // 只取 `group` 的静默识别:命中即停,**不参与 miss 预算**。展开计数的
2945
+ // workspace 行识别属非关键路径(拿不到就保持上游原文案),不能让它的落空
2946
+ // 把整套侧栏增强熔断;命中即返也省掉继续上溯整条 fiber 链。
2947
+ const recognizeGroupQuiet = (row) => {
2948
+ if (disabled || !row || typeof row !== 'object') return null
2949
+ let f = findFiber(row)
2950
+ let guard = 0
2951
+ while (f && guard++ < 300) {
2952
+ const props = f.memoizedProps
2953
+ if (props && props.group && typeof props.group === 'object') return props.group
2954
+ f = f.return
2955
+ }
2956
+ return null
2957
+ }
2773
2958
  return {
2774
2959
  version: SIDEBAR_ADAPTER_VERSION,
2775
2960
  get disabled() { return disabled },
2776
2961
  findFiber,
2777
2962
  recognize,
2963
+ recognizeGroupQuiet,
2778
2964
  }
2779
2965
  }
2780
2966
  const sidebarAdapter = createSidebarAdapter()
@@ -3060,6 +3246,25 @@ function installSidebarSessionMenuAug() {
3060
3246
  function installSidebarStatusDots() {
3061
3247
  if (typeof document === 'undefined') return
3062
3248
  const DOT = 'data-dsm-dot'
3249
+ // issue #16:被插件隐藏的行打标记;展开计数的扣减统计靠它(数据层不可用时兜底)。
3250
+ const DSM_HIDDEN_ATTR = 'data-dsm-hidden'
3251
+ // 空分组:整组被我们藏起时打在**分组容器**上的标记(见 dsmSyncEmptyGroups)。
3252
+ const GROUP_UNIT_ATTR = 'data-dsm-group-hidden'
3253
+ // 展开按钮上的数据集:上游原文(基线)、上次写入值、口径版本、是否被我们藏起。
3254
+ const DSM_OVERFLOW_BASE = 'data-dsm-overflow-base'
3255
+ const DSM_OVERFLOW_WRITE = 'data-dsm-overflow-write'
3256
+ const DSM_OVERFLOW_VER = 'data-dsm-overflow-ver'
3257
+ const DSM_OVERFLOW_HIDDEN = 'data-dsm-overflow-hidden'
3258
+ let overflowDebugged = false
3259
+ const debugOverflowOnce = (msg) => {
3260
+ if (overflowDebugged) return
3261
+ overflowDebugged = true
3262
+ try { console.debug('[dsh-sessions-manager] 展开计数:' + msg) } catch (e) { /* 控制台不可用 */ }
3263
+ }
3264
+ // 展开按钮的文案基线 / 上次写入值存在 dataset(而非 WeakMap):HMR / 插件
3265
+ // 重装后新闭包里 WeakMap 是空的,会把「已扣减的文案」再当上游原文扣一次;
3266
+ // dataset 跟随节点存活,卸载时还能据此还原原文案。data-dsm-* 不在
3267
+ // MutationObserver 的 attributeFilter 里,不会自触发重绘。
3063
3268
  if (!document.querySelector('style[data-dsm=aug]')) {
3064
3269
  const s = document.createElement('style')
3065
3270
  s.dataset.dsm = 'aug'
@@ -3093,37 +3298,347 @@ function installSidebarStatusDots() {
3093
3298
  // 行;官方(重命名等)改写文本后所有权移交,绝不覆盖 DSH Core 的实时更新。
3094
3299
  const titleBaselines = new WeakMap()
3095
3300
  // 空分组兜底:DSH 会把「日志已不在原处」的会话重新归到「未分组」组里(删
3096
- // 除后最明显)。我们把行隐藏了,但空组标题会留下来——所以组内所有行都被
3097
- // 隐藏时,把整个分组(含它的标题行)一起隐藏;组内重新出现可见行时自动
3098
- // 还原。只在「组内确实有行」时才判定,避免把正在加载的空组误藏。
3099
- // 原 display 记在 owner 的 dataset 上(见下方 dsmSyncEmptyGroups)。
3100
- function dsmSyncEmptyGroups() {
3101
- let groups = null
3102
- try { groups = document.querySelectorAll('[role="group"]') } catch (e) { return }
3103
- if (!groups || !groups.length) return
3104
- groups.forEach((g) => {
3105
- const items = g.querySelectorAll('[role="treeitem"]')
3106
- if (!items.length) return
3107
- let visible = 0
3108
- items.forEach((it) => { if (it.style.display !== 'none') visible++ })
3109
- // ARIA tree:group 通常嵌在它的标题 treeitem 里,藏标题即藏整组。
3110
- // 原 display 存进 dataset(而非 WeakMap):清场/还原函数无法遍历
3111
- // WeakMap,dataset 让「谁被我们藏过、原来是什么」随时可查可还原。
3112
- const owner = g.closest('[role="treeitem"]') || g
3113
- if (visible === 0) {
3114
- if (!owner.hasAttribute('data-dsm-group-hidden')) {
3115
- owner.dataset.dsmPrevDisplay = owner.style.display || ''
3116
- owner.setAttribute('data-dsm-group-hidden', '')
3117
- }
3118
- if (owner.style.display !== 'none') owner.style.display = 'none'
3119
- } else if (owner.hasAttribute('data-dsm-group-hidden')) {
3120
- const prev = owner.dataset.dsmPrevDisplay || ''
3121
- if (owner.style.display !== prev) owner.style.display = prev
3122
- owner.removeAttribute('data-dsm-group-hidden')
3123
- delete owner.dataset.dsmPrevDisplay
3301
+ // 除后最明显)。我们把行隐藏了,但空组标题会留下来——所以组内没有**可能
3302
+ // 出现**的会话时,把整个分组容器一起隐藏;重新出现时自动还原。
3303
+ //
3304
+ // 容器定位:各版本的顶层分组容器都没有稳定的 role/class —— `role="group"`
3305
+ // 从 0.1.7 到 0.2.1 只出现在**子分组**容器上(一个包着整段子分组的 div),
3306
+ // 顶层分组是无 role 的普通 div(`groupSection`)。所以以
3307
+ // `data-row-key="workspace:*"`(0.1.7 起稳定)为锚点,取它的父元素当容器:
3308
+ // 本组的会话行与展开按钮都是这个容器的子节点。
3309
+ //
3310
+ // 判定走数据层(与展开计数同一口径):只有 group.sessions 里「非空白、且
3311
+ // 不在回收站/墓碑/空白集合」的条数确实为 0 才藏。只看 DOM 会把「上游折叠
3312
+ // 后还没渲染出来的可见会话」误判成空组,连标题带展开按钮一起藏掉——那些
3313
+ // 会话就再也点不到了。
3314
+ //
3315
+ // 原 display 记在容器的 dataset 上(而不是 WeakMap):还原路径无法遍历
3316
+ // WeakMap,dataset 让「谁被我们藏过、原来是什么」随时可查可还原。
3317
+ const setGroupHidden = (scope, hidden) => {
3318
+ if (hidden) {
3319
+ if (!scope.hasAttribute(GROUP_UNIT_ATTR)) {
3320
+ scope.dataset.dsmPrevDisplay = scope.style.display || ''
3321
+ scope.setAttribute(GROUP_UNIT_ATTR, '')
3322
+ }
3323
+ if (scope.style.display !== 'none') scope.style.display = 'none'
3324
+ return
3325
+ }
3326
+ if (!scope.hasAttribute(GROUP_UNIT_ATTR)) return
3327
+ const prev = scope.dataset.dsmPrevDisplay || ''
3328
+ if (scope.style.display !== prev) scope.style.display = prev
3329
+ scope.removeAttribute(GROUP_UNIT_ATTR)
3330
+ delete scope.dataset.dsmPrevDisplay
3331
+ }
3332
+ // 元素自身到 stopAt(不含)之间有没有被藏起来。只看我们自己的两处标记与
3333
+ // style.display:不碰 getComputedStyle(每帧调用的代价太高)。
3334
+ const isShownWithin = (el, stopAt) => {
3335
+ let node = el
3336
+ while (node && node !== stopAt) {
3337
+ if (node.style && node.style.display === 'none') return false
3338
+ if (node.hasAttribute && (node.hasAttribute(GROUP_UNIT_ATTR) || node.hasAttribute(DSM_HIDDEN_ATTR))) return false
3339
+ node = node.parentElement
3340
+ }
3341
+ return true
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
3124
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
+ }
3395
+ function dsmSyncEmptyGroups(purgedNow) {
3396
+ let rows = null
3397
+ try { rows = document.querySelectorAll('[role="treeitem"][data-row-key^="workspace:"]') } catch (e) { return }
3398
+ if (!rows || !rows.length) return
3399
+ const purged = purgedNow instanceof Set ? purgedNow : new Set()
3400
+ // 被插件藏起来的 id 集合只在真的需要判定时建一次(绝大多数帧用不到)。
3401
+ let hiddenIds = null
3402
+ const hiddenIdSet = () => {
3403
+ if (hiddenIds) return hiddenIds
3404
+ hiddenIds = new Set()
3405
+ if (dsmTrashIds) for (const id of dsmTrashIds) hiddenIds.add(id)
3406
+ for (const id of purged) hiddenIds.add(id)
3407
+ for (const [id, info] of dsmLineage.entries()) if (info && info.empty) hiddenIds.add(id)
3408
+ return hiddenIds
3409
+ }
3410
+ rows.forEach((row) => {
3411
+ try {
3412
+ // 容器必须是无 role 的普通元素(顶层分组)。子分组容器 role="group"
3413
+ // 里装的是整段子分组,不能当组容器用——拿它当容器会把整段子分组藏掉。
3414
+ // 上游把标题行包在 wrapper span 里时 parentElement 不是组容器,
3415
+ // 因此向上找到真正的 groupSection(见 groupContainerOf)。
3416
+ const scope = groupContainerOf(row)
3417
+ if (!scope || scope === document.body) return
3418
+ // 组内还有没被我们藏起来的会话行 → 绝不藏(也顺手还原)。
3419
+ // 行可能被上游包在 wrapper span 里(见 groupSessionRows 的说明),
3420
+ // 因此用收集器而不是直接遍历 scope.children。
3421
+ let rendered = 0
3422
+ for (const el of groupSessionRows(scope, row)) {
3423
+ if (el.hasAttribute(DSM_HIDDEN_ATTR)) continue
3424
+ rendered++
3425
+ }
3426
+ if (rendered > 0) { setGroupHidden(scope, false); return }
3427
+ // 容器里可能还挂着子分组(上游把嵌套工作区渲染在 role="group" 的
3428
+ // div 里)。只要还有子分组对用户可见,就绝不能藏父容器——那会把整
3429
+ // 段子分组一起带走。
3430
+ for (const nested of scope.querySelectorAll('[data-row-key^="workspace:"]')) {
3431
+ if (nested === row) continue
3432
+ if (!isShownWithin(nested, scope)) continue
3433
+ setGroupHidden(scope, false)
3434
+ return
3435
+ }
3436
+ // 渲染行全被我们藏了(或一行都没渲染):只有数据层能证明「真空」才藏。
3437
+ // 拿不到 group.sessions / 数组为空 = 不知道 → 按非空处理,保持显示。
3438
+ const group = sidebarAdapter.recognizeGroupQuiet(row)
3439
+ const sessions = group && Array.isArray(group.sessions) ? group.sessions : null
3440
+ setGroupHidden(scope, aliveSessionCount(sessions, hiddenIdSet()) === 0)
3441
+ } catch (e) { /* 单组失败不影响其它组 */ }
3125
3442
  })
3126
3443
  }
3444
+ // issue #16:「展开其余 N 个会话」的 N 是上游按数据层算的(0.2.0-rc.2 叫
3445
+ // collapsedSessionRows,0.2.1-rc.2 改叫 watermarkRows),它看不到插件用
3446
+ // display:none 藏起来的回收站/空白行,于是 N 虚高;点开只是把更多已隐藏行
3447
+ // 塞进 DOM,画面不动,很像「会话丢了」。
3448
+ //
3449
+ // 只改**文案与显隐**,不碰上游的展开状态机(watermark / 点击行为),也不改
3450
+ // 任何行自身的可见性判定。
3451
+ //
3452
+ // 口径(F1):上游 N 是「水位线之外未渲染的行数」,和「整组被插件藏起来的
3453
+ // 行数」不重叠,相减会重复扣减、点开一次就翻负,把仍有可见行的按钮藏掉
3454
+ // (上游只有这一个展开入口 → 会话不可达)。这里改用
3455
+ // 还能出现几行 = 本组可见总数(非 blank、且不会被插件藏)- 已渲染可见行数
3456
+ // 空白行两边都排除(F2):上游 watermarkRows 里 `if (session.blank) return true`
3457
+ // —— 空白行永远保留、从不计入 N。
3458
+ //
3459
+ // 数据层拿不到 group.sessions 时**什么都不改**(F6):DOM 兜底在数学上不成立,
3460
+ // 写一个看起来合理却会算错的数比不改更糟。
3461
+ const OVERFLOW_SIGS = new WeakMap()
3462
+ // 签名只看「便宜且能反映变化」的量:本组已渲染可见会话行(数量 + 首尾 id)、
3463
+ // 按钮自身文案与显隐、三个集合的大小。流式输出时 paint 每帧都跑,但侧栏结构
3464
+ // 绝大多数帧没变 —— 命中签名就直接跳过 fiber 走链(F4)。
3465
+ const overflowSignature = (scope, btn, purgedNow) => {
3466
+ let n = 0
3467
+ let firstKey = ''
3468
+ let lastKey = ''
3469
+ for (const el of groupSessionRows(scope, btn)) {
3470
+ if (el.hasAttribute(DSM_HIDDEN_ATTR)) continue
3471
+ n++
3472
+ const k = el.getAttribute('data-row-key') || ''
3473
+ if (!firstKey) firstKey = k
3474
+ lastKey = k
3475
+ }
3476
+ return n + '|' + firstKey + '|' + lastKey + '|' + (btn.textContent || '') + '|' + (btn.style.display || '') +
3477
+ '|' + (dsmTrashIds ? dsmTrashIds.size : -1) + '|' + purgedNow.size + '|' + dsmLineage.size
3478
+ }
3479
+ function dsmSyncOverflowLabels(purgedNow) {
3480
+ let buttons = null
3481
+ try { buttons = document.querySelectorAll('[data-row-key^="overflow:"]') } catch (e) { return }
3482
+ if (!buttons || !buttons.length) return
3483
+ try {
3484
+ buttons.forEach((btn) => {
3485
+ // 工作区标题行与本组的会话行同属一个 groupSection(上游 renderGroup 的
3486
+ // 直接子节点;子分组在另一个 role=group 容器里)→ 只按直接子节点找,
3487
+ // 不再做全文档属性前缀扫描。
3488
+ const scope = btn.parentElement
3489
+ if (!scope || !scope.children) return
3490
+ const sig = overflowSignature(scope, btn, purgedNow)
3491
+ if (OVERFLOW_SIGS.get(btn) === sig) return
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
3496
+ let wsRow = null
3497
+ for (const el of scope.children) {
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 }
3504
+ }
3505
+ const group = wsRow ? sidebarAdapter.recognizeGroupQuiet(wsRow) : null
3506
+ const nodes = group && Array.isArray(group.sessions) ? group.sessions : null
3507
+ if (!nodes) {
3508
+ // 数据层不可用:还原成上游原文案并放开按钮,绝不猜数字。
3509
+ restoreOverflowButton(btn)
3510
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3511
+ return
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
3523
+ // 本组「上游会列出、且不会被插件藏起来」的会话集合。
3524
+ const ids = new Set()
3525
+ for (const node of (nodes && nodes.length ? nodes : [])) {
3526
+ if (!node || node.blank) continue
3527
+ const id = node.id != null ? String(node.id) : ''
3528
+ if (!id) continue
3529
+ if ((dsmTrashIds && dsmTrashIds.has(id)) || purgedNow.has(id)) continue
3530
+ const li = dsmLineage.get(id)
3531
+ if (li && li.empty) continue
3532
+ ids.add(id)
3533
+ }
3534
+ // 已渲染进 DOM 的可见行:本组会话行里不在隐藏标记中的那些。
3535
+ // 用 `data-dsm-hidden` 而非 style.display 判定——上游折叠父级时行自身
3536
+ // 的 display 仍是空串,按 style 判会把看不见的行算成已渲染。
3537
+ const rendered = []
3538
+ for (const el of groupSessionRows(scope, btn)) {
3539
+ const k = el.getAttribute('data-row-key') || ''
3540
+ rendered.push({ id: k.slice('session:'.length), pluginHidden: el.hasAttribute(DSM_HIDDEN_ATTR) })
3541
+ }
3542
+ // 空集/口径不匹配的保护:数据层说「一条都没有」但 DOM 里明明有会话行,
3543
+ // 说明 group.sessions 的语义变了(懒加载、上游又换了过滤条件)。此时按
3544
+ // 未知处理,绝不能算出 0 把按钮藏掉 —— 那是把可见会话变成不可达。
3545
+ // 折叠态(collapsed)不算「空集」:那是上游把 sessions 收回的正常表现,
3546
+ // 继续走下面的计数分支,否则展开按钮永远得不到校准。
3547
+ if (!nodes.length && !collapsed) {
3548
+ restoreOverflowButton(btn)
3549
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3550
+ return
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
+ }
3582
+ const renderedVisible = countRenderedVisible(rendered, ids)
3583
+ const more = remainingRows(ids.size, renderedVisible)
3584
+ applyOverflowPlan(btn, more, '可见 ' + ids.size + ' / 已渲染 ' + renderedVisible)
3585
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3586
+ })
3587
+ } catch (e) {
3588
+ // 非关键路径:失败就保持上游原样,不影响本次 paint 的其它注入(F5)。
3589
+ try { console.debug('[dsh-sessions-manager] 展开计数校准跳过:' + (e && e.message)) } catch (e2) {}
3590
+ }
3591
+ }
3592
+ // 把计划落到按钮上:文案基线 / 上次写入值 / 口径版本都记在 dataset
3593
+ // (F8:跨版本残留的旧基线不会延续到下个版本),HMR 重装后也不会二次处理。
3594
+ function applyOverflowPlan(btn, more, detail) {
3595
+ const raw = btn.textContent || ''
3596
+ const ver = String(SIDEBAR_ADAPTER_VERSION)
3597
+ const fresh = btn.getAttribute(DSM_OVERFLOW_VER) === ver
3598
+ const last = fresh ? btn.getAttribute(DSM_OVERFLOW_WRITE) : null
3599
+ let baseline = fresh ? btn.getAttribute(DSM_OVERFLOW_BASE) : null
3600
+ // 首次见到,或上游重渲染换了文案(含点击展开后的新 N)→ 以当前文案为新基准。
3601
+ if (baseline == null || raw !== last) baseline = raw
3602
+ btn.setAttribute(DSM_OVERFLOW_BASE, baseline)
3603
+ btn.setAttribute(DSM_OVERFLOW_VER, ver)
3604
+ // F3:上游 N 藏在基线文案里。若我们算出的「还能出现几行」反而比上游 N 大,
3605
+ // 说明数据层与 DOM 的口径已经不一致(上游改了 group.sessions 的过滤语义、
3606
+ // 或将来插件改用官方归档 API)——收敛回上游 N 并留一条 debug,把「静默
3607
+ // 失效」变成可观测。注意必须用**基线**里的 N:当前文案可能是我们写过的值。
3608
+ const bm = baseline.match(/(\d[\d,]*)/)
3609
+ const upstream = bm ? Number(bm[1].replace(/,/g, '')) : null
3610
+ if (more != null && upstream != null && upstream > 0 && more > upstream) {
3611
+ more = upstream
3612
+ debugOverflowOnce((detail || '') + ',超过上游计数 ' + upstream + ',已按上游计数收敛')
3613
+ }
3614
+ const plan = overflowPlan(baseline, more)
3615
+ if (plan.hide) {
3616
+ if (btn.style.display !== 'none') btn.style.display = 'none'
3617
+ if (!btn.hasAttribute(DSM_OVERFLOW_HIDDEN)) btn.setAttribute(DSM_OVERFLOW_HIDDEN, '')
3618
+ btn.setAttribute(DSM_OVERFLOW_WRITE, baseline)
3619
+ return
3620
+ }
3621
+ if (btn.hasAttribute(DSM_OVERFLOW_HIDDEN)) {
3622
+ btn.style.display = ''
3623
+ btn.removeAttribute(DSM_OVERFLOW_HIDDEN)
3624
+ }
3625
+ if (plan.changed) {
3626
+ // 同值不写:写入会触发 characterData 观察 → 重绘,必须保持幂等。
3627
+ if (btn.textContent !== plan.text) btn.textContent = plan.text
3628
+ btn.setAttribute(DSM_OVERFLOW_WRITE, plan.text)
3629
+ } else {
3630
+ if (btn.textContent !== baseline) btn.textContent = baseline
3631
+ btn.setAttribute(DSM_OVERFLOW_WRITE, baseline)
3632
+ }
3633
+ }
3634
+ function restoreOverflowButton(btn) {
3635
+ const verbatim = btn.getAttribute(DSM_OVERFLOW_BASE)
3636
+ if (verbatim != null && btn.textContent !== verbatim) btn.textContent = verbatim
3637
+ if (btn.hasAttribute(DSM_OVERFLOW_HIDDEN)) {
3638
+ btn.style.display = ''
3639
+ btn.removeAttribute(DSM_OVERFLOW_HIDDEN)
3640
+ }
3641
+ }
3127
3642
  const paint = () => {
3128
3643
  if (sidebarAdapter.disabled) return
3129
3644
  // Recompute the active session on every paint so the active row is never
@@ -3169,6 +3684,10 @@ function installSidebarStatusDots() {
3169
3684
  const purged = purgedNow
3170
3685
  if ((dsmTrashIds && dsmTrashIds.has(id)) || purged.has(id)) {
3171
3686
  if (row.style.display !== 'none') row.style.display = 'none'
3687
+ // issue #16:标记「这一行是插件隐藏的」,供展开计数扣减使用(数据层
3688
+ // 拿不到 group.sessions 时也能按 DOM 兜底统计)。属性不进 observer 的
3689
+ // attributeFilter,不会自触发重绘。
3690
+ if (row.getAttribute(DSM_HIDDEN_ATTR) !== 'trash') row.setAttribute(DSM_HIDDEN_ATTR, 'trash')
3172
3691
  const d = row.querySelector('[' + DOT + ']'); if (d) d.remove()
3173
3692
  dsmDropSubList(row, id)
3174
3693
  return
@@ -3201,11 +3720,15 @@ function installSidebarStatusDots() {
3201
3720
  }
3202
3721
  if (hideLineage) {
3203
3722
  if (row.style.display !== 'none') row.style.display = 'none'
3723
+ if (row.getAttribute(DSM_HIDDEN_ATTR) !== 'empty') row.setAttribute(DSM_HIDDEN_ATTR, 'empty')
3204
3724
  const d = row.querySelector('[' + DOT + ']'); if (d) d.remove()
3205
3725
  dsmDropSubList(row, id)
3206
3726
  return
3207
3727
  }
3208
3728
  if (row.style.display === 'none') row.style.display = ''
3729
+ // 行重新可见(已从回收站恢复 / 空白状态消失):摘掉隐藏标记,展开计数
3730
+ // 立刻把这一条还回「可展开」的数量里。
3731
+ if (row.hasAttribute(DSM_HIDDEN_ATTR)) row.removeAttribute(DSM_HIDDEN_ATTR)
3209
3732
  if (!isEmpty && li && li.parentSession && li.origin !== 'subagent') {
3210
3733
  // fork 分支行:绿「⑂ 分支」chip,点击滚动并高亮父会话行。
3211
3734
  // v3.6.2 #3:chip 每次 paint 幂等同步(存在才不建,但 tooltip/父 id
@@ -3341,7 +3864,9 @@ function installSidebarStatusDots() {
3341
3864
  }
3342
3865
  dot.style.background = color
3343
3866
  })
3344
- dsmSyncEmptyGroups()
3867
+ dsmSyncEmptyGroups(purgedNow)
3868
+ // issue #16:等隐藏标记落定后再校准展开计数(数据层优先,DOM 标记兜底)。
3869
+ dsmSyncOverflowLabels(purgedNow)
3345
3870
  }
3346
3871
  dsmRepaintDots = paint
3347
3872
  const paintToast = (msg, kind) => {