dsh-sessions-manager 3.7.6 → 3.7.7

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/lib/index.js CHANGED
@@ -2039,7 +2039,7 @@ function emptyScanCandidate(sizeBytes) {
2039
2039
  }
2040
2040
 
2041
2041
  // src/index.js
2042
- var BUILD_STAMP = true ? "3.7.6+68c2ab69" : "dev";
2042
+ var BUILD_STAMP = true ? "3.7.7+89abb735" : "dev";
2043
2043
  var name = "dsh-sessions-manager";
2044
2044
  var inject = ["webServer", "workspaceRegistry", "sessionPersistence", "sessionQuery", "storageDomain"];
2045
2045
  var MAX_TITLE = 80;
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.6",
4
+ "version": "3.7.7",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useEffect, useMemo, useRef, useState } from 'react'
14
- import { applyTagFilter, canDropOnWorkspace, dotStateFor, effectiveTitleOf, filterShapeFromSaved, filterSnapshotOf, foldBranches, foldSubagents, noticeToastPlan, openSubagentToast, pathTail, sessionForNodes, shortId, starredOf, tagDeleteConfirm, titleBackfillDecision, toastDurationFor, workspaceForNodes } from './logic.js'
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'
15
15
 
16
16
  export const inject = ['slots']
17
17
 
