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/README.en.md +8 -7
- package/README.md +8 -7
- package/lib/client.js +522 -323
- package/lib/client.js.map +3 -3
- package/lib/index.js +192 -15
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/client/index.jsx +313 -63
- package/src/client/logic.js +40 -0
- package/src/index.js +156 -14
- package/src/lineage.js +86 -6
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.
|
|
4
|
+
"version": "3.7.8",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/src/client/index.jsx
CHANGED
|
@@ -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
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
//
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
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
|
-
//
|
|
1354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={`${
|
|
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) }}>全部 ({
|
|
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 :
|
|
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 &&
|
|
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} 个收藏会话` : `共 ${
|
|
2060
|
-
|
|
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 &&
|
|
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
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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') ===
|
|
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
|
|
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
|
-
|
|
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)
|