dsh-sessions-manager 3.5.3 → 3.6.0

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.
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useEffect, useMemo, useRef, useState } from 'react'
14
- import { authoritativeTitleForFirstPaint, canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
14
+ import { authoritativeTitleForFirstPaint, canDropOnWorkspace, dotStateFor, foldSubagents, openSubagentToast, sessionForNodes, shortId, starredOf, toastDurationFor, workspaceForNodes } from './logic.js'
15
15
 
16
16
  export const inject = ['slots']
17
17
 
@@ -21,43 +21,75 @@ function loadPanelPrefs() {
21
21
  }
22
22
 
23
23
  const CSS = `
24
- .archv{--dsm-radius-tag:4px;--dsm-radius-ctl:9px;--dsm-radius-sheet:10px;--dsm-radius-card:12px;display:flex;flex-direction:column;gap:4px;max-width:800px;padding:8px 2px 28px}
24
+ .archv{--dsm-radius-tag:9px;--dsm-radius-ctl:9px;--dsm-radius-sheet:10px;--dsm-radius-card:12px;display:flex;flex-direction:column;gap:4px;max-width:800px;padding:8px 2px 28px}
25
25
  .archv-head{display:flex;align-items:center;gap:10px;margin:0 0 2px}
26
26
  .archv-title{font-size:16px;font-weight:650;color:var(--dsw-alias-label-primary);letter-spacing:-0.01em;margin:0}
27
+ .archv-stamp{font-size:11px;font-weight:500;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:2px 8px;white-space:nowrap;cursor:default}
27
28
  .archv-count{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;flex:none}
28
29
  .archv-sub{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-tertiary);margin:0 0 12px;max-width:64ch}
29
30
  .archv-err{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);border-radius:var(--dsm-radius-sheet);color:var(--dsw-alias-state-error-primary);font-size:12px;margin-bottom:10px}
30
31
  .archv-errretry{appearance:none;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent);background:transparent;color:inherit;border-radius:8px;padding:4px 10px;font-size:11px;cursor:pointer;flex:none}
31
- .sess-filter{display:flex;align-items:center;gap:6px;margin:0 0 12px}
32
+ .sess-fwrap{margin:0 0 12px}
33
+ .sess-filter{display:flex;align-items:center;gap:6px}
34
+ /* 低频分类(已收藏/空白/回收站)横向收纳:默认 max-width:0 隐藏,点按箭头
35
+ (sess-fmore-open)或低频分类处于选中态(sess-fsec-on)时向右展开;
36
+ max-width 过渡做横向滑出动画,overflow 裁剪回缩过程。 */
37
+ .sess-fmore{display:inline-flex;align-items:center;gap:6px;max-width:0;opacity:0;overflow:hidden;white-space:nowrap;transform:translateX(-8px);visibility:hidden;transition:max-width .22s ease,opacity .18s ease,transform .22s ease,visibility 0s .22s}
38
+ .sess-fwrap.sess-fmore-open .sess-fmore,.sess-fwrap.sess-fsec-on .sess-fmore{max-width:30em;opacity:1;transform:none;visibility:visible;transition:max-width .22s ease,opacity .18s ease,transform .22s ease}
39
+ .sess-farrow{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none;padding:0;opacity:0;pointer-events:none;transition:opacity .18s ease,color .18s ease}
40
+ /* 箭头默认紧跟三个主 tab 右边(悬停筛选行才浮现);展开组撑开后把它推到
41
+ 行尾。无外圈底色,只渲染 svg 本体。隐藏态用 pointer-events:none 挡住
42
+ 误点;展开态(含选中低频分类的兜底)常驻——收起入口不能消失。 */
43
+ .sess-fwrap:hover .sess-farrow,.sess-fwrap.sess-fmore-open .sess-farrow,.sess-fwrap.sess-fsec-on .sess-farrow{opacity:1;pointer-events:auto}
44
+ .sess-farrow:hover{color:var(--dsw-alias-label-primary)}
45
+ .sess-farrow svg{display:block;transition:transform .22s ease}
46
+ .sess-fwrap.sess-fmore-open .sess-farrow svg,.sess-fwrap.sess-fsec-on .sess-farrow svg{transform:rotate(180deg)}
32
47
  .sess-fbtn{appearance:none;min-height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:999px;font-size:12px;font-weight:500;cursor:pointer}
33
48
  .sess-fbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
34
49
  .sess-fbtn-on{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
35
- .sess-tools{display:grid;grid-template-columns:minmax(180px,1fr) minmax(140px,.7fr) minmax(130px,.55fr);gap:8px;margin:0 0 8px}
50
+ .sess-tools{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin:0 0 8px}
51
+ .sess-tools-4{grid-template-columns:repeat(auto-fit,minmax(132px,1fr))}
52
+ .dsm-kids{display:flex;flex-direction:column;gap:6px;margin:10px 0 2px;margin-left:43px;padding-left:10px;border-left:2px solid var(--dsw-alias-border-l3)}
53
+ .dsm-kid .dsm-kids{margin-left:8px;margin-top:6px}
54
+ .dsm-kids-toggle{appearance:none;min-height:22px;padding:0 9px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary);border-radius:var(--dsm-radius-tag);font:inherit;font-size:11px;font-weight:500;cursor:pointer;flex:none;white-space:nowrap}
55
+ .dsm-kids-toggle:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent)}
56
+ .dsm-kid{display:flex;flex-direction:column;gap:6px;min-width:0}
57
+ .dsm-kid-row{display:flex;align-items:center;gap:8px;min-width:0}
58
+ .dsm-kid-name{flex:1 1 auto;min-width:0;font-size:12px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
59
+ .dsm-kid-meta{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:none;white-space:nowrap}
60
+ .dsm-kid-acts{display:flex;align-items:center;gap:6px;flex:none;margin-left:auto}
61
+ .dsm-kid-acts .archv-btn{min-height:26px;padding:0 9px;font-size:11px}
36
62
  .sess-field{display:flex;flex-direction:column;gap:5px;min-width:0}
37
63
  .sess-field label{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary)}
38
64
  .sess-field input,.sess-field select{box-sizing:border-box;width:100%;min-height:36px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-ctl);background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
39
- .sess-results{font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0 0 4px}
65
+ .sess-results{display:flex;flex-direction:column;gap:1px;font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0 0 4px}
40
66
  .archv button:focus-visible,.archv input:focus-visible,.archv select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
41
67
  .sess-batch{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 2px 4px;margin-bottom:4px}
68
+ /* issue #7:勾选后批量操作栏吸顶——列表再长,操作按钮也一直在手边。 */
69
+ .sess-batch-pin{position:sticky;top:0;z-index:30;margin:0 -2px 4px;padding:8px 4px 6px;background:var(--dsw-alias-bg-layer-1);border-bottom:1px solid var(--dsw-alias-border-l2);box-shadow:0 6px 14px rgb(0 0 0/.08)}
42
70
  .sess-btntext{font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none}
43
71
  .archv-list{display:flex;flex-direction:column;gap:8px}
44
72
  .archv-card{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-card);background:var(--dsw-alias-fill-elevated);transition:border-color .15s ease,background-color .15s ease}
45
73
  .archv-card:hover{border-color:var(--dsw-alias-border-l4)}
46
74
  .archv-card-exp{border-color:var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1)}
47
75
  .archv-row{display:flex;align-items:center;gap:14px;width:100%;min-width:0}
48
- .archv-main{min-width:0;display:flex;flex-direction:column;gap:4px}
49
- .archv-name{font-size:13px;font-weight:550;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
50
- .archv-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;min-width:0}
51
- .archv-wtag{display:inline-flex;align-items:center;gap:4px;max-width:100%;font-size:11px;font-weight:500;line-height:1;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);padding:3px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
76
+ .archv-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px}
77
+ .archv-titlerow{display:flex;align-items:center;gap:8px;min-width:0}
78
+ .archv-name{font-size:13px;font-weight:550;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex:0 1 auto}
79
+ .archv-meta{display:flex;align-items:center;gap:7px;flex-wrap:nowrap;min-width:0;overflow:hidden}
80
+ .archv-wtag{display:inline-flex;align-items:center;gap:4px;min-width:0;flex:0 1 auto;font-size:11px;font-weight:500;line-height:1;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);padding:3px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
52
81
  .archv-wgone{border-style:dashed;color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 7%,transparent)}
53
82
  .archv-active{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent)}
54
83
  .archv-date{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;flex:none}
55
- .archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
84
+ .dsm-branch-chip{display:inline-flex;align-items:center;flex:none;min-height:22px;padding:0 9px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,transparent);border-radius:var(--dsm-radius-tag);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent);color:var(--dsw-alias-state-success-primary);font-size:11px;font-weight:500;line-height:1;white-space:nowrap}
85
+ .dsm-empty-chip{display:inline-flex;align-items:center;flex:none;min-height:22px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:500;line-height:1;white-space:nowrap}
86
+ .archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none;margin-left:auto;white-space:nowrap}
56
87
  .archv-dot{color:var(--dsw-alias-border-l3);flex:none}
57
88
  .archv-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);flex:none;cursor:pointer}
58
- .archv-star{appearance:none;width:24px;height:24px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:none;background:0 0;border-radius:7px;cursor:pointer;color:var(--dsw-alias-label-tertiary);transition:color .15s ease,background-color .15s ease}
59
- .archv-star:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
60
- .archv-star svg{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:round}
89
+ .archv-star{appearance:none;width:22px;height:22px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:none;background:0 0;padding:0;line-height:0;color:var(--dsw-alias-label-tertiary);cursor:pointer;border-radius:50%;transition:color .15s ease,transform .12s ease}
90
+ .archv-star:hover{background:0 0;color:var(--dsw-alias-label-secondary);transform:scale(1.12)}
91
+ .archv-star:active{transform:scale(.92)}
92
+ .archv-star svg{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:round;stroke-linecap:round;display:block}
61
93
  .archv-star-on,.archv-star-on:hover{color:var(--dsw-alias-state-business-primary)}
62
94
  .archv-star-on svg{fill:currentColor}
63
95
  .archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
@@ -73,13 +105,16 @@ const CSS = `
73
105
  .archv-skel{display:flex;flex-direction:column;gap:8px}
74
106
  .archv-skel-card{height:58px;border-radius:var(--dsm-radius-card);background:var(--dsw-alias-fill-subtle);position:relative;overflow:hidden}
75
107
  .archv-skel-card::after{content:'';position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--dsw-alias-fill-elevated) 75%,transparent),transparent);animation:archv-shimmer 1.4s infinite}
76
- .archv-status{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:60;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgb(0 0 0/.18);display:flex;align-items:center;gap:8px;animation:archv-pop .18s ease-out;max-width:min(90vw,420px)}
108
+ .archv-status{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:2147483601;background:linear-gradient(var(--dsw-alias-fill-elevated),var(--dsw-alias-fill-elevated)),var(--dsw-alias-bg-layer-1,Canvas);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;line-height:1.5;text-align:center;box-shadow:0 8px 24px rgb(0 0 0/.25);display:flex;align-items:center;gap:8px;animation:archv-pop .18s ease-out;max-width:min(92vw,480px);cursor:pointer}
109
+ .archv-status-long{border-radius:14px;text-align:left}
77
110
  .archv-spin{width:12px;height:12px;border:2px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent);border-top-color:var(--dsw-alias-label-secondary);border-radius:50%;animation:archv-rot .8s linear infinite;flex:none}
78
111
  @keyframes archv-shimmer{100%{transform:translateX(100%)}}
79
112
  @keyframes archv-rot{to{transform:rotate(360deg)}}
80
113
  @keyframes archv-pop{from{opacity:0;transform:translateX(-50%) translateY(10px)}}
