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.
- package/README.en.md +49 -9
- package/README.md +50 -9
- package/assets/screenshot-session-empty.png +0 -0
- package/assets/screenshot-session-multimove.png +0 -0
- package/assets/screenshot-session-settings.png +0 -0
- package/assets/screenshot-session-subagent.png +0 -0
- package/lib/client.js +890 -79
- package/lib/client.js.map +3 -3
- package/lib/index.js +779 -135
- package/lib/index.js.map +4 -4
- package/package.json +3 -2
- package/src/client/index.jsx +887 -60
- package/src/client/logic.js +79 -0
- package/src/compat/persistence.js +16 -2
- package/src/handle-era-ops.js +178 -18
- package/src/handle-era-paths.js +22 -9
- package/src/index.js +409 -15
- package/src/lineage.js +78 -0
- package/src/pending-moves.js +126 -0
- package/src/state-temp-sweep.js +42 -0
package/src/client/index.jsx
CHANGED
|
@@ -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:
|
|
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-
|
|
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:
|
|
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:
|
|
49
|
-
.archv-
|
|
50
|
-
.archv-
|
|
51
|
-
.archv-
|
|
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
|
-
.
|
|
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:
|
|
59
|
-
.archv-star:hover{background:
|
|
60
|
-
.archv-star
|
|
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:
|
|
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
|
-
|
|
82
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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),
|
|
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
|
|
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=
|
|
732
|
-
<
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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=
|
|
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
|
-
{
|
|
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="
|
|
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-
|
|
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=
|
|
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:
|
|
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
|
-
|
|
1332
|
-
t.
|
|
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
|
-
|
|
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(() =>
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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', () =>
|