@@ -2726,6 +2726,24 @@ function dsmTeardownSidebarAug() {
2726
2726
  for (const sel of selectors) {
2727
2727
  try { document.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) { /* best-effort */ }
2728
2728
  }
2729
+ try {
2730
+ // issue #16:被隐藏的会话行与展开按钮在卸载时还原——adapter 停用(上游
2731
+ // 改版)或插件停用后,不能把「看不见的会话」留在官方侧栏上。
2732
+ document.querySelectorAll('[data-dsm-hidden]').forEach((row) => {
2733
+ row.style.display = ''
2734
+ row.removeAttribute('data-dsm-hidden')
2735
+ })
2736
+ document.querySelectorAll('[data-dsm-overflow-hidden], [data-dsm-overflow-base]').forEach((btn) => {
2737
+ // 文案可能被我们改写过:dataset 里存着上游原文,卸载时连文案一起还原。
2738
+ const base = btn.getAttribute('data-dsm-overflow-base')
2739
+ if (base != null && btn.textContent !== base) btn.textContent = base
2740
+ btn.style.display = ''
2741
+ btn.removeAttribute('data-dsm-overflow-hidden')
2742
+ btn.removeAttribute('data-dsm-overflow-base')
2743
+ btn.removeAttribute('data-dsm-overflow-write')
2744
+ btn.removeAttribute('data-dsm-overflow-ver')
2745
+ })
2746
+ } catch (e) { /* best-effort */ }
2729
2747
  try {
2730
2748
  document.querySelectorAll('[data-dsm-group-hidden]').forEach((owner) => {
2731
2749
  owner.style.display = owner.dataset.dsmPrevDisplay || ''
@@ -2770,11 +2788,26 @@ function createSidebarAdapter() {
2770
2788
  }
2771
2789
  return out
2772
2790
  }
2791
+ // 只取 `group` 的静默识别:命中即停,**不参与 miss 预算**。展开计数的
2792
+ // workspace 行识别属非关键路径(拿不到就保持上游原文案),不能让它的落空
2793
+ // 把整套侧栏增强熔断;命中即返也省掉继续上溯整条 fiber 链。
2794
+ const recognizeGroupQuiet = (row) => {
2795
+ if (disabled || !row || typeof row !== 'object') return null
2796
+ let f = findFiber(row)
2797
+ let guard = 0
2798
+ while (f && guard++ < 300) {
2799
+ const props = f.memoizedProps
2800
+ if (props && props.group && typeof props.group === 'object') return props.group
2801
+ f = f.return
2802
+ }
2803
+ return null
2804
+ }
2773
2805
  return {
2774
2806
  version: SIDEBAR_ADAPTER_VERSION,
2775
2807
  get disabled() { return disabled },
2776
2808
  findFiber,
2777
2809
  recognize,
2810
+ recognizeGroupQuiet,
2778
2811
  }
2779
2812
  }
2780
2813
  const sidebarAdapter = createSidebarAdapter()
@@ -3060,6 +3093,25 @@ function installSidebarSessionMenuAug() {
3060
3093
  function installSidebarStatusDots() {
3061
3094
  if (typeof document === 'undefined') return
3062
3095
  const DOT = 'data-dsm-dot'
3096
+ // issue #16:被插件隐藏的行打标记;展开计数的扣减统计靠它(数据层不可用时兜底)。
3097
+ const DSM_HIDDEN_ATTR = 'data-dsm-hidden'
3098
+ // 空分组:整组被我们藏起时打在**分组容器**上的标记(见 dsmSyncEmptyGroups)。
3099
+ const GROUP_UNIT_ATTR = 'data-dsm-group-hidden'
3100
+ // 展开按钮上的数据集:上游原文(基线)、上次写入值、口径版本、是否被我们藏起。
3101
+ const DSM_OVERFLOW_BASE = 'data-dsm-overflow-base'
3102
+ const DSM_OVERFLOW_WRITE = 'data-dsm-overflow-write'
3103
+ const DSM_OVERFLOW_VER = 'data-dsm-overflow-ver'
3104
+ const DSM_OVERFLOW_HIDDEN = 'data-dsm-overflow-hidden'
3105
+ let overflowDebugged = false
3106
+ const debugOverflowOnce = (msg) => {
3107
+ if (overflowDebugged) return
3108
+ overflowDebugged = true
3109
+ try { console.debug('[dsh-sessions-manager] 展开计数:' + msg) } catch (e) { /* 控制台不可用 */ }
3110
+ }
3111
+ // 展开按钮的文案基线 / 上次写入值存在 dataset(而非 WeakMap):HMR / 插件
3112
+ // 重装后新闭包里 WeakMap 是空的,会把「已扣减的文案」再当上游原文扣一次;
3113
+ // dataset 跟随节点存活,卸载时还能据此还原原文案。data-dsm-* 不在
3114
+ // MutationObserver 的 attributeFilter 里,不会自触发重绘。
3063
3115
  if (!document.querySelector('style[data-dsm=aug]')) {
3064
3116
  const s = document.createElement('style')
3065
3117
  s.dataset.dsm = 'aug'
@@ -3093,37 +3145,250 @@ function installSidebarStatusDots() {
3093
3145
  // 行;官方(重命名等)改写文本后所有权移交,绝不覆盖 DSH Core 的实时更新。
3094
3146
  const titleBaselines = new WeakMap()
3095
3147
  // 空分组兜底: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
3148
+ // 除后最明显)。我们把行隐藏了,但空组标题会留下来——所以组内没有**可能
3149
+ // 出现**的会话时,把整个分组容器一起隐藏;重新出现时自动还原。
3150
+ //
3151
+ // 容器定位:各版本的顶层分组容器都没有稳定的 role/class —— `role="group"`
3152
+ // 从 0.1.7 到 0.2.1 只出现在**子分组**容器上(一个包着整段子分组的 div),
3153
+ // 顶层分组是无 role 的普通 div(`groupSection`)。所以以
3154
+ // `data-row-key="workspace:*"`(0.1.7 起稳定)为锚点,取它的父元素当容器:
3155
+ // 本组的会话行与展开按钮都是这个容器的子节点。
3156
+ //
3157
+ // 判定走数据层(与展开计数同一口径):只有 group.sessions 里「非空白、且
3158
+ // 不在回收站/墓碑/空白集合」的条数确实为 0 才藏。只看 DOM 会把「上游折叠
3159
+ // 后还没渲染出来的可见会话」误判成空组,连标题带展开按钮一起藏掉——那些
3160
+ // 会话就再也点不到了。
3161
+ //
3162
+ // 原 display 记在容器的 dataset 上(而不是 WeakMap):还原路径无法遍历
3163
+ // WeakMap,dataset 让「谁被我们藏过、原来是什么」随时可查可还原。
3164
+ const setGroupHidden = (scope, hidden) => {
3165
+ if (hidden) {
3166
+ if (!scope.hasAttribute(GROUP_UNIT_ATTR)) {
3167
+ scope.dataset.dsmPrevDisplay = scope.style.display || ''
3168
+ scope.setAttribute(GROUP_UNIT_ATTR, '')
3124
3169
  }
3170
+ if (scope.style.display !== 'none') scope.style.display = 'none'
3171
+ return
3172
+ }
3173
+ if (!scope.hasAttribute(GROUP_UNIT_ATTR)) return
3174
+ const prev = scope.dataset.dsmPrevDisplay || ''
3175
+ if (scope.style.display !== prev) scope.style.display = prev
3176
+ scope.removeAttribute(GROUP_UNIT_ATTR)
3177
+ delete scope.dataset.dsmPrevDisplay
3178
+ }
3179
+ // 元素自身到 stopAt(不含)之间有没有被藏起来。只看我们自己的两处标记与
3180
+ // style.display:不碰 getComputedStyle(每帧调用的代价太高)。
3181
+ const isShownWithin = (el, stopAt) => {
3182
+ let node = el
3183
+ while (node && node !== stopAt) {
3184
+ if (node.style && node.style.display === 'none') return false
3185
+ if (node.hasAttribute && (node.hasAttribute(GROUP_UNIT_ATTR) || node.hasAttribute(DSM_HIDDEN_ATTR))) return false
3186
+ node = node.parentElement
3187
+ }
3188
+ return true
3189
+ }
3190
+ function dsmSyncEmptyGroups(purgedNow) {
3191
+ let rows = null
3192
+ try { rows = document.querySelectorAll('[role="treeitem"][data-row-key^="workspace:"]') } catch (e) { return }
3193
+ if (!rows || !rows.length) return
3194
+ const purged = purgedNow instanceof Set ? purgedNow : new Set()
3195
+ // 被插件藏起来的 id 集合只在真的需要判定时建一次(绝大多数帧用不到)。
3196
+ let hiddenIds = null
3197
+ const hiddenIdSet = () => {
3198
+ if (hiddenIds) return hiddenIds
3199
+ hiddenIds = new Set()
3200
+ if (dsmTrashIds) for (const id of dsmTrashIds) hiddenIds.add(id)
3201
+ for (const id of purged) hiddenIds.add(id)
3202
+ for (const [id, info] of dsmLineage.entries()) if (info && info.empty) hiddenIds.add(id)
3203
+ return hiddenIds
3204
+ }
3205
+ rows.forEach((row) => {
3206
+ try {
3207
+ const scope = row.parentElement
3208
+ if (!scope || scope === document.body) return
3209
+ // 容器必须是无 role 的普通元素(顶层分组)。子分组容器 role="group"
3210
+ // 里装的是整段子分组,不能当组容器用——拿它当容器会把整段子分组藏掉。
3211
+ if (typeof scope.getAttribute !== 'function' || scope.getAttribute('role')) return
3212
+ // 组内还有没被我们藏起来的会话行 → 绝不藏(也顺手还原)。
3213
+ 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
3218
+ if (el.hasAttribute(DSM_HIDDEN_ATTR)) continue
3219
+ rendered++
3220
+ }
3221
+ if (rendered > 0) { setGroupHidden(scope, false); return }
3222
+ // 容器里可能还挂着子分组(上游把嵌套工作区渲染在 role="group" 的
3223
+ // div 里)。只要还有子分组对用户可见,就绝不能藏父容器——那会把整
3224
+ // 段子分组一起带走。
3225
+ for (const nested of scope.querySelectorAll('[data-row-key^="workspace:"]')) {
3226
+ if (nested === row) continue
3227
+ if (!isShownWithin(nested, scope)) continue
3228
+ setGroupHidden(scope, false)
3229
+ return
3230
+ }
3231
+ // 渲染行全被我们藏了(或一行都没渲染):只有数据层能证明「真空」才藏。
3232
+ // 拿不到 group.sessions / 数组为空 = 不知道 → 按非空处理,保持显示。
3233
+ const group = sidebarAdapter.recognizeGroupQuiet(row)
3234
+ const sessions = group && Array.isArray(group.sessions) ? group.sessions : null
3235
+ setGroupHidden(scope, aliveSessionCount(sessions, hiddenIdSet()) === 0)
3236
+ } catch (e) { /* 单组失败不影响其它组 */ }
3125
3237
  })
3126
3238
  }
3239
+ // issue #16:「展开其余 N 个会话」的 N 是上游按数据层算的(0.2.0-rc.2 叫
3240
+ // collapsedSessionRows,0.2.1-rc.2 改叫 watermarkRows),它看不到插件用
3241
+ // display:none 藏起来的回收站/空白行,于是 N 虚高;点开只是把更多已隐藏行
3242
+ // 塞进 DOM,画面不动,很像「会话丢了」。
3243
+ //
3244
+ // 只改**文案与显隐**,不碰上游的展开状态机(watermark / 点击行为),也不改
3245
+ // 任何行自身的可见性判定。
3246
+ //
3247
+ // 口径(F1):上游 N 是「水位线之外未渲染的行数」,和「整组被插件藏起来的
3248
+ // 行数」不重叠,相减会重复扣减、点开一次就翻负,把仍有可见行的按钮藏掉
3249
+ // (上游只有这一个展开入口 → 会话不可达)。这里改用
3250
+ // 还能出现几行 = 本组可见总数(非 blank、且不会被插件藏)- 已渲染可见行数
3251
+ // 空白行两边都排除(F2):上游 watermarkRows 里 `if (session.blank) return true`
3252
+ // —— 空白行永远保留、从不计入 N。
3253
+ //
3254
+ // 数据层拿不到 group.sessions 时**什么都不改**(F6):DOM 兜底在数学上不成立,
3255
+ // 写一个看起来合理却会算错的数比不改更糟。
3256
+ const OVERFLOW_SIGS = new WeakMap()
3257
+ // 签名只看「便宜且能反映变化」的量:本组已渲染可见会话行(数量 + 首尾 id)、
3258
+ // 按钮自身文案与显隐、三个集合的大小。流式输出时 paint 每帧都跑,但侧栏结构
3259
+ // 绝大多数帧没变 —— 命中签名就直接跳过 fiber 走链(F4)。
3260
+ const overflowSignature = (scope, btn, purgedNow) => {
3261
+ let n = 0
3262
+ let firstKey = ''
3263
+ 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
3268
+ if (el.hasAttribute(DSM_HIDDEN_ATTR)) continue
3269
+ n++
3270
+ if (!firstKey) firstKey = k
3271
+ lastKey = k
3272
+ }
3273
+ return n + '|' + firstKey + '|' + lastKey + '|' + (btn.textContent || '') + '|' + (btn.style.display || '') +
3274
+ '|' + (dsmTrashIds ? dsmTrashIds.size : -1) + '|' + purgedNow.size + '|' + dsmLineage.size
3275
+ }
3276
+ function dsmSyncOverflowLabels(purgedNow) {
3277
+ let buttons = null
3278
+ try { buttons = document.querySelectorAll('[data-row-key^="overflow:"]') } catch (e) { return }
3279
+ if (!buttons || !buttons.length) return
3280
+ try {
3281
+ buttons.forEach((btn) => {
3282
+ // 工作区标题行与本组的会话行同属一个 groupSection(上游 renderGroup 的
3283
+ // 直接子节点;子分组在另一个 role=group 容器里)→ 只按直接子节点找,
3284
+ // 不再做全文档属性前缀扫描。
3285
+ const scope = btn.parentElement
3286
+ if (!scope || !scope.children) return
3287
+ const sig = overflowSignature(scope, btn, purgedNow)
3288
+ if (OVERFLOW_SIGS.get(btn) === sig) return
3289
+ const key = (btn.getAttribute('data-row-key') || '').slice('overflow:'.length)
3290
+ let wsRow = null
3291
+ for (const el of scope.children) {
3292
+ if (el.getAttribute && el.getAttribute('data-row-key') === 'workspace:' + key) { wsRow = el; break }
3293
+ }
3294
+ const group = wsRow ? sidebarAdapter.recognizeGroupQuiet(wsRow) : null
3295
+ const nodes = group && Array.isArray(group.sessions) ? group.sessions : null
3296
+ if (!nodes) {
3297
+ // 数据层不可用:还原成上游原文案并放开按钮,绝不猜数字。
3298
+ restoreOverflowButton(btn)
3299
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3300
+ return
3301
+ }
3302
+ // 本组「上游会列出、且不会被插件藏起来」的会话集合。
3303
+ const ids = new Set()
3304
+ for (const node of nodes) {
3305
+ if (!node || node.blank) continue
3306
+ const id = node.id != null ? String(node.id) : ''
3307
+ if (!id) continue
3308
+ if ((dsmTrashIds && dsmTrashIds.has(id)) || purgedNow.has(id)) continue
3309
+ const li = dsmLineage.get(id)
3310
+ if (li && li.empty) continue
3311
+ ids.add(id)
3312
+ }
3313
+ // 已渲染进 DOM 的可见行:只认本组直接子节点里不在隐藏标记中的会话行。
3314
+ // 用 `data-dsm-hidden` 而非 style.display 判定——上游折叠父级时行自身
3315
+ // 的 display 仍是空串,按 style 判会把看不见的行算成已渲染。
3316
+ const rendered = []
3317
+ for (const el of scope.children) {
3318
+ if (el === btn || !el.getAttribute) continue
3319
+ if (el.getAttribute('role') !== 'treeitem') continue
3320
+ const k = el.getAttribute('data-row-key') || ''
3321
+ if (!k.startsWith('session:')) continue
3322
+ rendered.push({ id: k.slice('session:'.length), pluginHidden: el.hasAttribute(DSM_HIDDEN_ATTR) })
3323
+ }
3324
+ // 空集/口径不匹配的保护:数据层说「一条都没有」但 DOM 里明明有会话行,
3325
+ // 说明 group.sessions 的语义变了(懒加载、上游又换了过滤条件)。此时按
3326
+ // 未知处理,绝不能算出 0 把按钮藏掉 —— 那是把可见会话变成不可达。
3327
+ if (!nodes.length || (!ids.size && rendered.length)) {
3328
+ restoreOverflowButton(btn)
3329
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3330
+ return
3331
+ }
3332
+ const renderedVisible = countRenderedVisible(rendered, ids)
3333
+ const more = remainingRows(ids.size, renderedVisible)
3334
+ applyOverflowPlan(btn, more, '可见 ' + ids.size + ' / 已渲染 ' + renderedVisible)
3335
+ OVERFLOW_SIGS.set(btn, overflowSignature(scope, btn, purgedNow))
3336
+ })
3337
+ } catch (e) {
3338
+ // 非关键路径:失败就保持上游原样,不影响本次 paint 的其它注入(F5)。
3339
+ try { console.debug('[dsh-sessions-manager] 展开计数校准跳过:' + (e && e.message)) } catch (e2) {}
3340
+ }
3341
+ }
3342
+ // 把计划落到按钮上:文案基线 / 上次写入值 / 口径版本都记在 dataset
3343
+ // (F8:跨版本残留的旧基线不会延续到下个版本),HMR 重装后也不会二次处理。
3344
+ function applyOverflowPlan(btn, more, detail) {
3345
+ const raw = btn.textContent || ''
3346
+ const ver = String(SIDEBAR_ADAPTER_VERSION)
3347
+ const fresh = btn.getAttribute(DSM_OVERFLOW_VER) === ver
3348
+ const last = fresh ? btn.getAttribute(DSM_OVERFLOW_WRITE) : null
3349
+ let baseline = fresh ? btn.getAttribute(DSM_OVERFLOW_BASE) : null
3350
+ // 首次见到,或上游重渲染换了文案(含点击展开后的新 N)→ 以当前文案为新基准。
3351
+ if (baseline == null || raw !== last) baseline = raw
3352
+ btn.setAttribute(DSM_OVERFLOW_BASE, baseline)
3353
+ btn.setAttribute(DSM_OVERFLOW_VER, ver)
3354
+ // F3:上游 N 藏在基线文案里。若我们算出的「还能出现几行」反而比上游 N 大,
3355
+ // 说明数据层与 DOM 的口径已经不一致(上游改了 group.sessions 的过滤语义、
3356
+ // 或将来插件改用官方归档 API)——收敛回上游 N 并留一条 debug,把「静默
3357
+ // 失效」变成可观测。注意必须用**基线**里的 N:当前文案可能是我们写过的值。
3358
+ const bm = baseline.match(/(\d[\d,]*)/)
3359
+ const upstream = bm ? Number(bm[1].replace(/,/g, '')) : null
3360
+ if (more != null && upstream != null && upstream > 0 && more > upstream) {
3361
+ more = upstream
3362
+ debugOverflowOnce((detail || '') + ',超过上游计数 ' + upstream + ',已按上游计数收敛')
3363
+ }
3364
+ const plan = overflowPlan(baseline, more)
3365
+ if (plan.hide) {
3366
+ if (btn.style.display !== 'none') btn.style.display = 'none'
3367
+ if (!btn.hasAttribute(DSM_OVERFLOW_HIDDEN)) btn.setAttribute(DSM_OVERFLOW_HIDDEN, '')
3368
+ btn.setAttribute(DSM_OVERFLOW_WRITE, baseline)
3369
+ return
3370
+ }
3371
+ if (btn.hasAttribute(DSM_OVERFLOW_HIDDEN)) {
3372
+ btn.style.display = ''
3373
+ btn.removeAttribute(DSM_OVERFLOW_HIDDEN)
3374
+ }
3375
+ if (plan.changed) {
3376
+ // 同值不写:写入会触发 characterData 观察 → 重绘,必须保持幂等。
3377
+ if (btn.textContent !== plan.text) btn.textContent = plan.text
3378
+ btn.setAttribute(DSM_OVERFLOW_WRITE, plan.text)
3379
+ } else {
3380
+ if (btn.textContent !== baseline) btn.textContent = baseline
3381
+ btn.setAttribute(DSM_OVERFLOW_WRITE, baseline)
3382
+ }
3383
+ }
3384
+ function restoreOverflowButton(btn) {
3385
+ const verbatim = btn.getAttribute(DSM_OVERFLOW_BASE)
3386
+ if (verbatim != null && btn.textContent !== verbatim) btn.textContent = verbatim
3387
+ if (btn.hasAttribute(DSM_OVERFLOW_HIDDEN)) {
3388
+ btn.style.display = ''
3389
+ btn.removeAttribute(DSM_OVERFLOW_HIDDEN)
3390
+ }
3391
+ }
3127
3392
  const paint = () => {
3128
3393
  if (sidebarAdapter.disabled) return
3129
3394
  // Recompute the active session on every paint so the active row is never
@@ -3169,6 +3434,10 @@ function installSidebarStatusDots() {
3169
3434
  const purged = purgedNow
3170
3435
  if ((dsmTrashIds && dsmTrashIds.has(id)) || purged.has(id)) {
3171
3436
  if (row.style.display !== 'none') row.style.display = 'none'
3437
+ // issue #16:标记「这一行是插件隐藏的」,供展开计数扣减使用(数据层
3438
+ // 拿不到 group.sessions 时也能按 DOM 兜底统计)。属性不进 observer 的
3439
+ // attributeFilter,不会自触发重绘。
3440
+ if (row.getAttribute(DSM_HIDDEN_ATTR) !== 'trash') row.setAttribute(DSM_HIDDEN_ATTR, 'trash')
3172
3441
  const d = row.querySelector('[' + DOT + ']'); if (d) d.remove()
3173
3442
  dsmDropSubList(row, id)
3174
3443
  return
@@ -3201,11 +3470,15 @@ function installSidebarStatusDots() {
3201
3470
  }
3202
3471
  if (hideLineage) {
3203
3472
  if (row.style.display !== 'none') row.style.display = 'none'
3473
+ if (row.getAttribute(DSM_HIDDEN_ATTR) !== 'empty') row.setAttribute(DSM_HIDDEN_ATTR, 'empty')
3204
3474
  const d = row.querySelector('[' + DOT + ']'); if (d) d.remove()
3205
3475
  dsmDropSubList(row, id)
3206
3476
  return
3207
3477
  }
3208
3478
  if (row.style.display === 'none') row.style.display = ''
3479
+ // 行重新可见(已从回收站恢复 / 空白状态消失):摘掉隐藏标记,展开计数
3480
+ // 立刻把这一条还回「可展开」的数量里。
3481
+ if (row.hasAttribute(DSM_HIDDEN_ATTR)) row.removeAttribute(DSM_HIDDEN_ATTR)
3209
3482
  if (!isEmpty && li && li.parentSession && li.origin !== 'subagent') {
3210
3483
  // fork 分支行:绿「⑂ 分支」chip,点击滚动并高亮父会话行。
3211
3484
  // v3.6.2 #3:chip 每次 paint 幂等同步(存在才不建,但 tooltip/父 id
@@ -3341,7 +3614,9 @@ function installSidebarStatusDots() {
3341
3614
  }
3342
3615
  dot.style.background = color
3343
3616
  })
3344
- dsmSyncEmptyGroups()
3617
+ dsmSyncEmptyGroups(purgedNow)
3618
+ // issue #16:等隐藏标记落定后再校准展开计数(数据层优先,DOM 标记兜底)。
3619
+ dsmSyncOverflowLabels(purgedNow)
3345
3620
  }
3346
3621
  dsmRepaintDots = paint
3347
3622
  const paintToast = (msg, kind) => {
@@ -462,3 +462,96 @@ export function filterShapeFromSaved(item) {
462
462
  if (tag === '' && f.tag !== '') degraded.push('tag')
463
463
  return { view, workspace, sort, tag, degraded }
464
464
  }
465
+
466
+ // issue #16:侧栏「展开其余 N 个会话」的 N 由上游按**数据层**计算
467
+ // (0.2.0-rc.2 `collapsedSessionRows` → 0.2.1-rc.2 `watermarkRows`),
468
+ // 看不到插件用 display:none 隐藏的回收站/空白行,于是数字虚高;点展开只是
469
+ // 把更多已隐藏行塞进 DOM,可见行数不变——按钮在动、画面不动,容易被误读成
470
+ // 「会话丢了」。
471
+ //
472
+ // 注意**不能**用「N − 本组被插件隐藏的条数」:上游的 N 数是「水位线之外
473
+ // 还没渲染的行」,隐藏数数的是「整组被藏起来的行」,两个口径不重叠——被藏的
474
+ // 行可能就在已渲染窗口内,减它属重复扣减,点开一次就翻负,进而把仍有可见行
475
+ // 的按钮藏掉(上游只有这一个展开入口 → 会话不可达)。
476
+ //
477
+ // 正确口径由 `remainingRows` 给出:还能出现几行 = 本组可见总数 − 已渲染的
478
+ // 可见行数;`overflowPlan` 只负责把它写进文案、并决定是否藏按钮。
479
+ export function remainingRows(visibleTotal, renderedVisible) {
480
+ // Number(null) === 0:null / undefined 必须当作「不知道」,不能当成 0。
481
+ if (visibleTotal == null || visibleTotal === '') return null
482
+ const total = Number(visibleTotal)
483
+ if (!Number.isFinite(total)) return null
484
+ const shown = Number(renderedVisible)
485
+ const shownCount = Number.isFinite(shown) ? Math.max(0, Math.floor(shown)) : 0
486
+ return Math.max(0, Math.max(0, Math.floor(total)) - shownCount)
487
+ }
488
+
489
+ // 本组已渲染的可见会话行数。`rows` 是直接子节点里会话行的描述
490
+ // ({ id, pluginHidden }),`visibleIds` 是「上游会列出且插件不会藏」的 id 集合。
491
+ // 插件隐藏的行(回收站 / 空白)与不在集合里的行(空白会话 / 上游过滤口径差异)
492
+ // 都不计入 —— 与 `remainingRows` 的「可见总数」保持同一口径。
493
+ export function countRenderedVisible(rows, visibleIds) {
494
+ if (!Array.isArray(rows) || !visibleIds || typeof visibleIds.has !== 'function') return 0
495
+ let n = 0
496
+ for (const row of rows) {
497
+ if (!row || row.pluginHidden) continue
498
+ if (row.id && visibleIds.has(row.id)) n++
499
+ }
500
+ return n
501
+ }
502
+
503
+ // 空分组判定:`group.sessions` 里「还会出现在侧栏」的条数。
504
+ // - 空白会话不计:上游 `watermarkRows` 里 `if (session.blank) return true`,
505
+ // 空白行永远保留、从不计入可展开数;插件也一律把它藏起来。
506
+ // - `hiddenIds` = 回收站 / 墓碑 / 空白血缘的行 id(与展开计数同一份口径)。
507
+ // - 返回 null 表示**不知道**(拿不到数据层,或该组还没加载出会话):调用方
508
+ // 必须按「非空」处理——宁可留一个空标题,也不能把仍有会话的组藏掉。
509
+ export function aliveSessionCount(sessions, hiddenIds) {
510
+ if (!Array.isArray(sessions) || !sessions.length) return null
511
+ let alive = 0
512
+ let seen = 0
513
+ for (const node of sessions) {
514
+ if (!node || node.id == null) continue
515
+ const id = String(node.id)
516
+ if (!id) continue
517
+ seen++
518
+ if (node.blank) continue
519
+ if (hiddenIds && typeof hiddenIds.has === 'function' && hiddenIds.has(id)) continue
520
+ alive++
521
+ }
522
+ // 一条带 id 的都没有 = 结构不认识(不是「组空」),照未知处理。
523
+ return seen === 0 ? null : alive
524
+ }
525
+
526
+ // 把「还能出现几行」落到展开按钮上:
527
+ // - `more` 未知(拿不到数据层)→ 一律不动作。DOM 兜底在数学上不成立
528
+ // (窗口内/外的隐藏数口径不同),宁可不改也不写一个看起来合理却会算错的数;
529
+ // - 文案没有数字(展开态的「收起」、或上游改成纯图标)→ 不改也不藏。无法确认
530
+ // 是不是计数文案时保持沉默;
531
+ // - 只替换第一个数字(各语言文案都把计数放在数字位,如「展开其余 12 个会话」
532
+ // /「Show 12 more」),支持千分位写法;
533
+ // - `more === 0` 才藏按钮:此时确实一行都不会再出现,留着只会让人反复点。
534
+ export function overflowPlan(text, more) {
535
+ const raw = typeof text === 'string' ? text : ''
536
+ const unknown = more == null || !Number.isFinite(Number(more))
537
+ const remaining = unknown ? 0 : Math.max(0, Math.floor(Number(more)))
538
+ const m = raw.match(/(\d[\d,]*)/)
539
+ const base = m ? Number(m[1].replace(/,/g, '')) : null
540
+ if (base === null || unknown) {
541
+ return { text: raw, hide: false, base, more: unknown ? null : remaining, changed: false }
542
+ }
543
+ if (remaining === 0) {
544
+ // base<=0 是上游没给出可信计数(理论上不会出现):不据此隐藏,避免误判。
545
+ return { text: raw, hide: base > 0, base, more: 0, changed: false }
546
+ }
547
+ if (remaining === base) {
548
+ return { text: raw, hide: false, base, more: remaining, changed: false }
549
+ }
550
+ return {
551
+ text: raw.slice(0, m.index) + String(remaining) + raw.slice(m.index + m[1].length),
552
+ hide: false,
553
+ base,
554
+ more: remaining,
555
+ changed: true,
556
+ }
557
+ }