81
- @media (prefers-reduced-motion:reduce){.archv-skel-card::after{animation:none}.archv-card,.archv-btn{transition:none}.archv-status,.archv-spin{animation:none}}
82
- @media (max-width:640px){.archv-card{flex-direction:column;align-items:stretch;gap:10px}.archv-actions{justify-content:flex-end}.sess-tools{grid-template-columns:1fr}.sess-fbtn,.archv-btn{min-height:40px}}
114
+ /* 失败提示用警示色 + 加粗,并停留更久(需要用户读完去做下一步操作)。 */
115
+ .archv-status-err{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-weight:500}
116
+ @media (prefers-reduced-motion:reduce){.archv-skel-card::after{animation:none}.archv-card,.archv-btn,.archv-star{transition:none}.archv-star:hover,.archv-star:active{transform:none}.archv-status,.archv-spin{animation:none}}
117
+ @media (max-width:640px){.archv-card{flex-direction:column;align-items:stretch;gap:10px}.archv-actions{justify-content:flex-end}.sess-tools,.sess-tools-4{grid-template-columns:1fr}.sess-fbtn,.archv-btn{min-height:40px}.archv-star{width:30px;height:30px}.archv-star svg{width:22px;height:22px}.dsm-kids{margin-left:10px}.archv-titlerow{flex-wrap:wrap}.dsm-kid-row{flex-wrap:wrap}.dsm-kid-acts{margin-left:0}}
83
118
  .mv-sheet{width:100%;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-sheet);background:var(--dsw-alias-fill-subtle)}
84
119
  .mv-sheet-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
85
120
  .mv-sheet-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}
@@ -157,6 +192,10 @@ function pathName(p) {
157
192
  return parts[parts.length - 1] || p
158
193
  }
159
194
 
195
+ // 服务端 409 错误码:会话正被 DSH 占用写权限(0.1.3 单写者机制)。切走会话后
196
+ // 原地重试即可,所以这类失败要给用户一个「重试」入口。
197
+ const BUSY_CODE = 'DSM_SESSION_BUSY'
198
+
160
199
  async function postJSON(path, body) {
161
200
  const res = await fetch(path, {
162
201
  method: 'POST',
@@ -164,10 +203,67 @@ async function postJSON(path, body) {
164
203
  body: JSON.stringify(body || {}),
165
204
  })
166
205
  const data = await res.json().catch(() => null)
167
- if (!res.ok || !data) throw new Error((data && (data.error || data.message)) || `request failed (${res.status})`)
206
+ if (!res.ok || !data) {
207
+ // 保留服务端错误码:UI 需要据此区分「可重试」的失败(如 DSM_SESSION_BUSY
208
+ // —— 会话正被 DSH 占用写权限,切走会话后原地重试即可)与重试也无用的错误。
209
+ const error = new Error((data && (data.error || data.message)) || `request failed (${res.status})`)
210
+ if (data && data.code) error.code = data.code
211
+ throw error
212
+ }
168
213
  return data
169
214
  }
170
215
 
216
+ // 打开一个会话(尤其是侧栏不渲染的子代理会话)。
217
+ //
218
+ // 只走官方公开契约(runtime 的 client api-catalog):
219
+ // sessions.open(id) —— 选中会话为当前
220
+ // sessions.refreshSubagents(parent) —— 刷新某父会话的直接子代理目录
221
+ // sessions.openSubagent(address) —— 用「直接父 + 子」地址打开目录里的子代理
222
+ // 拿不到 sessions 服务(老 runtime)时返回 false,由调用方给出降级提示,绝不
223
+ // 触碰任何私有内部状态。
224
+ let dsmClientCtx = null
225
+ function dsmSessionsService() {
226
+ try { return dsmClientCtx && typeof dsmClientCtx.get === 'function' ? dsmClientCtx.get('sessions') : null } catch (e) { return null }
227
+ }
228
+ function dsmCurrentSessionId(svc) {
229
+ try {
230
+ const snap = svc && svc.list && typeof svc.list.getSnapshot === 'function' ? svc.list.getSnapshot() : null
231
+ return snap && snap.current != null ? String(snap.current) : null
232
+ } catch (e) { return null }
233
+ }
234
+ // 返回 Promise<'ok' | 'no-service' | 'failed'>:只回 true/false 的话,调用方
235
+ // 只能给出一句「打不开」的笼统提示——用户既不知道为什么,也不知道下一步该
236
+ // 怎么办。区分原因后提示才能说清「去哪儿、做什么」。
237
+ async function dsmOpenSessionById(childId, directParentId) {
238
+ const sid = String(childId || '')
239
+ if (!sid) return 'failed'
240
+ const svc = dsmSessionsService()
241
+ if (!svc) return 'no-service'
242
+ const parent = directParentId ? String(directParentId) : null
243
+ // 1) 直接选中(对仍在会话列表里的子代理最省事)。
244
+ if (typeof svc.open === 'function') {
245
+ try {
246
+ svc.open(sid)
247
+ await new Promise((r) => setTimeout(r, 350))
248
+ if (dsmCurrentSessionId(svc) === sid) return 'ok'
249
+ } catch (e) { /* 落到目录地址路径 */ }
250
+ }
251
+ // 2) 官方子代理目录路径:父会话头部的目录是 DSH 为子代理准备的导航入口,
252
+ // 这里复用同一个地址契约(parentSessionId 必须是「直接父」,孙代也一样)。
253
+ if (parent && typeof svc.openSubagent === 'function') {
254
+ try {
255
+ if (typeof svc.refreshSubagents === 'function') await svc.refreshSubagents(parent)
256
+ svc.openSubagent({ parentSessionId: parent, childSessionId: sid, mode: 'continuable' })
257
+ await new Promise((r) => setTimeout(r, 350))
258
+ if (dsmCurrentSessionId(svc) === sid) return 'ok'
259
+ // openSubagent 是同步 void,无法从返回值判断成败:地址已提交、当前会话
260
+ // 可能因父会话上下文尚未就绪而稍后才切换,这里按成功处理避免重复触发。
261
+ return 'ok'
262
+ } catch (e) { /* 落到失败提示 */ }
263
+ }
264
+ return 'failed'
265
+ }
266
+
171
267
  // Left-nav icon swap: DSH's settings shell renders a shared fallback gear for
172
268
  // every custom section and settings.section has no per-section icon field.
173
269
  // Like dsh-better-sidebar, we mark our own row by matching its visible label
@@ -186,8 +282,12 @@ const NAV_CSS = `
186
282
  function markSettingsNav() {
187
283
  const LABEL = '会话管理'
188
284
  let disposed = false
189
- const sync = () => {
285
+ let frame = 0
286
+ const run = () => {
287
+ frame = 0
190
288
  if (disposed) return
289
+ // 廉价早退:设置面板没打开时整页只有一个 dialog 查询的开销,不遍历按钮。
290
+ if (!document.querySelector('[role="dialog"]')) return
191
291
  const buttons = document.querySelectorAll('[role="dialog"] nav button')
192
292
  for (const b of buttons) {
193
293
  const t = (b.textContent || '').trim()
@@ -195,11 +295,18 @@ function markSettingsNav() {
195
295
  else b.removeAttribute('data-dsh-nav-sessions')
196
296
  }
197
297
  }
198
- sync()
298
+ // rAF 合并:整页 MutationObserver 在一个渲染帧里可能触发成百上千次回调,
299
+ // 每帧最多真正同步一次。
300
+ const sync = () => { if (!frame && !disposed) frame = requestAnimationFrame(run) }
301
+ run()
302
+ // 刻意**不**监听 characterData:流式回复逐 token 改文本,会把整页观察器打成
303
+ // 高频回调;导航按钮的标签只在 childList(面板重渲染)时变化,够用。
199
304
  const obs = new MutationObserver(sync)
200
- obs.observe(document.body, { childList: true, subtree: true, characterData: true })
305
+ obs.observe(document.body, { childList: true, subtree: true })
201
306
  return () => {
202
307
  disposed = true
308
+ if (frame) cancelAnimationFrame(frame)
309
+ frame = 0
203
310
  obs.disconnect()
204
311
  document.querySelectorAll('[data-dsh-nav-sessions]').forEach((e) => e.removeAttribute('data-dsh-nav-sessions'))
205
312
  }
@@ -223,19 +330,27 @@ function SessionPanel({ workspacesSvc }) {
223
330
  const initialPrefs = useRef(loadPanelPrefs()).current
224
331
  // 注意:'storage' 已从可持久化取值中移除(它不再是视图),旧版残留的
225
332
  // filter='storage' 会自动回落到 'all',避免落到一个已不存在的界面。
226
- const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'starred', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
333
+ const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'starred', 'empty', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
334
+ // 低频分类折叠区:挂载时若选中的就是低频分类(偏好恢复/上次停留)则展开,
335
+ // 保证激活 tab 始终可见;平时由箭头 hover/点按控制。
336
+ const [moreOpen, setMoreOpen] = useState(() => ['starred', 'empty', 'trash'].includes(initialPrefs.filter))
227
337
  const [query, setQuery] = useState('')
228
338
  const [workspaceFilter, setWorkspaceFilter] = useState(() => initialPrefs.workspaceFilter || 'all')
229
339
  const [sortBy, setSortBy] = useState(() => ['newest', 'oldest', 'title'].includes(initialPrefs.sortBy) ? initialPrefs.sortBy : 'newest')
230
340
  const [selected, setSelected] = useState({})
231
341
  const [delTarget, setDelTarget] = useState(null)
232
342
  const [confirmBatch, setConfirmBatch] = useState(false)
343
+ const [batchMoveOpen, setBatchMoveOpen] = useState(false)
233
344
  const [busy, setBusy] = useState(null)
234
345
  const [openMove, setOpenMove] = useState(null)
235
346
  const [moveMode, setMoveMode] = useState('existing')
236
347
  const [targetWs, setTargetWs] = useState('')
237
348
  const [newPath, setNewPath] = useState('')
238
349
  const [error, setError] = useState(null)
350
+ // 可重试的失败:{ run } —— run 是「把刚才那一步原样再做一次」的闭包。存对象
351
+ // 而非裸函数,避免被 useState 当成更新函数吃掉。没有它时错误条的「重试」退回
352
+ // 刷新列表(refresh)。
353
+ const [retry, setRetry] = useState(null)
239
354
  const [toast, setToast] = useState(null)
240
355
  const [picking, setPicking] = useState(false)
241
356
  const [trash, setTrash] = useState([])
@@ -266,10 +381,26 @@ function SessionPanel({ workspacesSvc }) {
266
381
  const menuRef = useRef(null)
267
382
  const dialogRef = useRef(null)
268
383
 
269
- const showToast = (msg) => {
384
+ // 血缘分组(issue #6):子代理折叠在父会话下。DSH 侧栏按设计不渲染子代理
385
+ // 行(ui-subagent 明文 "omitted from the ordinary sidebar"),本面板是唯一
386
+ // 能完整看到它们的地方,所以同一套「父 — 子」模型在这里落地:分组开启时
387
+ // 子代理不再作为独立顶层卡片,而是折叠在父会话卡片下方。数据来自
388
+ // sidebar-state 的官方 header 血缘字段(parentSession / origin),零解码。
389
+ const [lineage, setLineage] = useState({})
390
+ const [openKids, setOpenKids] = useState({})
391
+ const [groupByLineage, setGroupByLineage] = useState(() => initialPrefs.groupByLineage !== false)
392
+
393
+ const loadLineage = () => postJSON('/archived-sessions/sidebar-state', {})
394
+ .then((r) => setLineage((r && r.lineage) || {}))
395
+ .catch(() => {})
396
+
397
+ // kind: 'ok'(默认)| 'err'。停留时长按**字数**给(logic.toastDurationFor):
398
+ // 固定 2.4s 对"排队/失败"这类几十字的文案根本读不完(用户 2026-09-10 反馈);
399
+ // 失败类再 +2s 并用警示色。任何提示都可以**点击立即关闭**,不必干等。
400
+ const showToast = (msg, kind) => {
270
401
  if (timer.current) clearTimeout(timer.current)
271
- setToast(msg)
272
- timer.current = setTimeout(() => setToast(null), 2400)
402
+ setToast({ msg, kind: kind === 'err' ? 'err' : 'ok' })
403
+ timer.current = setTimeout(() => setToast(null), toastDurationFor(msg, kind))
273
404
  }
274
405
 
275
406
  const refresh = () => {
@@ -287,9 +418,13 @@ function SessionPanel({ workspacesSvc }) {
287
418
  setSessions(visible)
288
419
  setWorkspaces(works.items || [])
289
420
  setCapabilities(caps || null)
421
+ // 构建指纹打到控制台:面板行为与源码不一致时,第一件事就是核对这行——
422
+ // host 端只在宿主启动时加载一次,不重启宿主看到的永远是旧代码。
423
+ if (caps && caps.buildStamp) console.info('[dsh-sessions-manager] host build:', caps.buildStamp)
290
424
  setSelected({})
291
425
  setDelTarget(null)
292
426
  setConfirmBatch(false)
427
+ loadLineage()
293
428
  if (!targetWs && works.items && works.items.length) setTargetWs(works.items[0].workspaceId)
294
429
  loadTrash()
295
430
  // 会话集合变了:面板开着就同步刷新;关着则只标脏,等展开时再刷。
@@ -306,8 +441,8 @@ function SessionPanel({ workspacesSvc }) {
306
441
  }, [])
307
442
 
308
443
  useEffect(() => {
309
- try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy })) } catch (e) {}
310
- }, [filter, workspaceFilter, sortBy])
444
+ try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy, groupByLineage })) } catch (e) {}
445
+ }, [filter, workspaceFilter, sortBy, groupByLineage])
311
446
 
312
447
  // 自动归档设置随面板加载一次(host 侧读取即触发每日检查)。
313
448
  // 存储统计刻意不在这里预取:它要 stat 每条日志,只在用户展开面板时才算。
@@ -408,9 +543,15 @@ function SessionPanel({ workspacesSvc }) {
408
543
  const archivedList = sessions ? sessions.filter((x) => x.archived) : []
409
544
  const activeList = sessions ? sessions.filter((x) => !x.archived) : []
410
545
  const starredList = starredOf(sessions)
546
+ // issue #6 之三:空白会话单分一类。空白判定来自宿主血缘快照(sizeBytes 阈值
547
+ // 实测校准),与侧栏「空白」标记同源——两处永远不会各说各话。
548
+ const emptyList = useMemo(() => {
549
+ if (!sessions) return []
550
+ return sessions.filter((x) => { const li = lineage[String(x.sessionId)]; return !!(li && li.empty) })
551
+ }, [sessions, lineage])
411
552
  const list = useMemo(() => {
412
553
  if (filter === 'trash') return []
413
- const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : filter === 'starred' ? starredList : sessions || []
554
+ const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : filter === 'starred' ? starredList : filter === 'empty' ? emptyList : sessions || []
414
555
  const needle = query.trim().toLocaleLowerCase()
415
556
  const filtered = base.filter((item) => {
416
557
  if (workspaceFilter !== 'all' && (item.workspacePath || '') !== workspaceFilter) return false
@@ -422,11 +563,86 @@ function SessionPanel({ workspacesSvc }) {
422
563
  if (sortBy === 'title') return String(a.title || '').localeCompare(String(b.title || ''), 'zh-CN')
423
564
  return Number(b.createdAt || 0) - Number(a.createdAt || 0)
424
565
  })
425
- }, [sessions, filter, query, workspaceFilter, sortBy])
566
+ }, [sessions, filter, query, workspaceFilter, sortBy, emptyList, starredList])
426
567
  const selIds = Object.keys(selected).filter((k) => selected[k])
427
568
  // 「回收站」是独立视图,不共用会话列表。
428
569
  const showSessionList = filter !== 'trash'
429
570
 
571
+ // 血缘分组:把 origin === 'subagent' 的会话挂到父会话卡片下(可嵌套到孙
572
+ // 代)。父会话不在当前列表里(被筛选掉 / 已删除 / 跨组)时子会话保持顶层,
573
+ // 保证「搜得到就一定看得见」,不会因为折叠而消失。
574
+ const { topList, kidsOf, foldedCount } = useMemo(() => {
575
+ if (!groupByLineage) return { topList: list, kidsOf: new Map(), foldedCount: 0 }
576
+ return foldSubagents(list, lineage)
577
+ }, [list, lineage, groupByLineage])
578
+
579
+ // 子代理折叠分两个部件,各自贴在它该在的位置:
580
+ // kidsBadge —— 标题行内,紧跟会话标题,是「这个会话有 N 个子代理」的身份
581
+ // 标记(贴在卡片底部会显得像多出的一块,没有归属);
582
+ // renderKids —— 展开时的子列表,用竖线挂在父卡片内容左边缘下方。
583
+ const kidsBadge = (sessionId) => {
584
+ if (!groupByLineage) return null
585
+ const kids = kidsOf.get(String(sessionId)) || []
586
+ if (!kids.length) return null
587
+ const open = !!openKids[sessionId]
588
+ return (
589
+ <button
590
+ type="button"
591
+ className="dsm-kids-toggle"
592
+ aria-expanded={open}
593
+ title={(open ? '收起' : '展开') + ' ' + kids.length + ' 个子代理会话'}
594
+ onClick={() => setOpenKids((s) => ({ ...s, [sessionId]: !s[sessionId] }))}
595
+ >
596
+ {open ? '▾' : '▸'} {kids.length} 子代理
597
+ </button>
598
+ )
599
+ }
600
+
601
+ // issue #6 之二/之三:面板侧的分支与空白视觉信号(侧栏已有同源 chip/tag)。
602
+ // 分支标记 hover 说明来源;空白标记提示去「空白」分类清理。
603
+ // 父会话标题查找走一次性 Map(sessions 会到几百条,每卡片 find 是 O(n²))。
604
+ const sessionsById = useMemo(() => new Map((sessions || []).map((x) => [String(x.sessionId), x])), [sessions])
605
+ const branchBadge = (sessionId) => {
606
+ const li = lineage[String(sessionId)]
607
+ if (!li || li.origin === 'subagent' || !li.parentSession) return null
608
+ const parent = sessionsById.get(String(li.parentSession))
609
+ const label = parent && parent.title ? `分支于:${parent.title}` : `分支会话(来源:${shortId(li.parentSession)})`
610
+ return <span className="dsm-branch-chip" title={label}>⑂ 分支</span>
611
+ }
612
+ const emptyBadge = (sessionId) => {
613
+ const li = lineage[String(sessionId)]
614
+ if (!li || !li.empty) return null
615
+ return <span className="dsm-empty-chip" title="无内容的空白会话">空白</span>
616
+ }
617
+
618
+ const renderKids = (parentId, depth) => {
619
+ const kids = kidsOf.get(String(parentId)) || []
620
+ if (!kids.length || !openKids[parentId]) return null
621
+ return (
622
+ <div className="dsm-kids">
623
+ {kids.map((k) => (
624
+ <div className="dsm-kid" key={k.sessionId}>
625
+ <div className="dsm-kid-row">
626
+ <span className="dsm-kid-name" title={k.title || k.sessionId}>{k.title || '(无标题)'}</span>
627
+ <span className="dsm-kid-meta">{k.archived ? '已归档' : '活动'}{fmtDate(k.createdAt) ? ` · ${fmtDate(k.createdAt)}` : ''}</span>
628
+ <span className="dsm-kid-acts">
629
+ <button type="button" className="archv-btn" disabled={busy !== null} title="切换到这个子代理会话(设置面板挡着会话区,关掉即可看到)" onClick={async () => {
630
+ const name = k.title || (String(k.sessionId).slice(0, 8) + '…')
631
+ const res = await dsmOpenSessionById(k.sessionId, parentId)
632
+ const msg = openSubagentToast(res, name, 'panel')
633
+ showToast(msg.text, msg.kind)
634
+ }}>打开</button>
635
+ <button type="button" className="archv-btn" disabled={busy !== null} onClick={() => act(k.archived ? 'restore' : 'archive', k)}>{k.archived ? '恢复' : '归档'}</button>
636
+ <button type="button" className="archv-btn archv-del" disabled={busy !== null} title="移入回收站,可在回收站恢复" onClick={() => setDelTarget(k)}>删除</button>
637
+ </span>
638
+ </div>
639
+ {(depth || 0) < 4 && renderKids(k.sessionId, (depth || 0) + 1)}
640
+ </div>
641
+ ))}
642
+ </div>
643
+ )
644
+ }
645
+
430
646
  const toggle = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }))
431
647
  const clearSel = () => setSelected({})
432
648
  const selectAll = () => {
@@ -586,13 +802,30 @@ function SessionPanel({ workspacesSvc }) {
586
802
  setOpenMove(null)
587
803
  setMoveMode('existing')
588
804
  setNewPath('')
805
+ // 会话被 DSH 占用(活跃会话 / 未关闭的会话):服务端已登记排队,等它
806
+ // 释放(session/disposed)或下次启动时自动完成。不要谎报"已移动"。
807
+ if (r && r.queued) {
808
+ showToast(r.notes && r.notes.length ? r.notes.join(' ') : '会话正被 DSH 打开,已排队待移动。', 'err')
809
+ setRetry(null)
810
+ refresh()
811
+ return
812
+ }
589
813
  showToast(`已把「${it.title || it.sessionId}」移到 ${r.workspaceTitle || targetPath}`)
814
+ // 非致命提示(如源目录仍有旧代日志未自动清理):错误样式 + 停留更久,
815
+ // 让用户来得及读完再去决定怎么处理。
816
+ if (Array.isArray(r.notes) && r.notes.length > 0) showToast(r.notes.join(' '), 'err')
590
817
  // 移动只改 workspacePath/workspaceTitle:本地替换该行,不整表 refresh。
591
818
  setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId
592
819
  ? { ...x, workspacePath: r.workspacePath || targetPath, workspaceTitle: r.workspaceTitle || targetPath }
593
820
  : x)))
821
+ setRetry(null)
822
+ })
823
+ .catch((e) => {
824
+ setBusy(null)
825
+ setError(String((e && e.message) || e))
826
+ // 会话被 DSH 占用写权限:切走会话后原地重试即可,不必重新选目标。
827
+ setRetry(e && e.code === BUSY_CODE ? { run: () => doMove(it) } : null)
594
828
  })
595
- .catch((e) => { setBusy(null); setError(String((e && e.message) || e)) })
596
829
  }
597
830
 
598
831
  const doBatch = (action) => {
@@ -612,6 +845,54 @@ function SessionPanel({ workspacesSvc }) {
612
845
  .catch((e) => { setBusy(null); setConfirmBatch(false); setError(String((e && e.message) || e)) })
613
846
  }
614
847
 
848
+ // issue #7:批量移动到同一个目标工作区。失败逐条回报,不中断其余会话。
849
+ // 传入 ids 时只搬这些(用于「重试失败的会话」),否则搬当前选中。
850
+ const doBatchMove = (retryIds) => {
851
+ const ids = (Array.isArray(retryIds) && retryIds.length) ? retryIds : selIds
852
+ if (!ids.length || busy) return
853
+ if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
854
+ const targetPath = moveMode === 'new' ? newPath.trim() : wsPath(targetWs)
855
+ if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
856
+ setBusy('__batch__')
857
+ setError(null)
858
+ setRetry(null)
859
+ postJSON('/archived-sessions/move-many', { sessionIds: ids, targetPath })
860
+ .then((r) => {
861
+ setBusy(null)
862
+ setBatchMoveOpen(false)
863
+ setMoveMode('existing')
864
+ setNewPath('')
865
+ const moved = (r && r.moved) || 0
866
+ const failed = (r && Array.isArray(r.failed)) ? r.failed : []
867
+ const queued = (r && Array.isArray(r.queued)) ? r.queued : []
868
+ if (queued.length) {
869
+ // 活跃/被占用的会话:服务端已排队,下次重启 DSH 时自动完成。
870
+ showToast(`${queued.length} 个会话已排队(正被 DSH 打开),重启 DSH 后自动完成;请先别打开它们。`, 'err')
871
+ }
872
+ if (failed.length) {
873
+ // 短 ID 列表 + 首个失败原因:长 UUID 全列出来不可读,原因只给第一条
874
+ // (通常同批失败同因),完整原因仍可在控制台网络面板查到。
875
+ const firstReason = failed[0] && failed[0].error ? `,首个原因:${failed[0].error.split('\n')[0]}` : ''
876
+ setError(`已移动 ${moved} 个会话,${failed.length} 个失败(${failed.map((f) => shortId(f.sessionId)).join('、')})${firstReason}`)
877
+ // 被 DSH 占用写权限的那几个:切走会话后重试仍有戏,只重投这几条。
878
+ const busyIds = failed.filter((f) => f.code === BUSY_CODE).map((f) => f.sessionId)
879
+ setRetry(busyIds.length ? { run: () => doBatchMove(busyIds) } : null)
880
+ } else if (moved > 0) {
881
+ showToast(`已把 ${moved} 个会话移到 ${targetPath}`)
882
+ setRetry(null)
883
+ } else {
884
+ setRetry(null)
885
+ }
886
+ clearSel()
887
+ refresh()
888
+ })
889
+ .catch((e) => {
890
+ setBusy(null)
891
+ setError(String((e && e.message) || e))
892
+ setRetry(e && e.code === BUSY_CODE ? { run: () => doBatchMove(ids) } : null)
893
+ })
894
+ }
895
+
615
896
  const openMoveFor = (it) => {
616
897
  if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
617
898
  if (openMove === it.sessionId) { setOpenMove(null); return }
@@ -711,6 +992,9 @@ function SessionPanel({ workspacesSvc }) {
711
992
  <style>{CSS}</style>
712
993
  <div className="archv-head">
713
994
  <h2 className="archv-title">会话管理</h2>
995
+ {capabilities && capabilities.buildStamp
996
+ ? <span className="archv-stamp" title={capabilities.buildStamp} onClick={() => navigator.clipboard && navigator.clipboard.writeText(capabilities.buildStamp).catch(() => {})}>{capabilities.buildStamp}</span>
997
+ : null}
714
998
  {sessions !== null && <span className="archv-count" aria-label={`${sessions.length} 个会话`}>{sessions.length}</span>}
715
999
  </div>
716
1000
  <p className="archv-sub">
@@ -719,7 +1003,16 @@ function SessionPanel({ workspacesSvc }) {
719
1003
  {error && (
720
1004
  <div className="archv-err" role="alert">
721
1005
  <span>{error}</span>
722
- <button type="button" className="archv-errretry" onClick={refresh}>重试</button>
1006
+ <button
1007
+ type="button"
1008
+ className="archv-errretry"
1009
+ title={retry ? '把刚才失败的操作原样再做一次(例如先切走被占用的会话后重试)' : '重新加载会话列表'}
1010
+ onClick={() => {
1011
+ const run = retry && retry.run
1012
+ setRetry(null)
1013
+ if (run) run(); else refresh()
1014
+ }}
1015
+ >重试</button>
723
1016
  </div>
724
1017
  )}
725
1018
  {sessions === null ? (
@@ -728,23 +1021,31 @@ function SessionPanel({ workspacesSvc }) {
728
1021
  </div>
729
1022
  ) : (
730
1023
  <>
731
- <div className="sess-filter" role="tablist" aria-label="会话筛选">
732
- <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>
733
- <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>
734
- <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>
735
- <button type="button" role="tab" aria-selected={filter === 'starred'} className={'sess-fbtn' + (filter === 'starred' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('starred'); clearSel(); setConfirmBatch(false) }}>已收藏 ({starredList.length})</button>
736
- <button type="button" role="tab" aria-selected={filter === 'trash'} className={'sess-fbtn' + (filter === 'trash' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('trash'); clearSel(); setConfirmBatch(false) }}>回收站 ({trash.length})</button>
1024
+ <div className={'sess-fwrap' + (moreOpen ? ' sess-fmore-open' : '') + (filter === 'starred' || filter === 'empty' || filter === 'trash' ? ' sess-fsec-on' : '')}>
1025
+ <div className="sess-filter" role="tablist" aria-label="会话筛选">
1026
+ <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>
1027
+ <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>
1028
+ <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>
1029
+ <span className="sess-fmore" role="tablist" aria-label="更多会话筛选">
1030
+ <button type="button" role="tab" aria-selected={filter === 'starred'} className={'sess-fbtn' + (filter === 'starred' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('starred'); clearSel(); setConfirmBatch(false) }}>已收藏 ({starredList.length})</button>
1031
+ <button type="button" role="tab" aria-selected={filter === 'empty'} className={'sess-fbtn' + (filter === 'empty' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('empty'); clearSel(); setConfirmBatch(false) }}>空白 ({emptyList.length})</button>
1032
+ <button type="button" role="tab" aria-selected={filter === 'trash'} className={'sess-fbtn' + (filter === 'trash' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('trash'); clearSel(); setConfirmBatch(false) }}>回收站 ({trash.length})</button>
1033
+ </span>
1034
+ <button type="button" className="sess-farrow" aria-expanded={moreOpen} aria-label={moreOpen ? '收起更多筛选' : '展开更多筛选'} title={moreOpen ? '收起更多筛选' : '展开更多筛选(已收藏 / 空白 / 回收站)'} onClick={() => setMoreOpen(!moreOpen)}>
1035
+ <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M4 2l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
1036
+ </button>
1037
+ </div>
737
1038
  </div>
738
1039
 
739
1040
  {/* 维护栏是面板级工具,与当前查看哪一组会话无关,故所有视图都显示。 */}
740
1041
  <div className="maint-bar">
1042
+ <button type="button" className="archv-btn" aria-expanded={storageOpen} onClick={() => { const next = !storageOpen; setStorageOpen(next); if (next && (!storage || storageDirty.current) && !storageBusy) loadStorage() }}>
1043
+ 存储占用{storage ? ` · ${fmtBytes(storage.totalBytes) || '0 B'}` : ''}
1044
+ </button>
741
1045
  <button type="button" className={'archv-btn' + (aa.settings.inactiveDays ? ' archv-go' : '')} aria-expanded={aaOpen} onClick={() => setAaOpen(!aaOpen)}>
742
1046
  自动归档{aa.settings.inactiveDays ? `:${aa.settings.inactiveDays} 天未活跃` : ':未启用'}
743
1047
  </button>
744
1048
  {aa.lastRunAt ? <span className="maint-note">上次检查 {fmtDate(aa.lastRunAt)},归档 {aa.lastArchivedCount} 个</span> : <span className="maint-note">尚未检查</span>}
745
- <button type="button" className="archv-btn maint-bar-right" aria-expanded={storageOpen} onClick={() => { const next = !storageOpen; setStorageOpen(next); if (next && (!storage || storageDirty.current) && !storageBusy) loadStorage() }}>
746
- 存储占用{storage ? ` · ${fmtBytes(storage.totalBytes) || '0 B'}` : ''}
747
- </button>
748
1049
  </div>
749
1050
  {aaOpen && (
750
1051
  <div className="mv-sheet" aria-label="自动归档设置">
@@ -825,10 +1126,9 @@ function SessionPanel({ workspacesSvc }) {
825
1126
  )}
826
1127
  </div>
827
1128
  )}
828
-
829
1129
  {showSessionList && (
830
1130
  <>
831
- <div className="sess-tools" aria-label="查找和整理会话">
1131
+ <div className="sess-tools sess-tools-4" aria-label="查找和整理会话">
832
1132
  <div className="sess-field">
833
1133
  <label htmlFor="dsm-search">搜索会话</label>
834
1134
  <input id="dsm-search" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="标题、会话 ID 或工作区" />
@@ -846,13 +1146,31 @@ function SessionPanel({ workspacesSvc }) {
846
1146
  <option value="newest">最新创建</option><option value="oldest">最早创建</option><option value="title">标题 A–Z</option>
847
1147
  </select>
848
1148
  </div>
1149
+ <div className="sess-field">
1150
+ <label htmlFor="dsm-group">分组</label>
1151
+ <select id="dsm-group" value={groupByLineage ? 'lineage' : 'flat'} onChange={(e) => setGroupByLineage(e.target.value === 'lineage')} title="血缘分组:子代理折叠在父会话下;平铺:与 DSH 原生一致,全部并列">
1152
+ <option value="lineage">血缘(子代理折叠)</option>
1153
+ <option value="flat">平铺(全部并列)</option>
1154
+ </select>
1155
+ </div>
1156
+ </div>
1157
+ <div className="sess-results" role="status">
1158
+ {/* 一句话说清现状:有子代理折叠时,折叠数取代「共 X 个」尾巴
1159
+ 出现在同一句里;没有折叠时维持「显示 N 个,共 M 个」。 */}
1160
+ <span className="sess-results-main">
1161
+ 显示 {topList.length} 个会话
1162
+ {foldedCount
1163
+ ? `,另有 ${foldedCount} 个子代理折叠在父会话下`
1164
+ : query || workspaceFilter !== 'all'
1165
+ ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : filter === 'empty' ? emptyList.length : sessions.length} 个`
1166
+ : (topList.length !== list.length ? `,共 ${list.length} 个` : '')}
1167
+ </span>
849
1168
  </div>
850
- <div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : sessions.length} 个` : ''}</div>
851
1169
  </>
852
1170
  )}
853
1171
 
854
1172
  {showSessionList && list.length > 0 && (
855
- <div className="sess-batch">
1173
+ <div className={'sess-batch' + (selIds.length > 0 ? ' sess-batch-pin' : '')}>
856
1174
  <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : filter === 'starred' ? `共 ${starredList.length} 个收藏会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
857
1175
  <button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
858
1176
  {selIds.length > 0 && (
@@ -866,17 +1184,79 @@ function SessionPanel({ workspacesSvc }) {
866
1184
  {filter !== 'archived' && (
867
1185
  <button type="button" className="archv-btn" disabled={busy !== null} onClick={() => doBatch('archive-many')}>归档所选</button>
868
1186
  )}
1187
+ {canMove.available && (
1188
+ <button
1189
+ type="button"
1190
+ className="archv-btn"
1191
+ disabled={busy !== null}
1192
+ aria-expanded={batchMoveOpen}
1193
+ onClick={() => setBatchMoveOpen(!batchMoveOpen)}
1194
+ >
1195
+ 移动所选…
1196
+ </button>
1197
+ )}
869
1198
  <button type="button" className="archv-btn" onClick={clearSel}>取消选择</button>
870
1199
  </>
871
1200
  )}
1201
+ {selIds.length > 0 && batchMoveOpen && canMove.available && (
1202
+ <div className="mv-sheet" role="region" aria-label="批量移动到工作区">
1203
+ <div className="mv-sheet-head">
1204
+ <h3 className="mv-sheet-title">批量移动 {selIds.length} 个会话</h3>
1205
+ <button type="button" className="mv-sheet-close" aria-label="关闭" onClick={() => setBatchMoveOpen(false)}>×</button>
1206
+ </div>
1207
+ <div className="mv-seg" role="tablist">
1208
+ <button type="button" role="tab" aria-selected={moveMode === 'existing'} className={'mv-segbtn' + (moveMode === 'existing' ? ' mv-segbtn-on' : '')} onClick={() => setMoveMode('existing')}>已有工作区</button>
1209
+ <button type="button" role="tab" aria-selected={moveMode === 'new'} className={'mv-segbtn' + (moveMode === 'new' ? ' mv-segbtn-on' : '')} onClick={() => setMoveMode('new')}>新建目录</button>
1210
+ </div>
1211
+ {moveMode === 'existing' ? (
1212
+ <div className="mv-field">
1213
+ <label className="mv-field-label" htmlFor="mv-batch-ws">目标工作区</label>
1214
+ <select id="mv-batch-ws" value={targetWs} onChange={(e) => setTargetWs(e.target.value)}>
1215
+ {workspaces.length === 0 && <option value="">(暂无工作区)</option>}
1216
+ {workspaces.map((w) => (
1217
+ <option key={w.workspaceId} value={w.workspaceId}>{w.title} · {w.path}</option>
1218
+ ))}
1219
+ </select>
1220
+ </div>
1221
+ ) : (
1222
+ <div className="mv-field">
1223
+ <label className="mv-field-label" htmlFor="mv-batch-path">新工作区目录路径</label>
1224
+ <div className="mv-browse-row">
1225
+ <input
1226
+ id="mv-batch-path"
1227
+ type="text"
1228
+ value={newPath}
1229
+ onChange={(e) => setNewPath(e.target.value)}
1230
+ placeholder="例如 /Users/you/Projects/demo 或 ~/demo"
1231
+ />
1232
+ <button
1233
+ type="button"
1234
+ className="archv-btn"
1235
+ onClick={pickDirectory}
1236
+ disabled={busy !== null || picking || !workspacesSvc}
1237
+ title={!workspacesSvc ? '当前运行环境不支持系统目录选择' : '打开系统目录选择窗口'}
1238
+ >
1239
+ {picking ? '选择中…' : '浏览…'}
1240
+ </button>
1241
+ </div>
1242
+ </div>
1243
+ )}
1244
+ <div className="mv-foot">
1245
+ <button type="button" className="archv-btn" onClick={() => setBatchMoveOpen(false)}>取消</button>
1246
+ <button type="button" className="archv-btn archv-go" disabled={busy !== null} onClick={() => doBatchMove()}>
1247
+ {busy === '__batch__' && <span className="archv-spin" aria-hidden="true" />}确认移动
1248
+ </button>
1249
+ </div>
1250
+ </div>
1251
+ )}
872
1252
  </div>
873
1253
  )}
874
1254
 
875
1255
  {showSessionList && list.length === 0 ? (
876
- <div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : '暂无可管理的会话。'}</div>
1256
+ <div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : filter === 'empty' ? '没有空白会话。新开会话还没产生内容时会归到这里,可在侧栏用「纯净视图」把它们一并折叠。' : '暂无可管理的会话。'}</div>
877
1257
  ) : showSessionList ? (
878
1258
  <div className="archv-list" role="list">
879
- {list.map((it) => {
1259
+ {topList.map((it) => {
880
1260
  const date = fmtDate(it.createdAt)
881
1261
  const expanded = openMove === it.sessionId
882
1262
  return (
@@ -897,21 +1277,27 @@ function SessionPanel({ workspacesSvc }) {
897
1277
  title={it.starred ? '取消收藏' : '收藏'}
898
1278
  onClick={(e) => { e.stopPropagation(); toggleStar(it) }}
899
1279
  >
900
- <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M12 2.5l2.9 5.9 6.6.9-4.8 4.6 1.2 6.5-5.9-3.1-5.9 3.1 1.2-6.5L2.5 9.3l6.6-.9z" /></svg>
1280
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path d="M12 2.5l2.9 5.9 6.6.9-4.8 4.6 1.2 6.5-5.9-3.1-5.9 3.1 1.2-6.5L2.5 9.3l6.6-.9z" /></svg>
901
1281
  </button>
902
1282
  <div className="archv-body">
903
1283
  <div className="archv-main">
904
- <div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
1284
+ <div className="archv-titlerow">
1285
+ <span className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</span>
1286
+ {branchBadge(it.sessionId)}
1287
+ {emptyBadge(it.sessionId)}
1288
+ {kidsBadge(it.sessionId)}
1289
+ <span className="archv-id" title={it.sessionId}>{shortId(it.sessionId)}</span>
1290
+ </div>
905
1291
  <div className="archv-meta">
906
1292
  {it.archived ? <span className="archv-wtag archv-wgone">已归档</span> : <span className="archv-wtag archv-active">活动</span>}
907
1293
  {workspaceTag(it)}
908
1294
  {date && <><span className="archv-dot">·</span><span className="archv-date">{date}</span></>}
909
- <span className="archv-id">{it.sessionId}</span>
910
1295
  </div>
911
1296
  </div>
912
1297
  {rowMenu(it)}
913
1298
  </div>
914
1299
  </div>
1300
+ {groupByLineage && renderKids(it.sessionId, 0)}
915
1301
  {expanded && (
916
1302
  <div className="mv-sheet" role="region" aria-label="移动到工作区">
917
1303
  <div className="mv-sheet-head">
@@ -1036,7 +1422,7 @@ function SessionPanel({ workspacesSvc }) {
1036
1422
  ) : null}
1037
1423
  </>
1038
1424
  )}
1039
- {toast && <div className="archv-status" role="status">{toast}</div>}
1425
+ {toast && <div className={'archv-status' + (toast.kind === 'err' ? ' archv-status-err' : '') + (String(toast.msg).length > 28 ? ' archv-status-long' : '')} role="status" title="点击关闭" onClick={() => setToast(null)}>{toast.msg}</div>}
1040
1426
  {delTarget && (
1041
1427
  <div className="dlg-backdrop" onClick={() => setDelTarget(null)}>
1042
1428
  <div ref={dialogRef} className="dlg" role="alertdialog" aria-modal="true" aria-label="删除会话" onClick={(e) => e.stopPropagation()}>
@@ -1133,7 +1519,9 @@ const SIDEBAR_AUG_CSS = `
1133
1519
  .dsm-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1134
1520
  .dsm-del{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent)}
1135
1521
  .dsm-del:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
1136
- .dsm-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:2147483601;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgb(0 0 0/.18);max-width:min(90vw,420px)}
1522
+ .dsm-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:2147483601;background:Canvas;color:CanvasText;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgb(0 0 0/.25);max-width:min(92vw,460px);cursor:pointer}
1523
+ .dsm-toast-long{border-radius:14px;text-align:left;line-height:18px}
1524
+ .dsm-toast-err{background:#4A1D1D;color:#FFD9D9;border:1px solid var(--dsw-alias-state-error-primary)}
1137
1525
  .dsm-sub{position:fixed;z-index:1100;box-sizing:border-box;min-width:190px;max-width:320px;padding:4px;display:flex;flex-direction:column;gap:0;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;box-shadow:var(--dsw-shadow-lv3)}
1138
1526
  .dsm-sub-loading,.dsm-sub-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:6px 10px}
1139
1527
  .dsm-sub-err{font-size:12px;color:var(--dsw-alias-state-error-primary);padding:6px 10px}
@@ -1169,11 +1557,54 @@ const SIDEBAR_AUG_CSS = `
1169
1557
  .dsm-storage-size{font-size:12px;color:var(--dsw-alias-label-secondary);flex:none;white-space:nowrap}
1170
1558
  .dsm-storage-count{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:none;white-space:nowrap;max-width:32%;overflow:hidden;text-overflow:ellipsis}
1171
1559
  .maint-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 8px}
1172
- .maint-bar-right{margin-left:auto}
1173
1560
  .maint-note{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1174
1561
  .mv-sheet-actions{display:flex;align-items:center;gap:6px}
1175
1562
  .aa-check{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}
1176
1563
  .aa-check input{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);cursor:pointer;flex:none}
1564
+ /* ---- 血缘分层披露(issue #6):行内徽标 ---- */
1565
+ .dsm-lineage-pick{display:flex;flex-direction:column;gap:6px;margin:8px 0}
1566
+ .dsm-lineage-pick-btn{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));border-radius:8px;background:transparent;color:inherit;cursor:pointer;font-size:12px;text-align:left}
1567
+ .dsm-lineage-pick-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.08))}
1568
+ .dsm-lineage-sum{font-size:12px;opacity:.75;margin:6px 0;display:flex;align-items:center;gap:6px;overflow:hidden}
1569
+ .dsm-lineage-tree{display:flex;flex-direction:column;gap:2px;margin:8px 0;max-height:340px;overflow:auto}
1570
+ .dsm-lineage-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:8px;font-size:12px;min-height:28px}
1571
+ .dsm-lineage-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.08))}
1572
+ .dsm-lineage-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1573
+ .dsm-lineage-kids,.dsm-lineage-size{font-size:11px;opacity:.65;white-space:nowrap}
1574
+ .dsm-lineage-livechip{font-size:10px;padding:1px 6px;border-radius:999px;background:rgba(234,179,8,.18);color:#A16207;white-space:nowrap}
1575
+ .dsm-lineage-acts{display:flex;gap:4px}
1576
+ .dsm-kids-badge{appearance:none;display:inline-flex;align-items:center;gap:3px;margin-left:auto;padding:1px 7px;border:none;border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary);font-size:10px;font-weight:600;line-height:15px;cursor:pointer;flex:none;pointer-events:auto}
1577
+ .dsm-subs{display:flex;flex-direction:column;gap:2px;padding:2px 0 6px 26px;pointer-events:auto}
1578
+ .dsm-sub-row{display:flex;align-items:center;gap:8px;min-height:24px;padding-right:6px;border-radius:7px;font-size:11.5px;color:var(--dsw-alias-label-secondary);position:relative}
1579
+ .dsm-sub-row::before{content:'';position:absolute;left:0;top:0;bottom:0;width:1px;background:var(--dsw-alias-border-l2)}
1580
+ .dsm-sub-row:hover{background:var(--dsw-alias-interactive-bg-hover);cursor:pointer}
1581
+ .dsm-sub-row:hover .dsm-sub-name{color:var(--dsw-alias-label-primary)}
1582
+ .dsm-sub-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1583
+ .dsm-sub-meta{flex:none;font-size:10.5px;opacity:.8}
1584
+ .dsm-sub-live .dsm-sub-meta{color:var(--dsw-alias-state-warning-primary);opacity:1;font-weight:600}
1585
+ .dsm-sub-acts{display:none;align-items:center;gap:4px;flex:none}
1586
+ .dsm-sub-row:hover .dsm-sub-acts,.dsm-sub-row:focus-within .dsm-sub-acts{display:inline-flex}
1587
+ .dsm-sub-btn{appearance:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-secondary);border-radius:6px;padding:1px 6px;font:inherit;font-size:10.5px;line-height:16px;cursor:pointer}
1588
+ .dsm-sub-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
1589
+ .dsm-sub-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent)}
1590
+ .dsm-sub-note{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:2px 0}
1591
+ .dsm-kids-badge:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent)}
1592
+ .dsm-row-sub{transform:translateX(calc(10px + var(--dsm-indent,0px)));transition:transform .15s ease}
1593
+ .dsm-row-sub::before{content:'';position:absolute;left:calc(-6px - var(--dsm-indent,0px));top:0;bottom:0;width:2px;border-radius:1px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent);pointer-events:none}
1594
+ .dsm-tag{display:inline-flex;align-items:center;margin-left:auto;padding:1px 7px;border-radius:999px;background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:500;line-height:15px;flex:none;pointer-events:auto;border:none;cursor:default}
1595
+ .dsm-tag-fork{cursor:pointer;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);color:var(--dsw-alias-state-success-primary);font-weight:600}
1596
+ .dsm-tag-fork:hover{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 20%,transparent)}
1597
+ .dsm-row-flash{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px;border-radius:8px;transition:outline-color .6s ease}
1598
+ /* 徽标/标签统一显隐(对齐「子代理折叠」的节奏):默认一律隐藏,维持 DSH
1599
+ 原生行外观;行被选中(dsm-row-active,paint 按 activeId 刷新)、鼠标悬
1600
+ 停、键盘聚焦或已展开子代理列表(dsm-row-open)时才出现。display 切换
1601
+ 不占位:隐藏时标题拿全宽,悬停时才让位给徽标。 */
1602
+ [role="treeitem"]>.dsm-kids-badge,[role="treeitem"]>.dsm-tag{display:none}
1603
+ [role="treeitem"]:hover>.dsm-kids-badge,[role="treeitem"]:hover>.dsm-tag,
1604
+ [role="treeitem"].dsm-row-active>.dsm-kids-badge,[role="treeitem"].dsm-row-active>.dsm-tag,
1605
+ [role="treeitem"].dsm-row-open>.dsm-kids-badge,
1606
+ [role="treeitem"]:focus-within>.dsm-kids-badge,[role="treeitem"]:focus-within>.dsm-tag{display:inline-flex}
1607
+ @media (prefers-reduced-motion:reduce){.dsm-row-sub{transition:none}.dsm-row-flash{transition:none}}
1177
1608
  @media (max-width:640px){.dsm-storage-bar{display:none}.dsm-storage-count{max-width:40%}}
1178
1609
  `
1179
1610
 
@@ -1204,6 +1635,13 @@ let dsmServerPurgedIds = new Set()
1204
1635
  let dsmAuthoritativeTitles = new Map()
1205
1636
  let dsmTrashTick = 0
1206
1637
  let dsmCapabilities = null
1638
+ // 血缘分层披露(issue #6):服务端血缘分类 + 本地视图状态。
1639
+ // dsmLineage: id → { origin, parentSession, delegationDepth, empty }
1640
+ const dsmLineage = new Map()
1641
+ // 空白会话一律隐藏(原「纯净视图」的默认行为固化为唯一行为,开关已于
1642
+ // 0.1.3 移除:上游本就不把空白会话渲染进侧栏,开关没有可服务的场景)。
1643
+ // 只隐藏,永不删除;子代理行按 DSH 设计不进侧栏(ui-subagent README 明文
1644
+ // "omitted from the ordinary sidebar"),收纳由上游完成,插件侧不做。
1207
1645
  async function dsmLoadCapabilities() {
1208
1646
  try { dsmCapabilities = await postJSON('/archived-sessions/capabilities', {}) } catch (e) { /* unknown stays safely unavailable */ }
1209
1647
  return dsmCapabilities
@@ -1223,6 +1661,10 @@ async function dsmLoadTrashIds() {
1223
1661
  if (dsmServerPurgedIds.has(id)) dsmPendingPurged.delete(id)
1224
1662
  }
1225
1663
  dsmAuthoritativeTitles = new Map(Object.entries((r && r.titles) || {}).map(([id, title]) => [String(id), String(title)]))
1664
+ dsmLineage.clear()
1665
+ for (const [id, info] of Object.entries((r && r.lineage) || {})) {
1666
+ if (info && typeof info === 'object') dsmLineage.set(String(id), info)
1667
+ }
1226
1668
  if (dsmRepaintDots) dsmRepaintDots()
1227
1669
  } catch (e) { /* keep last known set */ }
1228
1670
  return dsmTrashIds
@@ -1253,6 +1695,26 @@ function dsmMarkPurged(ids) {
1253
1695
  const SIDEBAR_ADAPTER_VERSION = 3
1254
1696
  const SIDEBAR_ADAPTER_MAX_MISSES = 200
1255
1697
 
1698
+ // 侧栏注入的整场清退:移除我们加到 DSH 侧栏 DOM 里的所有节点(状态圆点 /
1699
+ // 子代理徽标 / 就地子列表 / fork·空白 tag),并还原被隐藏的空分组。
1700
+ // 两个调用时机:adapter 因上游结构连续未识别而停用(此刻残留的是「半残」
1701
+ // 节点,必须整体撤下而不是留在官方侧栏上错位显示),以及插件 effect 卸载。
1702
+ // 只按自己的 data-dsm-* 标记删除,绝不碰 DSH 自己的节点。
1703
+ function dsmTeardownSidebarAug() {
1704
+ if (typeof document === 'undefined') return
1705
+ const selectors = ['[data-dsm-dot]', '[data-dsm-kids]', '[data-dsm-subs]', '[data-dsm-tag]']
1706
+ for (const sel of selectors) {
1707
+ try { document.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) { /* best-effort */ }
1708
+ }
1709
+ try {
1710
+ document.querySelectorAll('[data-dsm-group-hidden]').forEach((owner) => {
1711
+ owner.style.display = owner.dataset.dsmPrevDisplay || ''
1712
+ owner.removeAttribute('data-dsm-group-hidden')
1713
+ delete owner.dataset.dsmPrevDisplay
1714
+ })
1715
+ } catch (e) { /* best-effort */ }
1716
+ }
1717
+
1256
1718
  function createSidebarAdapter() {
1257
1719
  let disabled = false
1258
1720
  let misses = 0
@@ -1275,8 +1737,12 @@ function createSidebarAdapter() {
1275
1737
  f = f.return
1276
1738
  }
1277
1739
  if (!out.node && !out.group) {
1278
- if (++misses >= SIDEBAR_ADAPTER_MAX_MISSES) {
1740
+ if (++misses >= SIDEBAR_ADAPTER_MAX_MISSES && !disabled) {
1279
1741
  disabled = true
1742
+ // 停用 = 承认对上游结构失去认知:此刻侧栏上残留的注入节点已经失去
1743
+ // 幂等维护(paint 不再跑),必须整体撤下,绝不能把半残行为留在
1744
+ // 官方 UI 上。
1745
+ dsmTeardownSidebarAug()
1280
1746
  try { console.warn('[dsh-sessions-manager] 侧栏注入 adapter v' + SIDEBAR_ADAPTER_VERSION + ' 已停用:未识别到已知的 DSH 侧栏节点结构(上游可能已改版)') } catch (e) {}
1281
1747
  }
1282
1748
  } else if (misses > 0) {
@@ -1293,8 +1759,13 @@ function createSidebarAdapter() {
1293
1759
  }
1294
1760
  const sidebarAdapter = createSidebarAdapter()
1295
1761
 
1762
+ // apply(ctx) 可能被再次调用(HMR / profile 重载):用模块级守卫保证观察器、
1763
+ // 定时器与 document 级监听器只装一份,绝不叠加(叠加会造成重复提示与后台 CPU 增长)。
1764
+ let sidebarMenuAugInstalled = false
1296
1765
  function installSidebarSessionMenuAug() {
1297
1766
  if (typeof document === 'undefined') return
1767
+ if (sidebarMenuAugInstalled) return
1768
+ sidebarMenuAugInstalled = true
1298
1769
  const AUG = 'data-dsm-aug'
1299
1770
  let styleInjected = false
1300
1771
  let activeSubClose = null
@@ -1328,10 +1799,17 @@ function installSidebarSessionMenuAug() {
1328
1799
  }
1329
1800
  const toast = (msg) => {
1330
1801
  const t = document.createElement('div')
1331
- t.className = 'dsm-toast'
1332
- t.textContent = msg
1802
+ const text = String(msg == null ? '' : msg)
1803
+ t.className = 'dsm-toast' + (text.length > 28 ? ' dsm-toast-long' : '')
1804
+ t.textContent = text
1805
+ t.title = '点击关闭'
1806
+ t.setAttribute('role', 'status')
1333
1807
  document.body.appendChild(t)
1334
- setTimeout(() => t.remove(), 2600)
1808
+ // 停留时长按字数给(固定 2.6s 读不完排队/失败类文案),并允许点击提前关闭。
1809
+ let killTimer = null
1810
+ const kill = () => { if (killTimer) clearTimeout(killTimer); t.remove() }
1811
+ killTimer = setTimeout(kill, toastDurationFor(text))
1812
+ t.addEventListener('click', kill)
1335
1813
  }
1336
1814
  const ensureStyle = () => {
1337
1815
  if (styleInjected) return
@@ -1415,7 +1893,17 @@ function installSidebarSessionMenuAug() {
1415
1893
  closeSub()
1416
1894
  closeMenu()
1417
1895
  postJSON('/archived-sessions/move', { sessionId: info.id, targetPath: w.path })
1418
- .then(() => toast('移动成功'))
1896
+ .then((r) => {
1897
+ // 会话被 DSH 打开(活跃/未关闭)时服务端只登记排队:磁盘与工作区分组都没动。
1898
+ // 这条路径原先无条件报「移动成功」——2026-09-10 用户报的「说移动成功但还在
1899
+ // 原工作区」就是这个。必须按服务端结果如实说明。
1900
+ const label = w.title || pathName(w.path) || '目标工作区'
1901
+ if (r && r.queued) {
1902
+ toast(r.notes && r.notes.length ? r.notes.join(' ') : '会话正被 DSH 打开,已排队待移动;重启 DSH 后会自动完成(请先别打开它)。')
1903
+ return
1904
+ }
1905
+ toast(r && r.already ? `已在「${label}」` : `已移到「${label}」`)
1906
+ })
1419
1907
  .catch((ee) => toast('移动失败:' + String((ee && ee.message) || ee)))
1420
1908
  })
1421
1909
  sub.appendChild(b)
@@ -1581,6 +2069,38 @@ function installSidebarStatusDots() {
1581
2069
  }
1582
2070
  const manualUnread = dsmLoadManual()
1583
2071
  const titleInitializedRows = new WeakSet()
2072
+ // 空分组兜底:DSH 会把「日志已不在原处」的会话重新归到「未分组」组里(删
2073
+ // 除后最明显)。我们把行隐藏了,但空组标题会留下来——所以组内所有行都被
2074
+ // 隐藏时,把整个分组(含它的标题行)一起隐藏;组内重新出现可见行时自动
2075
+ // 还原。只在「组内确实有行」时才判定,避免把正在加载的空组误藏。
2076
+ // 原 display 记在 owner 的 dataset 上(见下方 dsmSyncEmptyGroups)。
2077
+ function dsmSyncEmptyGroups() {
2078
+ let groups = null
2079
+ try { groups = document.querySelectorAll('[role="group"]') } catch (e) { return }
2080
+ if (!groups || !groups.length) return
2081
+ groups.forEach((g) => {
2082
+ const items = g.querySelectorAll('[role="treeitem"]')
2083
+ if (!items.length) return
2084
+ let visible = 0
2085
+ items.forEach((it) => { if (it.style.display !== 'none') visible++ })
2086
+ // ARIA tree:group 通常嵌在它的标题 treeitem 里,藏标题即藏整组。
2087
+ // 原 display 存进 dataset(而非 WeakMap):清场/还原函数无法遍历
2088
+ // WeakMap,dataset 让「谁被我们藏过、原来是什么」随时可查可还原。
2089
+ const owner = g.closest('[role="treeitem"]') || g
2090
+ if (visible === 0) {
2091
+ if (!owner.hasAttribute('data-dsm-group-hidden')) {
2092
+ owner.dataset.dsmPrevDisplay = owner.style.display || ''
2093
+ owner.setAttribute('data-dsm-group-hidden', '')
2094
+ }
2095
+ if (owner.style.display !== 'none') owner.style.display = 'none'
2096
+ } else if (owner.hasAttribute('data-dsm-group-hidden')) {
2097
+ const prev = owner.dataset.dsmPrevDisplay || ''
2098
+ if (owner.style.display !== prev) owner.style.display = prev
2099
+ owner.removeAttribute('data-dsm-group-hidden')
2100
+ delete owner.dataset.dsmPrevDisplay
2101
+ }
2102
+ })
2103
+ }
1584
2104
  const paint = () => {
1585
2105
  if (sidebarAdapter.disabled) return
1586
2106
  // Recompute the active session on every paint so the active row is never
@@ -1596,7 +2116,26 @@ function installSidebarStatusDots() {
1596
2116
  dsmSaveManual()
1597
2117
  }
1598
2118
  curActive = activeId
2119
+ // 每次 paint 只聚合一次:父会话 id → 子代理数(O(n),避免每行重算)。
2120
+ const purgedNow = dsmLoadPurged()
2121
+ const kidsByParent = (() => {
2122
+ const m = new Map()
2123
+ for (const [id, info] of dsmLineage.entries()) {
2124
+ if (info.origin !== 'subagent' || !info.parentSession) continue
2125
+ // 已进回收站 / 已彻底删除的子代理不计数:否则删掉了徽标数字还挂着。
2126
+ if ((dsmTrashIds && dsmTrashIds.has(id)) || purgedNow.has(id)) continue
2127
+ m.set(info.parentSession, (m.get(info.parentSession) || 0) + 1)
2128
+ }
2129
+ return m
2130
+ })()
1599
2131
  const rows = document.querySelectorAll('[role="treeitem"]')
2132
+ // 兜底清理:父行被 React 移除 / 复用后残留的子代理容器立刻摘掉,
2133
+ // 避免孤儿容器越堆越多。
2134
+ document.querySelectorAll('[data-dsm-subs]').forEach((box) => {
2135
+ const pid = box.getAttribute('data-dsm-subs')
2136
+ const prev = box.previousElementSibling
2137
+ if (!prev || rowId(prev) !== pid) box.remove()
2138
+ })
1600
2139
  rows.forEach((row) => {
1601
2140
  const id = rowId(row)
1602
2141
  if (!id) return
@@ -1604,13 +2143,120 @@ function installSidebarStatusDots() {
1604
2143
  // them under "未分组"). Hide the row entirely and skip its dot. So do
1605
2144
  // hard-purged sessions — they're gone for good and must never resurface
1606
2145
  // (otherwise DSH re-renders the orphan under a "未分组" group).
1607
- const purged = dsmLoadPurged()
2146
+ const purged = purgedNow
1608
2147
  if ((dsmTrashIds && dsmTrashIds.has(id)) || purged.has(id)) {
1609
2148
  if (row.style.display !== 'none') row.style.display = 'none'
1610
2149
  const d = row.querySelector('[' + DOT + ']'); if (d) d.remove()
2150
+ dsmDropSubList(row, id)
2151
+ return
2152
+ }
2153
+ // ---- 血缘分层披露(issue #6)--------------------------------------
2154
+ // 优先级:回收站/墓碑隐藏 > 纯净视图隐藏(仅空白行) > 原生显示。
2155
+ // 子代理行按 DSH 设计不进侧栏(ui-subagent README 明文);万一上游
2156
+ // 改回渲染,这里只做缩进视觉、绝不隐藏——侧栏没有展开入口,藏了
2157
+ // 就再也找不回来。
2158
+ const li = dsmLineage.get(id)
2159
+ const isSub = !!(li && li.origin === 'subagent')
2160
+ const isEmpty = !!(li && li.empty)
2161
+ // React 会复用行 DOM:每次先按当前 id 重置血缘视觉,再按需加回。
2162
+ // classList 的 add/remove 对已处于目标状态的行是无操作(不触发
2163
+ // MutationObserver),徽标节点只允许「缺失才创建」。
2164
+ row.classList.remove('dsm-row-sub')
2165
+ // 选中行标记:徽标/标签的「选中才显示」靠它驱动(CSS 侧)。
2166
+ // toggle 同值时不动 DOM 属性,不会触发 MutationObserver。
2167
+ row.classList.toggle('dsm-row-active', id === activeId)
2168
+ row.style.removeProperty('--dsm-indent')
2169
+ let hideLineage = false
2170
+ if (li && isSub) {
2171
+ row.classList.add('dsm-row-sub')
2172
+ if (getComputedStyle(row).position === 'static') row.style.position = 'relative'
2173
+ const depth = Math.max(0, (li.delegationDepth || 1) - 1)
2174
+ if (depth > 0) row.style.setProperty('--dsm-indent', String(depth * 10) + 'px')
2175
+ } else if (li && isEmpty) {
2176
+ // 空白行一律隐藏(原纯净视图行为固化,见 dsmLineage 注释)。
2177
+ hideLineage = true
2178
+ }
2179
+ if (hideLineage) {
2180
+ if (row.style.display !== 'none') row.style.display = 'none'
2181
+ const d = row.querySelector('[' + DOT + ']'); if (d) d.remove()
2182
+ dsmDropSubList(row, id)
1611
2183
  return
1612
2184
  }
1613
2185
  if (row.style.display === 'none') row.style.display = ''
2186
+ if (!isEmpty && li && li.parentSession && li.origin !== 'subagent') {
2187
+ // fork 分支行:绿「⑂ 分支」chip,点击滚动并高亮父会话行。
2188
+ if (!row.querySelector('[data-dsm-tag="fork"]')) {
2189
+ const fork = document.createElement('button')
2190
+ fork.type = 'button'
2191
+ fork.dataset.dsmTag = 'fork'
2192
+ fork.className = 'dsm-tag dsm-tag-fork'
2193
+ const parentTitle = dsmAuthoritativeTitles.get(li.parentSession)
2194
+ fork.textContent = '⑂ 分支'
2195
+ fork.title = parentTitle ? '分支于:' + parentTitle : '分支会话(点击查看来源会话)'
2196
+ fork.setAttribute('aria-label', fork.title)
2197
+ fork.addEventListener('click', (e) => {
2198
+ e.stopPropagation(); e.preventDefault()
2199
+ const rows = document.querySelectorAll('[role="treeitem"]')
2200
+ for (const other of rows) {
2201
+ if (rowId(other) === li.parentSession) {
2202
+ other.scrollIntoView({ block: 'center', behavior: 'smooth' })
2203
+ other.classList.add('dsm-row-flash')
2204
+ setTimeout(() => other.classList.remove('dsm-row-flash'), 1200)
2205
+ break
2206
+ }
2207
+ }
2208
+ })
2209
+ row.appendChild(fork)
2210
+ }
2211
+ } else {
2212
+ const staleFork = row.querySelector('[data-dsm-tag="fork"]')
2213
+ if (staleFork) staleFork.remove()
2214
+ }
2215
+ if (!isEmpty && !isSub) {
2216
+ const kids = kidsByParent.get(id)
2217
+ const existingKids = row.querySelector('[data-dsm-kids]')
2218
+ const open = dsmSubsOpen.has(id)
2219
+ // 徽标 DOM 随血缘常驻,显隐交给 CSS(选中 / 悬停 / 键盘聚焦)。已
2220
+ // 展开的父行加 dsm-row-open 让徽标常驻可见——否则切走之后就再也
2221
+ // 收不回来了。隐藏状态不占位:标题拿全宽,悬停才让位给徽标。
2222
+ row.classList.toggle('dsm-row-open', open)
2223
+ if (kids) {
2224
+ const text = (open ? '▾ ' : '▸ ') + kids + ' 子代理'
2225
+ let badge = existingKids
2226
+ if (!badge) {
2227
+ badge = document.createElement('button')
2228
+ badge.type = 'button'
2229
+ badge.dataset.dsmKids = ''
2230
+ badge.className = 'dsm-kids-badge'
2231
+ badge.addEventListener('click', (e) => {
2232
+ e.stopPropagation(); e.preventDefault()
2233
+ dsmToggleSubs(badge)
2234
+ })
2235
+ row.appendChild(badge)
2236
+ }
2237
+ // 父会话 id 存 dataset、每次 paint 刷新:React 复用行 DOM 时旧闭包
2238
+ // 里的 id 可能已过期,点击时读 dataset 才不会切错父会话。
2239
+ if (badge.dataset.dsmParentId !== id) badge.dataset.dsmParentId = id
2240
+ // 同值赋值会替换文本节点、触发 observer——只在变化时写。
2241
+ if (badge.textContent !== text) badge.textContent = text
2242
+ const label = (open ? '收起' : '展开') + '该会话的 ' + kids + ' 个子代理'
2243
+ if (badge.getAttribute('aria-label') !== label) {
2244
+ badge.setAttribute('aria-label', label)
2245
+ badge.title = label
2246
+ }
2247
+ if (badge.getAttribute('aria-expanded') !== String(open)) badge.setAttribute('aria-expanded', String(open))
2248
+ if (open) dsmSyncSubList(row, id)
2249
+ else dsmDropSubList(row, id)
2250
+ } else {
2251
+ // 没有子代理:徽标与可能残留的子代理容器一并摘掉。
2252
+ if (existingKids) existingKids.remove()
2253
+ dsmDropSubList(row, id)
2254
+ }
2255
+ } else {
2256
+ row.classList.remove('dsm-row-open')
2257
+ const staleKids = row.querySelector('[data-dsm-kids]')
2258
+ if (staleKids) staleKids.remove()
2259
+ }
1614
2260
  // DSH's cold list baseline can expose a stale header title until the
1615
2261
  // Session is opened. Paint the latest log-folded title from the host
1616
2262
  // authority without materializing the Session or changing its log.
@@ -1654,8 +2300,164 @@ function installSidebarStatusDots() {
1654
2300
  }
1655
2301
  dot.style.background = color
1656
2302
  })
2303
+ dsmSyncEmptyGroups()
1657
2304
  }
1658
2305
  dsmRepaintDots = paint
2306
+ const paintToast = (msg, kind) => {
2307
+ const t = document.createElement('div')
2308
+ t.className = 'dsm-toast' + (kind === 'err' ? ' dsm-toast-err' : '')
2309
+ t.textContent = msg
2310
+ t.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);max-width:80%;padding:8px 14px;border-radius:8px;background:#2C2C2A;color:#F1EFE8;font-size:12px;line-height:1.5;z-index:9999'
2311
+ document.body.appendChild(t)
2312
+ setTimeout(() => t.remove(), 4200)
2313
+ }
2314
+ // ---- 侧栏就地展开子代理(issue #6)------------------------------------
2315
+ // DSH 按设计不渲染子代理行(ui-subagent 明文 "omitted from the ordinary
2316
+ // sidebar"),所以插件自己把它们在父行下方补渲染出来:点击徽标就地展开,
2317
+ // 不需要跳转到任何别处。数据来自 lineage-tree(官方 header 血缘 + live
2318
+ // 标记),带短期缓存;删除后整表失效重取。
2319
+ const DSM_KEY_SUBS = 'dsm-subs-open-v1'
2320
+ const dsmSubsOpen = new Set()
2321
+ try { for (const id of JSON.parse(localStorage.getItem(DSM_KEY_SUBS) || '[]')) if (typeof id === 'string') dsmSubsOpen.add(id) } catch (e) {}
2322
+ const dsmSaveSubsOpen = () => { try { localStorage.setItem(DSM_KEY_SUBS, JSON.stringify([...dsmSubsOpen])) } catch (e) {} }
2323
+ const dsmSubTrees = new Map()
2324
+ const SUB_TTL_MS = 15000
2325
+ async function dsmFetchSubTree(pid) {
2326
+ const cached = dsmSubTrees.get(pid)
2327
+ if (cached && Date.now() - cached.ts < SUB_TTL_MS) return cached
2328
+ dsmSubTrees.set(pid, { ts: Date.now(), status: 'loading', nodes: (cached && cached.nodes) || [] })
2329
+ try {
2330
+ const r = await postJSON('/archived-sessions/lineage-tree', { sessionId: pid })
2331
+ dsmSubTrees.set(pid, { ts: Date.now(), status: 'ready', nodes: (r && r.nodes) || [] })
2332
+ } catch (e) {
2333
+ dsmSubTrees.set(pid, { ts: Date.now(), status: 'error', nodes: [], error: String((e && e.message) || e) })
2334
+ }
2335
+ if (dsmRepaintDots) dsmRepaintDots()
2336
+ return dsmSubTrees.get(pid)
2337
+ }
2338
+ async function dsmSubAction(sid, kind) {
2339
+ try {
2340
+ await postJSON('/archived-sessions/delete', { sessionId: sid })
2341
+ if (kind === 'purge') {
2342
+ await postJSON('/archived-sessions/trash/purge', { sessionId: sid })
2343
+ dsmMarkPurged([sid])
2344
+ paintToast('已彻底删除该子代理')
2345
+ } else {
2346
+ paintToast('已移入回收站')
2347
+ }
2348
+ // 乐观剔除:服务端重取是异步的(且 DSH 的内存会话列表可能还挂着刚删掉
2349
+ // 的子代理),先把这个 id 从所有已缓存的树里摘掉,立刻重绘,不等网络。
2350
+ dsmDropSubagentFromTrees(sid)
2351
+ // 后台重取:loading 期间沿用上一步的乐观结果(dsmFetchSubTree 保留旧
2352
+ // nodes),新数据到达后再刷新一次,不会闪回「加载中」。
2353
+ for (const pid of [...dsmSubTrees.keys()]) dsmFetchSubTree(pid)
2354
+ dsmLoadTrashIds()
2355
+ if (dsmRepaintDots) dsmRepaintDots()
2356
+ } catch (e) { paintToast('操作失败:' + String((e && e.message) || e)) }
2357
+ }
2358
+ // 从所有缓存的子代理树里递归摘掉一个 id(含它的整棵子树)。
2359
+ function dsmDropSubagentFromTrees(sid) {
2360
+ const target = String(sid)
2361
+ const prune = (nodes) => (nodes || []).filter((n) => String(n.sessionId) !== target).map((n) => ({ ...n, children: prune(n.children) }))
2362
+ for (const [pid, rec] of dsmSubTrees) {
2363
+ if (!rec || !Array.isArray(rec.nodes)) continue
2364
+ dsmSubTrees.set(pid, { ...rec, nodes: prune(rec.nodes) })
2365
+ }
2366
+ }
2367
+ function dsmToggleSubs(badge) {
2368
+ const pid = badge && badge.dataset ? badge.dataset.dsmParentId : null
2369
+ if (!pid) return
2370
+ if (sidebarAdapter.disabled) {
2371
+ paintToast('侧栏适配器已停用(上游结构变化),请改从 设置 → 会话管理 查看子代理')
2372
+ return
2373
+ }
2374
+ if (dsmSubsOpen.has(pid)) { dsmSubsOpen.delete(pid); dsmSubTrees.delete(pid) } else { dsmSubsOpen.add(pid) }
2375
+ dsmSaveSubsOpen()
2376
+ if (dsmRepaintDots) dsmRepaintDots()
2377
+ }
2378
+ function dsmDropSubList(row, pid) {
2379
+ const parent = row.parentNode
2380
+ if (!parent) return
2381
+ for (const el of parent.children) {
2382
+ if (el.getAttribute && el.getAttribute('data-dsm-subs') === pid) { el.remove(); break }
2383
+ }
2384
+ }
2385
+ // 把某父会话的子代理树同步到它下方。全程幂等:容器只在缺失时创建,内容
2386
+ // 只在签名变化时重写——否则自己的写入会再次触发 MutationObserver 死循环。
2387
+ function dsmSyncSubList(row, pid) {
2388
+ const parent = row.parentNode
2389
+ if (!parent) return
2390
+ const open = dsmSubsOpen.has(pid)
2391
+ let box = null
2392
+ for (const el of parent.children) {
2393
+ if (el.getAttribute && el.getAttribute('data-dsm-subs') === pid) { box = el; break }
2394
+ }
2395
+ if (!open) { if (box) box.remove(); return }
2396
+ const rec = dsmSubTrees.get(pid)
2397
+ if (!rec) { dsmFetchSubTree(pid); return }
2398
+ if (!box) {
2399
+ box = document.createElement('div')
2400
+ box.setAttribute('data-dsm-subs', pid)
2401
+ box.className = 'dsm-subs'
2402
+ parent.insertBefore(box, row.nextSibling)
2403
+ } else if (box.nextElementSibling !== row.nextSibling && box.previousElementSibling !== row) {
2404
+ // React 重排后容器可能不在原位:搬回父行下方。
2405
+ parent.insertBefore(box, row.nextSibling)
2406
+ }
2407
+ const flat = []
2408
+ const walk = (nodes, depth) => { for (const n of nodes || []) { flat.push([n, depth]); if (depth < 4) walk(n.children, depth + 1) } }
2409
+ walk(rec.nodes, 0)
2410
+ const sig = rec.status + '|' + flat.map(([n, d]) => [n.sessionId, n.title, n.live ? 1 : 0, n.sizeBytes, d].join(':')).join(';')
2411
+ if (box.dataset.dsmSig === sig) return
2412
+ box.dataset.dsmSig = sig
2413
+ box.textContent = ''
2414
+ if (rec.status === 'loading' && !flat.length) {
2415
+ const p = document.createElement('div'); p.className = 'dsm-sub-note'; p.textContent = '加载子代理…'; box.appendChild(p); return
2416
+ }
2417
+ if (rec.status === 'error') {
2418
+ const p = document.createElement('div'); p.className = 'dsm-sub-note'; p.textContent = '子代理加载失败:' + (rec.error || '未知错误'); box.appendChild(p); return
2419
+ }
2420
+ if (!flat.length) {
2421
+ const p = document.createElement('div'); p.className = 'dsm-sub-note'; p.textContent = '该会话没有子代理'; box.appendChild(p); return
2422
+ }
2423
+ const canPurgeSub = dsmActionCapability('physicalPurge', 'purge')
2424
+ for (const [n, depth] of flat) {
2425
+ const r = document.createElement('div')
2426
+ r.className = 'dsm-sub-row' + (n.live ? ' dsm-sub-live' : '')
2427
+ r.style.paddingLeft = String(14 + depth * 12) + 'px'
2428
+ // 整行可点:走官方 sessions.open / openSubagent 打开这个子代理会话。
2429
+ // 行内按钮自己 stopPropagation,不会误触发。
2430
+ r.title = '打开这个子代理会话'
2431
+ r.addEventListener('click', async () => {
2432
+ const res = await dsmOpenSessionById(n.sessionId, n.parentSession || pid)
2433
+ if (res === 'ok') return
2434
+ const msg = openSubagentToast(res, n.title, 'sidebar')
2435
+ paintToast(msg.text, msg.kind)
2436
+ })
2437
+ const name = document.createElement('span')
2438
+ name.className = 'dsm-sub-name'
2439
+ name.textContent = n.title || (String(n.sessionId).slice(0, 8) + '…')
2440
+ name.title = n.sessionId
2441
+ r.appendChild(name)
2442
+ const meta = document.createElement('span')
2443
+ meta.className = 'dsm-sub-meta'
2444
+ meta.textContent = n.live ? '运行中' : (fmtBytes(n.sizeBytes) || '已结束')
2445
+ r.appendChild(meta)
2446
+ const acts = document.createElement('span')
2447
+ acts.className = 'dsm-sub-acts'
2448
+ const del = document.createElement('button')
2449
+ del.type = 'button'
2450
+ del.className = 'dsm-sub-btn'
2451
+ del.textContent = '删除'
2452
+ del.title = '移入回收站,可在回收站恢复'
2453
+ del.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); dsmSubAction(n.sessionId, 'delete') })
2454
+ acts.appendChild(del)
2455
+ // 彻底删除是破坏性操作,只放在会话管理面板里(那里有二次确认、回收站
2456
+ // 与墓碑一整套安全网)。侧栏只提供「删除」(进回收站,可恢复)。
2457
+ r.appendChild(acts)
2458
+ box.appendChild(r)
2459
+ }
2460
+ }
1659
2461
  // MutationObserver 增量驱动为主(childList + 属性 + 文本),彻底替代高频率
1660
2462
  // 全表定时扫描;保留一个低频兜底 tick 处理无 DOM 变化的状态迁移,且后台
1661
2463
  // 标签页完全不跑。回收站/墓碑集合的刷新也挂在这个兜底上(每 8 拍 ≈ 32s)。
@@ -1678,15 +2480,26 @@ function installSidebarStatusDots() {
1678
2480
  attributeFilter: ['data-state', 'aria-selected', 'class', 'style'],
1679
2481
  characterData: true,
1680
2482
  })
1681
- setInterval(tick, FALLBACK_TICK_MS)
2483
+ const tickTimer = setInterval(tick, FALLBACK_TICK_MS)
2484
+ // 插件卸载(HMR / 停用)时整场清退:没有这一步,interval 和 observer 会
2485
+ // 跟着旧闭包活到页面关闭,注入节点也会留在侧栏上。
2486
+ return () => {
2487
+ clearInterval(tickTimer)
2488
+ obs.disconnect()
2489
+ dsmTeardownSidebarAug()
2490
+ }
1682
2491
  }
1683
2492
 
1684
2493
  // Cross-workspace drag/drop for the native DSH sidebar. DSH does not expose a
1685
2494
  // sidebar-row plugin slot, so the adapter discovers session/workspace rows from
1686
2495
  // their React fiber nodes, while the actual move remains entirely host-owned.
1687
2496
  // The existing “移动会话” menu is also the keyboard-accessible fallback.
2497
+ // 同上:拖拽增强的观察器 / interval / document 级 pointerdown|drag* 监听器只装一份。
2498
+ let sidebarDragInstalled = false
1688
2499
  function installSidebarWorkspaceDrag() {
1689
2500
  if (typeof document === 'undefined') return
2501
+ if (sidebarDragInstalled) return
2502
+ sidebarDragInstalled = true
1690
2503
  let sessions = new Map()
1691
2504
  let dragging = null
1692
2505
  let moving = false
@@ -1787,9 +2600,19 @@ function installSidebarWorkspaceDrag() {
1787
2600
  row.classList.add('dsm-drag-busy')
1788
2601
  try {
1789
2602
  const result = await postJSON('/archived-sessions/move', { sessionId: item.sessionId, targetPath: target.path })
2603
+ // 会话被 DSH 打开时服务端只登记排队:磁盘与工作区分组都没动。这条路径绝不能
2604
+ // 本地改行 + 报成功——那会让用户以为移动已完成,而侧栏刷新后该行又跳回原工作区
2605
+ //(用户 2026-09-10 报的「说移动成功但还在原工作区」就是这个)。排队时只如实说明。
2606
+ if (result && result.queued) {
2607
+ await dsmLoadTrashIds()
2608
+ toast(result.notes && result.notes.length ? result.notes.join(' ') : '会话正被 DSH 打开,已排队待移动;重启 DSH 后会自动完成(请先别打开它)。')
2609
+ return
2610
+ }
1790
2611
  sessions.set(item.sessionId, { ...item, workspacePath: result.workspacePath || target.path, workspaceTitle: result.workspaceTitle || target.title })
1791
2612
  await dsmLoadTrashIds()
1792
- toast(`已移到「${result.workspaceTitle || target.title}」`)
2613
+ toast(result && result.already
2614
+ ? `已在「${result.workspaceTitle || target.title}」`
2615
+ : `已移到「${result.workspaceTitle || target.title}」`)
1793
2616
  } catch (error) {
1794
2617
  toast('移动失败:' + String((error && error.message) || error))
1795
2618
  } finally {
@@ -1840,10 +2663,14 @@ function installSidebarWorkspaceDrag() {
1840
2663
  }
1841
2664
 
1842
2665
  export function apply(ctx) {
2666
+ dsmClientCtx = ctx
1843
2667
  dsmLoadCapabilities()
1844
2668
  installSettingsNavIcons(ctx)
1845
2669
  installSidebarSessionMenuAug()
1846
- installSidebarStatusDots()
2670
+ // 侧栏增强的卸载清退挂到 cordis effect:插件停用/HMR 时 interval、
2671
+ // MutationObserver 与全部注入 DOM 一并回收(否则泄漏到页面关闭)。
2672
+ const disposeSidebarDots = installSidebarStatusDots()
2673
+ if (disposeSidebarDots) ctx.effect(() => disposeSidebarDots)
1847
2674
  installSidebarWorkspaceDrag()
1848
2675
  const workspacesSvc = ctx.get('workspaces')
1849
2676
  ctx.slots.inject('settings.section', () =>