dsh-sessions-manager 3.6.1 → 3.7.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 +21 -10
- package/README.md +23 -12
- package/lib/client.js +1087 -57
- package/lib/client.js.map +2 -2
- package/lib/index.js +1103 -201
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +915 -56
- package/src/client/logic.js +314 -0
- package/src/empty-scan-index.js +118 -0
- package/src/index.js +566 -127
- package/src/lineage.js +18 -14
- package/src/move-notices.js +129 -0
- package/src/saved-filters.js +208 -0
- package/src/tag-index.js +308 -0
- package/src/title-persist-index.js +13 -3
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 {
|
|
14
|
+
import { applyTagFilter, canDropOnWorkspace, dotStateFor, effectiveTitleOf, filterShapeFromSaved, filterSnapshotOf, foldBranches, foldSubagents, noticeToastPlan, openSubagentToast, pathTail, sessionForNodes, shortId, starredOf, tagDeleteConfirm, titleBackfillDecision, toastDurationFor, workspaceForNodes } from './logic.js'
|
|
15
15
|
|
|
16
16
|
export const inject = ['slots']
|
|
17
17
|
|
|
@@ -49,10 +49,14 @@ const CSS = `
|
|
|
49
49
|
.sess-fbtn-on{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
|
|
50
50
|
.sess-tools{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin:0 0 8px}
|
|
51
51
|
.sess-tools-4{grid-template-columns:repeat(auto-fit,minmax(132px,1fr))}
|
|
52
|
+
/* T4 起搜索行有 5 个字段(搜索/工作区/标签/排序/分组):收窄下限保证 800px 内一行排满。 */
|
|
53
|
+
.sess-tools-5{grid-template-columns:repeat(auto-fit,minmax(124px,1fr))}
|
|
52
54
|
.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
55
|
.dsm-kid .dsm-kids{margin-left:8px;margin-top:6px}
|
|
54
56
|
.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
57
|
.dsm-kids-toggle:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent)}
|
|
58
|
+
.dsm-src-head{display:flex;align-items:center;gap:8px;padding:5px 9px;border:1px dashed var(--dsw-alias-border-l3);border-radius:var(--dsm-radius-ctl);font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
59
|
+
.dsm-src-head .dsm-src-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
56
60
|
.dsm-kid{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
57
61
|
.dsm-kid-row{display:flex;align-items:center;gap:8px;min-width:0}
|
|
58
62
|
.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}
|
|
@@ -62,7 +66,9 @@ const CSS = `
|
|
|
62
66
|
.sess-field{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
63
67
|
.sess-field label{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary)}
|
|
64
68
|
.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}
|
|
65
|
-
.sess-results{display:flex;
|
|
69
|
+
.sess-results{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0 0 4px}
|
|
70
|
+
/* T4 后计数行右侧挂了「保存筛选」控件组,正文独占左侧可收缩。 */
|
|
71
|
+
.sess-results-main{flex:1 1 auto;min-width:0}
|
|
66
72
|
.archv button:focus-visible,.archv input:focus-visible,.archv select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
|
|
67
73
|
.sess-batch{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 2px 4px;margin-bottom:4px}
|
|
68
74
|
/* issue #7:勾选后批量操作栏吸顶——列表再长,操作按钮也一直在手边。 */
|
|
@@ -83,6 +89,10 @@ const CSS = `
|
|
|
83
89
|
.archv-date{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;flex:none}
|
|
84
90
|
.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
91
|
.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}
|
|
92
|
+
/* 3.7.0 T4 标签 chip 系:卡片标题行内、分支/空白 chip 之后。警示色系区别于
|
|
93
|
+
分支(绿)/空白(灰),名字超长省略号;+N 计数 chip 中性色。 */
|
|
94
|
+
.dsm-tagchip{display:inline-flex;align-items:center;flex:none;max-width:9em;min-height:22px;padding:0 8px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary,#EAB308) 45%,transparent);border-radius:var(--dsm-radius-tag);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#EAB308) 10%,transparent);color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:500;line-height:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
95
|
+
.dsm-tagchip-more{max-width:none;color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle)}
|
|
86
96
|
.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}
|
|
87
97
|
.archv-dot{color:var(--dsw-alias-border-l3);flex:none}
|
|
88
98
|
.archv-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);flex:none;cursor:pointer}
|
|
@@ -114,7 +124,7 @@ const CSS = `
|
|
|
114
124
|
/* 失败提示用警示色 + 加粗,并停留更久(需要用户读完去做下一步操作)。 */
|
|
115
125
|
.archv-status-err{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-weight:500}
|
|
116
126
|
@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}}
|
|
127
|
+
@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,.sess-tools-5{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}}
|
|
118
128
|
.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)}
|
|
119
129
|
.mv-sheet-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
120
130
|
.mv-sheet-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}
|
|
@@ -163,6 +173,25 @@ const CSS = `
|
|
|
163
173
|
.dlg-title{font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary);margin:0}
|
|
164
174
|
.dlg-text{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary);margin:0;word-break:break-all}
|
|
165
175
|
.dlg-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}
|
|
176
|
+
/* ---- T4 标签编辑/管理 sheet 与筛选保存控件(全部走 --dsw token) ---- */
|
|
177
|
+
.dsm-taglist{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto}
|
|
178
|
+
.dsm-tagcheck{display:flex;align-items:center;gap:8px;min-width:0;padding:5px 2px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:7px}
|
|
179
|
+
.dsm-tagcheck:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
180
|
+
.dsm-tagcheck input{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);cursor:pointer;flex:none}
|
|
181
|
+
.dsm-tagcheck-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
182
|
+
.dsm-tagrow{display:flex;align-items:center;gap:8px;min-width:0;padding:6px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
|
183
|
+
.dsm-tagrow:last-child{border-bottom:none}
|
|
184
|
+
.dsm-tag-name{flex:0 1 auto;min-width:0;font-size:12.5px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
185
|
+
.dsm-tag-input{box-sizing:border-box;flex:1 1 auto;min-width:0;min-height:28px;padding:0 8px;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-size:12px;font-family:inherit}
|
|
186
|
+
.dsm-tag-input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
|
|
187
|
+
.dsm-tag-acts{display:flex;align-items:center;gap:6px;flex:none;margin-left:auto}
|
|
188
|
+
.dsm-tag-acts .archv-btn{min-height:26px;padding:0 9px;font-size:11px}
|
|
189
|
+
.dsm-tag-acts select{appearance:none;min-height:26px;padding:0 6px;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-secondary);font-size:11px;font-family:inherit;max-width:10em}
|
|
190
|
+
.dsm-fbar{display:flex;align-items:center;gap:6px;flex:none;margin-left:auto;flex-wrap:wrap}
|
|
191
|
+
.dsm-fbar .archv-btn{min-height:24px;padding:0 8px;font-size:11px}
|
|
192
|
+
.dsm-fbar select{appearance:none;min-height:24px;padding:0 6px;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-secondary);font-size:11px;font-family:inherit;max-width:12em}
|
|
193
|
+
.dsm-fbar input{box-sizing:border-box;min-width:9em;min-height:24px;padding:0 8px;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-size:11px;font-family:inherit}
|
|
194
|
+
@media (max-width:640px){.dsm-fbar{margin-left:0;width:100%;justify-content:flex-end}.dsm-tag-acts{margin-left:auto;flex-wrap:wrap;justify-content:flex-end}}
|
|
166
195
|
`
|
|
167
196
|
|
|
168
197
|
function fmtDate(iso) {
|
|
@@ -354,11 +383,22 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
354
383
|
const [toast, setToast] = useState(null)
|
|
355
384
|
const [picking, setPicking] = useState(false)
|
|
356
385
|
const [trash, setTrash] = useState([])
|
|
386
|
+
// T2:待移动队列小节(D4 拍板随本期)——README 承诺了查看/取消,路由早就有,
|
|
387
|
+
// 缺的只是入口。刷新尽力而为:失败=没队列,绝不打扰列表主流程。
|
|
388
|
+
const [pendingQueue, setPendingQueue] = useState([])
|
|
389
|
+
const cancelQueuedMove = async (sid) => {
|
|
390
|
+
try {
|
|
391
|
+
await postJSON('/archived-sessions/pending-moves/cancel', { sessionIds: [sid] })
|
|
392
|
+
showToast('已取消排队,会话留在原工作区', 'ok')
|
|
393
|
+
setPendingQueue((q) => q.filter((x) => String(x.sessionId) !== String(sid)))
|
|
394
|
+
} catch (e) { showToast('取消失败:' + String((e && e.message) || e), 'err') }
|
|
395
|
+
}
|
|
357
396
|
const [trashBusy, setTrashBusy] = useState(null)
|
|
358
397
|
const [trashSettings, setTrashSettings] = useState({ retentionDays: 0 })
|
|
359
398
|
const [trashCheck, setTrashCheck] = useState(null)
|
|
360
399
|
const [purgeTarget, setPurgeTarget] = useState(null)
|
|
361
400
|
const [details, setDetails] = useState({})
|
|
401
|
+
const detailsAt = useRef({})
|
|
362
402
|
const [openDetails, setOpenDetails] = useState(null)
|
|
363
403
|
const [detailsLoading, setDetailsLoading] = useState(null)
|
|
364
404
|
const [mdBusy, setMdBusy] = useState(null)
|
|
@@ -388,8 +428,341 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
388
428
|
// sidebar-state 的官方 header 血缘字段(parentSession / origin),零解码。
|
|
389
429
|
const [lineage, setLineage] = useState({})
|
|
390
430
|
const [openKids, setOpenKids] = useState({})
|
|
431
|
+
// 3.7.0 分支聚拢(D2/D4):组展开态持久化(照侧栏 dsm-subs-open-v1 模式,数组键)。
|
|
432
|
+
// 面板子代理折叠现状不持久化——不动它,避免两种语言互相牵连。
|
|
433
|
+
const [openBranches, setOpenBranches] = useState(() => {
|
|
434
|
+
try {
|
|
435
|
+
const v = JSON.parse(localStorage.getItem('dsm-branch-open-v1') || '[]')
|
|
436
|
+
const o = {}
|
|
437
|
+
for (const k of Array.isArray(v) ? v : []) if (typeof k === 'string') o[k] = true
|
|
438
|
+
return o
|
|
439
|
+
} catch (e) { return {} }
|
|
440
|
+
})
|
|
441
|
+
const toggleBranch = (rootId) => setOpenBranches((prev) => {
|
|
442
|
+
const next = Object.assign({}, prev)
|
|
443
|
+
if (next[rootId]) delete next[rootId]
|
|
444
|
+
else next[rootId] = true
|
|
445
|
+
try { localStorage.setItem('dsm-branch-open-v1', JSON.stringify(Object.keys(next))) } catch (e) {}
|
|
446
|
+
return next
|
|
447
|
+
})
|
|
391
448
|
const [groupByLineage, setGroupByLineage] = useState(() => initialPrefs.groupByLineage !== false)
|
|
392
449
|
|
|
450
|
+
// —— T4(3.7.0)标签 + 已存筛选 ——
|
|
451
|
+
// 数据源纪律:卡片 chip、编辑 sheet 勾选、管理行用量、筛选下拉,一律以本
|
|
452
|
+
// 地 assignments 乐观态为准(tags/list 全量 + 每次 /tags/set 采纳服务端回
|
|
453
|
+
// 来的那一行);/sessions 的 item.tags 只为筛选谓词供数,与 assignments
|
|
454
|
+
// 同一次乐观写里成对更新,两者不可能漂移。tags/list 失败 = tagsReady 假:
|
|
455
|
+
// chip 全隐藏、下拉与 ⋯ 菜单「标签」禁用(title 说原因),下次 refresh 再试。
|
|
456
|
+
// 刻意不把 tagFilter 写进 localStorage 偏好:重启后若标签数据不可用,一个
|
|
457
|
+
// 恢复出来的死筛选会把列表清成 0 行且无从解释——已存筛选功能才是持久组合
|
|
458
|
+
// 的正规去处。
|
|
459
|
+
const [tagDefs, setTagDefs] = useState([])
|
|
460
|
+
const [assignments, setAssignments] = useState({})
|
|
461
|
+
// 'loading'(首刷在途,中性文案)| 'ready' | 'failed'(tags/list 降级:chip
|
|
462
|
+
// 全隐藏、下拉与 ⋯「标签」禁用,title 分别说「加载中」/「加载失败」)。
|
|
463
|
+
const [tagsState, setTagsState] = useState('loading')
|
|
464
|
+
const tagsReady = tagsState === 'ready'
|
|
465
|
+
const [tagFilter, setTagFilter] = useState('')
|
|
466
|
+
const [savedFilters, setSavedFilters] = useState([])
|
|
467
|
+
const [openTags, setOpenTags] = useState(null) // 展开标签编辑 sheet 的卡片 sessionId
|
|
468
|
+
const [openTagMgr, setOpenTagMgr] = useState(false) // 标签管理 sheet
|
|
469
|
+
const [tagBusy, setTagBusy] = useState(null) // 单会话写锁(sessionId)
|
|
470
|
+
const [mgrBusy, setMgrBusy] = useState(false) // 管理 sheet 写锁(全局一把)
|
|
471
|
+
const [cardTagName, setCardTagName] = useState('') // 卡内 sheet「新建标签并贴上」输入
|
|
472
|
+
const [mgrTagName, setMgrTagName] = useState('') // 管理 sheet 顶部新建输入
|
|
473
|
+
const [renamingTagId, setRenamingTagId] = useState(null)
|
|
474
|
+
const [renameTagValue, setRenameTagValue] = useState('')
|
|
475
|
+
const [mergeTarget, setMergeTarget] = useState({}) // fromId -> toId(行内 select 值)
|
|
476
|
+
const [saveFilterOpen, setSaveFilterOpen] = useState(false)
|
|
477
|
+
const [saveFilterName, setSaveFilterName] = useState('')
|
|
478
|
+
const [appliedSavedId, setAppliedSavedId] = useState('')
|
|
479
|
+
const tagFilterRef = useRef('')
|
|
480
|
+
tagFilterRef.current = tagFilter
|
|
481
|
+
|
|
482
|
+
// 标签 id → 名字(chip 渲染与 sheet 共用;死 id 查不到名字 → 渲染层跳过,
|
|
483
|
+
// 半删标签的幽灵绝不上屏)。
|
|
484
|
+
const tagMap = useMemo(() => new Map(tagDefs.map((t) => [String(t.id), String(t.name)])), [tagDefs])
|
|
485
|
+
// 用量统计:管理行上的「N 个会话」以 assignments 为准(与会话是否在当前
|
|
486
|
+
// 列表无关——已归档/被筛掉的打标会话也要算进用量)。
|
|
487
|
+
const tagUsage = useMemo(() => {
|
|
488
|
+
const m = {}
|
|
489
|
+
for (const ids of Object.values(assignments)) for (const id of (Array.isArray(ids) ? ids : [])) m[String(id)] = (m[String(id)] || 0) + 1
|
|
490
|
+
return m
|
|
491
|
+
}, [assignments])
|
|
492
|
+
// 禁用态统一说人话:区分「还在加载」与「加载失败」两种 tagsReady=false。
|
|
493
|
+
const tagsBlockedTitle = tagsState === 'failed'
|
|
494
|
+
? '标签数据未能加载,暂时无法使用标签功能(重新打开设置面板可重试)'
|
|
495
|
+
: '标签数据加载中,稍候即可使用'
|
|
496
|
+
|
|
497
|
+
// tags/list:与 pending-moves 同一先例——尽力而为的旁路读取,失败降级空表
|
|
498
|
+
// 并置 tagsState='failed',绝不打扰列表主流程的错误条。
|
|
499
|
+
const loadTags = () => postJSON('/archived-sessions/tags/list', {})
|
|
500
|
+
.then((r) => {
|
|
501
|
+
const defs = (r && Array.isArray(r.tags)) ? r.tags : []
|
|
502
|
+
setTagDefs(defs)
|
|
503
|
+
setAssignments((r && r.assignments && typeof r.assignments === 'object' && !Array.isArray(r.assignments)) ? r.assignments : {})
|
|
504
|
+
setTagsState('ready')
|
|
505
|
+
// 死筛选自愈:标签被别的窗口删了,刷新后不能把列表永久卡在 0 行。
|
|
506
|
+
const cur = tagFilterRef.current
|
|
507
|
+
if (cur && !defs.some((t) => String(t.id) === cur)) setTagFilter('')
|
|
508
|
+
})
|
|
509
|
+
.catch(() => {
|
|
510
|
+
setTagDefs([]); setAssignments({}); setTagsState('failed')
|
|
511
|
+
if (tagFilterRef.current) setTagFilter('')
|
|
512
|
+
})
|
|
513
|
+
const loadSavedFilters = () => postJSON('/archived-sessions/filters/list', {})
|
|
514
|
+
.then((r) => setSavedFilters((r && Array.isArray(r.items)) ? r.items : []))
|
|
515
|
+
.catch(() => setSavedFilters([]))
|
|
516
|
+
|
|
517
|
+
// 全量替换某会话的标签:先双写本地(assignments + 该行 item.tags),失败
|
|
518
|
+
// 双双回滚;成功则采纳服务端响应里这一行的最终值(并发冲突以服务端为准,
|
|
519
|
+
// 其它行不动——它们可能正被别的在途乐观操作编辑,下一次 refresh 全量收敛)。
|
|
520
|
+
const setSessionTags = async (sid, nextIds) => {
|
|
521
|
+
const key = String(sid)
|
|
522
|
+
const prev = Array.isArray(assignments[key]) ? assignments[key].slice() : []
|
|
523
|
+
if (prev.length === nextIds.length && prev.every((t, i) => String(t) === String(nextIds[i]))) return
|
|
524
|
+
const writeLocal = (ids) => {
|
|
525
|
+
setAssignments((a) => {
|
|
526
|
+
const n = Object.assign({}, a)
|
|
527
|
+
if (ids.length) n[key] = ids
|
|
528
|
+
else delete n[key]
|
|
529
|
+
return n
|
|
530
|
+
})
|
|
531
|
+
setSessions((s) => s && s.map((x) => (String(x.sessionId) === key ? Object.assign({}, x, { tags: ids }) : x)))
|
|
532
|
+
}
|
|
533
|
+
writeLocal(nextIds)
|
|
534
|
+
setTagBusy(key)
|
|
535
|
+
try {
|
|
536
|
+
const r = await postJSON('/archived-sessions/tags/set', { sessionId: sid, tagIds: nextIds })
|
|
537
|
+
const row = r && r.assignments && Array.isArray(r.assignments[key]) ? r.assignments[key].map(String) : nextIds.map(String)
|
|
538
|
+
writeLocal(row)
|
|
539
|
+
} catch (e) {
|
|
540
|
+
writeLocal(prev)
|
|
541
|
+
showToast('标签设置失败:' + String((e && e.message) || e), 'err')
|
|
542
|
+
} finally {
|
|
543
|
+
setTagBusy(null)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 勾选 toggle:从当前本地行推导全量替换列表(>10 个时服务端 409,回滚 +
|
|
548
|
+
// 报错——v1 不在前端复刻上限校验,规则只有服务端一份事实源)。
|
|
549
|
+
const toggleTagFor = (sid, tagId) => {
|
|
550
|
+
if (tagBusy !== null) return
|
|
551
|
+
const key = String(sid)
|
|
552
|
+
const cur = Array.isArray(assignments[key]) ? assignments[key] : []
|
|
553
|
+
const has = cur.some((t) => String(t) === String(tagId))
|
|
554
|
+
const next = has ? cur.filter((t) => String(t) !== String(tagId)) : cur.concat(String(tagId))
|
|
555
|
+
setSessionTags(sid, next)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 卡内「新建标签并贴上」:create 成功即刻并入 tagDefs(chip 无需等整表刷新),
|
|
559
|
+
// 再走同一 setSessionTags 乐观通道(写锁交给它收尾);create 失败(重名/非法/
|
|
560
|
+
// 超限)只 toast,服务端中文错误信息原样转达。
|
|
561
|
+
const createTagAndAttach = async (sid) => {
|
|
562
|
+
const name = cardTagName.trim()
|
|
563
|
+
if (!name || tagBusy !== null) return
|
|
564
|
+
setTagBusy(String(sid))
|
|
565
|
+
let fresh = null
|
|
566
|
+
try {
|
|
567
|
+
const r = await postJSON('/archived-sessions/tags/create', { name })
|
|
568
|
+
if (r && r.tag && r.tag.id != null) fresh = r.tag
|
|
569
|
+
else throw new Error('服务端未返回新标签')
|
|
570
|
+
} catch (e) {
|
|
571
|
+
showToast('新建标签失败:' + String((e && e.message) || e), 'err')
|
|
572
|
+
}
|
|
573
|
+
if (!fresh) { setTagBusy(null); return }
|
|
574
|
+
setTagDefs((d) => (d.some((t) => String(t.id) === String(fresh.id)) ? d : d.concat(fresh)))
|
|
575
|
+
const cur = Array.isArray(assignments[String(sid)]) ? assignments[String(sid)].slice() : []
|
|
576
|
+
setCardTagName('')
|
|
577
|
+
await setSessionTags(sid, cur.concat(String(fresh.id)))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// —— 标签管理 sheet 的四个写操作 ——
|
|
581
|
+
// 全部本地镜像 + toast(merge/delete 会顺带自愈筛选态:删掉正在筛的标签 →
|
|
582
|
+
// 复位「全部」;并入 → 筛选跟着换成目标标签),下一次 refresh 全量收敛。
|
|
583
|
+
|
|
584
|
+
const createTag = async () => {
|
|
585
|
+
const name = mgrTagName.trim()
|
|
586
|
+
if (!name || mgrBusy) return
|
|
587
|
+
setMgrBusy(true)
|
|
588
|
+
try {
|
|
589
|
+
const r = await postJSON('/archived-sessions/tags/create', { name })
|
|
590
|
+
if (r && r.tag && r.tag.id != null) {
|
|
591
|
+
const fresh = r.tag
|
|
592
|
+
setTagDefs((d) => (d.some((t) => String(t.id) === String(fresh.id)) ? d : d.concat(fresh)))
|
|
593
|
+
setMgrTagName('')
|
|
594
|
+
}
|
|
595
|
+
} catch (e) {
|
|
596
|
+
showToast('新建标签失败:' + String((e && e.message) || e), 'err')
|
|
597
|
+
} finally {
|
|
598
|
+
setMgrBusy(false)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const commitTagRename = async (tag) => {
|
|
603
|
+
const name = renameTagValue.trim()
|
|
604
|
+
if (!name || mgrBusy) { setRenamingTagId(null); return }
|
|
605
|
+
if (name === tag.name) { setRenamingTagId(null); return }
|
|
606
|
+
setMgrBusy(true)
|
|
607
|
+
try {
|
|
608
|
+
await postJSON('/archived-sessions/tags/rename', { id: tag.id, name })
|
|
609
|
+
setTagDefs((d) => d.map((t) => (String(t.id) === String(tag.id) ? Object.assign({}, t, { name }) : t)))
|
|
610
|
+
setRenamingTagId(null)
|
|
611
|
+
} catch (e) {
|
|
612
|
+
showToast('重命名失败:' + String((e && e.message) || e), 'err')
|
|
613
|
+
} finally {
|
|
614
|
+
setMgrBusy(false)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const deleteTag = async (tag) => {
|
|
619
|
+
if (mgrBusy) return
|
|
620
|
+
if (!window.confirm(tagDeleteConfirm(tag.name))) return
|
|
621
|
+
setMgrBusy(true)
|
|
622
|
+
try {
|
|
623
|
+
await postJSON('/archived-sessions/tags/delete', { id: tag.id })
|
|
624
|
+
const gone = String(tag.id)
|
|
625
|
+
setTagDefs((d) => d.filter((t) => String(t.id) !== gone))
|
|
626
|
+
setAssignments((a) => {
|
|
627
|
+
const n = {}
|
|
628
|
+
for (const [sid, ids] of Object.entries(a)) {
|
|
629
|
+
const kept = (Array.isArray(ids) ? ids : []).filter((id) => String(id) !== gone)
|
|
630
|
+
if (kept.length) n[sid] = kept
|
|
631
|
+
}
|
|
632
|
+
return n
|
|
633
|
+
})
|
|
634
|
+
// item.tags 同步剔除死 id(筛选谓词读它)——否则被删标签的行仍留在筛选
|
|
635
|
+
// 结果里,与 chip 消失自相矛盾。
|
|
636
|
+
setSessions((s) => s && s.map((x) => {
|
|
637
|
+
const curT = Array.isArray(x.tags) ? x.tags : []
|
|
638
|
+
if (!curT.some((id) => String(id) === gone)) return x
|
|
639
|
+
return Object.assign({}, x, { tags: curT.filter((id) => String(id) !== gone) })
|
|
640
|
+
}))
|
|
641
|
+
// 红线:删掉正在筛选的标签 → 自动复位「全部」,绝不留下一个 0 行死筛选。
|
|
642
|
+
if (tagFilterRef.current === gone) setTagFilter('')
|
|
643
|
+
setMergeTarget((m) => { const n = Object.assign({}, m); delete n[gone]; return n })
|
|
644
|
+
} catch (e) {
|
|
645
|
+
showToast('删除标签失败:' + String((e && e.message) || e), 'err')
|
|
646
|
+
} finally {
|
|
647
|
+
setMgrBusy(false)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const mergeTag = async (from) => {
|
|
652
|
+
const toId = mergeTarget[String(from.id)]
|
|
653
|
+
if (!toId || mgrBusy) return
|
|
654
|
+
setMgrBusy(true)
|
|
655
|
+
try {
|
|
656
|
+
await postJSON('/archived-sessions/tags/merge', { fromId: from.id, toId })
|
|
657
|
+
const gone = String(from.id)
|
|
658
|
+
setTagDefs((d) => d.filter((t) => String(t.id) !== gone))
|
|
659
|
+
// 本地镜像 = 宿主语义:成员行改挂目标标签并按 id 去重(同会话两标都打
|
|
660
|
+
// 时并完只剩一个),目标行不动。
|
|
661
|
+
setAssignments((a) => {
|
|
662
|
+
const n = {}
|
|
663
|
+
for (const [sid, ids] of Object.entries(a)) {
|
|
664
|
+
const kept = []
|
|
665
|
+
const seen = new Set()
|
|
666
|
+
for (const id of (Array.isArray(ids) ? ids : [])) {
|
|
667
|
+
const v = String(id) === gone ? String(toId) : String(id)
|
|
668
|
+
if (seen.has(v)) continue
|
|
669
|
+
seen.add(v)
|
|
670
|
+
kept.push(v)
|
|
671
|
+
}
|
|
672
|
+
if (kept.length) n[sid] = kept
|
|
673
|
+
}
|
|
674
|
+
return n
|
|
675
|
+
})
|
|
676
|
+
if (tagFilterRef.current === gone) setTagFilter(String(toId))
|
|
677
|
+
setMergeTarget((m) => { const n = Object.assign({}, m); delete n[gone]; return n })
|
|
678
|
+
// 与 assignments 同步镜像 item.tags(筛选谓词读它):含 fromId 的行改挂
|
|
679
|
+
// 目标标签并按 id 去重——否则「按目标标签筛选」要等整表刷新才命中。
|
|
680
|
+
setSessions((s) => s && s.map((x) => {
|
|
681
|
+
const curT = Array.isArray(x.tags) ? x.tags : []
|
|
682
|
+
if (!curT.some((id) => String(id) === gone)) return x
|
|
683
|
+
const nextT = []
|
|
684
|
+
const seenT = new Set()
|
|
685
|
+
for (const id of curT) {
|
|
686
|
+
const v = String(id) === gone ? String(toId) : String(id)
|
|
687
|
+
if (seenT.has(v)) continue
|
|
688
|
+
seenT.add(v)
|
|
689
|
+
nextT.push(v)
|
|
690
|
+
}
|
|
691
|
+
return Object.assign({}, x, { tags: nextT })
|
|
692
|
+
}))
|
|
693
|
+
showToast(`已并入「${tagMap.get(String(toId)) || '目标标签'}」`)
|
|
694
|
+
} catch (e) {
|
|
695
|
+
showToast('合并标签失败:' + String((e && e.message) || e), 'err')
|
|
696
|
+
} finally {
|
|
697
|
+
setMgrBusy(false)
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// —— 已存筛选:保存 / 应用 / 删除 ——
|
|
702
|
+
// payload 形状转换全部走 logic 的纯函数对(snapshotOf 落盘、shapeFromSaved
|
|
703
|
+
// 读回),往返等值有单测锁着;非法字段回落默认并在 toast 里说清。
|
|
704
|
+
|
|
705
|
+
const saveCurrentFilter = async () => {
|
|
706
|
+
const name = saveFilterName.trim()
|
|
707
|
+
if (!name || mgrBusy) return
|
|
708
|
+
setMgrBusy(true)
|
|
709
|
+
try {
|
|
710
|
+
const r = await postJSON('/archived-sessions/filters/save', {
|
|
711
|
+
name,
|
|
712
|
+
filters: filterSnapshotOf({ filter, workspaceFilter, sortBy, tagFilter }),
|
|
713
|
+
})
|
|
714
|
+
if (r && r.item) setSavedFilters((l) => (l.some((x) => String(x.id) === String(r.item.id)) ? l : l.concat(r.item)))
|
|
715
|
+
setSaveFilterOpen(false)
|
|
716
|
+
setSaveFilterName('')
|
|
717
|
+
showToast(`已保存筛选「${(r && r.item && r.item.name) || name}」`)
|
|
718
|
+
} catch (e) {
|
|
719
|
+
// 409 重名 / 409 上限 / 400 名字无效:服务端中文消息原样转达。
|
|
720
|
+
showToast('保存筛选失败:' + String((e && e.message) || e), 'err')
|
|
721
|
+
} finally {
|
|
722
|
+
setMgrBusy(false)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const applySavedFilter = (id) => {
|
|
727
|
+
const key = String(id)
|
|
728
|
+
setAppliedSavedId(key)
|
|
729
|
+
if (!key) return
|
|
730
|
+
const item = savedFilters.find((x) => String(x.id) === key)
|
|
731
|
+
if (!item) return
|
|
732
|
+
const shape = filterShapeFromSaved(item)
|
|
733
|
+
// 形状合法但指向已消失的标签 / 工作区:纯函数无从得知活数据,这里对账
|
|
734
|
+
// ——标签不可用(未加载或已删)就复位「全部」,避免应用出 0 行死筛选;
|
|
735
|
+
// 工作区即便被删也保留原值(列表空是诚实结果,兜底 option 说明状态)。
|
|
736
|
+
const tagUnavailable = !!shape.tag && (!tagsReady || !tagMap.has(shape.tag))
|
|
737
|
+
const degraded = shape.degraded.slice()
|
|
738
|
+
if (tagUnavailable) degraded.push('tag')
|
|
739
|
+
setFilter(shape.view)
|
|
740
|
+
clearSel()
|
|
741
|
+
setConfirmBatch(false)
|
|
742
|
+
if (shape.view === 'starred' || shape.view === 'empty' || shape.view === 'trash') setMoreOpen(true)
|
|
743
|
+
setWorkspaceFilter(shape.workspace)
|
|
744
|
+
setSortBy(shape.sort)
|
|
745
|
+
setTagFilter(tagUnavailable ? '' : shape.tag)
|
|
746
|
+
if (degraded.length) {
|
|
747
|
+
const labels = { view: '视图', workspace: '工作区', sort: '排序', tag: '标签' }
|
|
748
|
+
showToast(`已应用筛选「${item.name}」,但其中 ${[...new Set(degraded)].map((k) => labels[k] || k).join('、')} 条件已失效并回落默认`, 'err')
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const deleteSavedFilter = async (id) => {
|
|
753
|
+
if (mgrBusy) return
|
|
754
|
+
setMgrBusy(true)
|
|
755
|
+
try {
|
|
756
|
+
await postJSON('/archived-sessions/filters/delete', { ids: [id] })
|
|
757
|
+
setSavedFilters((l) => l.filter((x) => String(x.id) !== String(id)))
|
|
758
|
+
if (String(appliedSavedId) === String(id)) setAppliedSavedId('')
|
|
759
|
+
} catch (e) {
|
|
760
|
+
showToast('删除已存筛选失败:' + String((e && e.message) || e), 'err')
|
|
761
|
+
} finally {
|
|
762
|
+
setMgrBusy(false)
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
393
766
|
const loadLineage = () => postJSON('/archived-sessions/sidebar-state', {})
|
|
394
767
|
.then((r) => setLineage((r && r.lineage) || {}))
|
|
395
768
|
.catch(() => {})
|
|
@@ -427,6 +800,11 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
427
800
|
loadLineage()
|
|
428
801
|
if (!targetWs && works.items && works.items.length) setTargetWs(works.items[0].workspaceId)
|
|
429
802
|
loadTrash()
|
|
803
|
+
// T4:标签与已存筛选的旁路读取(与 pending-moves 同一先例:失败降级,
|
|
804
|
+
// 不进主错误条)。
|
|
805
|
+
loadTags()
|
|
806
|
+
loadSavedFilters()
|
|
807
|
+
postJSON('/archived-sessions/pending-moves', {}).then((q) => setPendingQueue((q && q.items) || [])).catch(() => {})
|
|
430
808
|
// 会话集合变了:面板开着就同步刷新;关着则只标脏,等展开时再刷。
|
|
431
809
|
if (storageOpen) loadStorage()
|
|
432
810
|
else storageDirty.current = true
|
|
@@ -440,6 +818,16 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
440
818
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
441
819
|
}, [])
|
|
442
820
|
|
|
821
|
+
// v3.6.2 #5:面板只在 mount 拉一次,预热窗口内打开 = 整列占位冻结到手点
|
|
822
|
+
// 「重新加载」。这里挂一个 warm-done 钩子:host 预热从「在途」翻到「完成」
|
|
823
|
+
// 的瞬间自动补一次刷新(标题/血缘/空白分类一起浮现)。空闲时该转换不发生,
|
|
824
|
+
// 不引入任何周期性刷新。
|
|
825
|
+
const refreshRef = useRef(null)
|
|
826
|
+
refreshRef.current = refresh
|
|
827
|
+
useEffect(() => dsmOnWarmDone(() => { if (refreshRef.current) refreshRef.current() }), [])
|
|
828
|
+
// T2:侧栏适配器停用(或宿主未装侧栏)时,排队移动通知走面板 toast 通道。
|
|
829
|
+
useEffect(() => dsmOnNotice((text, kind) => showToast(text, kind)), [])
|
|
830
|
+
|
|
443
831
|
useEffect(() => {
|
|
444
832
|
try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy, groupByLineage })) } catch (e) {}
|
|
445
833
|
}, [filter, workspaceFilter, sortBy, groupByLineage])
|
|
@@ -552,18 +940,23 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
552
940
|
const list = useMemo(() => {
|
|
553
941
|
if (filter === 'trash') return []
|
|
554
942
|
const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : filter === 'starred' ? starredList : filter === 'empty' ? emptyList : sessions || []
|
|
943
|
+
// T4 标签筛选(单选,v1 不追多选 AND——理由见 logic.applyTagFilter):在
|
|
944
|
+
// 搜索/工作区之前先按标签收敛;''/未就绪时谓词原样返回,行为与旧版一致。
|
|
945
|
+
const tagPassed = applyTagFilter(base, tagsReady ? tagFilter : '')
|
|
555
946
|
const needle = query.trim().toLocaleLowerCase()
|
|
556
|
-
const filtered =
|
|
947
|
+
const filtered = tagPassed.filter((item) => {
|
|
557
948
|
if (workspaceFilter !== 'all' && (item.workspacePath || '') !== workspaceFilter) return false
|
|
558
949
|
if (!needle) return true
|
|
559
|
-
|
|
950
|
+
// v3.6.2 #6:占位标题的会话按「有效标题」(列表值 → 侧栏权威回落)匹配,
|
|
951
|
+
// 预热补齐但面板未刷新时也能按关键词搜到。
|
|
952
|
+
return [effectiveTitleOf(item, dsmAuthoritativeTitles), item.sessionId, item.workspaceTitle, item.workspacePath].some((value) => String(value || '').toLocaleLowerCase().includes(needle))
|
|
560
953
|
})
|
|
561
954
|
return [...filtered].sort((a, b) => {
|
|
562
955
|
if (sortBy === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
|
|
563
|
-
if (sortBy === 'title') return
|
|
956
|
+
if (sortBy === 'title') return effectiveTitleOf(a, dsmAuthoritativeTitles).localeCompare(effectiveTitleOf(b, dsmAuthoritativeTitles), 'zh-CN')
|
|
564
957
|
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
|
565
958
|
})
|
|
566
|
-
}, [sessions, filter, query, workspaceFilter, sortBy, emptyList, starredList])
|
|
959
|
+
}, [sessions, filter, query, workspaceFilter, sortBy, emptyList, starredList, tagFilter, tagsReady])
|
|
567
960
|
const selIds = Object.keys(selected).filter((k) => selected[k])
|
|
568
961
|
// 「回收站」是独立视图,不共用会话列表。
|
|
569
962
|
const showSessionList = filter !== 'trash'
|
|
@@ -571,10 +964,27 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
571
964
|
// 血缘分组:把 origin === 'subagent' 的会话挂到父会话卡片下(可嵌套到孙
|
|
572
965
|
// 代)。父会话不在当前列表里(被筛选掉 / 已删除 / 跨组)时子会话保持顶层,
|
|
573
966
|
// 保证「搜得到就一定看得见」,不会因为折叠而消失。
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
967
|
+
// 3.7.0 分支聚拢:先子代理折叠、后分支聚拢(foldBranches 的输入契约——顺序反了
|
|
968
|
+
// 会让分支父行的子代理变孤儿弹回顶层)。链式分支拍平进同一组;缺失父出
|
|
969
|
+
// 「来源:<短ID>」合成头;「一行不丢」由纯函数守恒不变量保证。
|
|
970
|
+
const { topList, kidsOf, foldedCount, branchGroupsOf, branchFolded, branchGroupCount } = useMemo(() => {
|
|
971
|
+
if (!groupByLineage) return { topList: list, kidsOf: new Map(), foldedCount: 0, branchGroupsOf: new Map(), branchFolded: 0, branchGroupCount: 0 }
|
|
972
|
+
const kids = foldSubagents(list, lineage)
|
|
973
|
+
const br = foldBranches(kids.topList, lineage)
|
|
974
|
+
return { topList: br.topList, kidsOf: kids.kidsOf, foldedCount: kids.foldedCount, branchGroupsOf: br.branchGroupsOf, branchFolded: br.foldedCount, branchGroupCount: br.groupCount }
|
|
577
975
|
}, [list, lineage, groupByLineage])
|
|
976
|
+
// D5:query 命中折叠组内成员(或子代理)→ 渲染层强制展开——「看得见才搜得到」。
|
|
977
|
+
const matchIds = useMemo(() => {
|
|
978
|
+
const needle = query.trim().toLocaleLowerCase()
|
|
979
|
+
if (!needle) return null
|
|
980
|
+
return new Set((sessions || [])
|
|
981
|
+
.filter((item) => [effectiveTitleOf(item, dsmAuthoritativeTitles), item.sessionId, item.workspaceTitle, item.workspacePath]
|
|
982
|
+
.some((v) => String(v || '').toLocaleLowerCase().includes(needle)))
|
|
983
|
+
.map((i) => String(i.sessionId)))
|
|
984
|
+
}, [sessions, query])
|
|
985
|
+
const kidsHit = (parentId) => !!(matchIds && (kidsOf.get(String(parentId)) || []).some((k) => matchIds.has(String(k.sessionId))))
|
|
986
|
+
const branchHit = (rootId) => !!(matchIds && (branchGroupsOf.get(String(rootId)) || []).some((m) => matchIds.has(String(m.sessionId))))
|
|
987
|
+
const syntheticCount = groupByLineage ? topList.reduce((n, it) => n + (it && it.syntheticRoot ? 1 : 0), 0) : 0
|
|
578
988
|
|
|
579
989
|
// 子代理折叠分两个部件,各自贴在它该在的位置:
|
|
580
990
|
// kidsBadge —— 标题行内,紧跟会话标题,是「这个会话有 N 个子代理」的身份
|
|
@@ -584,7 +994,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
584
994
|
if (!groupByLineage) return null
|
|
585
995
|
const kids = kidsOf.get(String(sessionId)) || []
|
|
586
996
|
if (!kids.length) return null
|
|
587
|
-
const open = !!openKids[sessionId]
|
|
997
|
+
const open = !!openKids[sessionId] || kidsHit(sessionId)
|
|
588
998
|
return (
|
|
589
999
|
<button
|
|
590
1000
|
type="button"
|
|
@@ -615,9 +1025,28 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
615
1025
|
return <span className="dsm-empty-chip" title="无内容的空白会话">空白</span>
|
|
616
1026
|
}
|
|
617
1027
|
|
|
1028
|
+
// T4 卡片标签 chips:分支/空白 chip 之后,每卡最多 3 个 +「+N」计数。
|
|
1029
|
+
// 数据走本地 assignments(tags/list 失败 = 空表 = 不显示,符合降级红线);
|
|
1030
|
+
// 死 id(标签被别处删)查不到名字 → 跳过,幽灵绝不上屏。+N 的 title 列全名。
|
|
1031
|
+
const tagChips = (sessionId) => {
|
|
1032
|
+
if (!tagsReady) return null
|
|
1033
|
+
const ids = Array.isArray(assignments[String(sessionId)]) ? assignments[String(sessionId)] : []
|
|
1034
|
+
const names = []
|
|
1035
|
+
for (const id of ids) { const n = tagMap.get(String(id)); if (n) names.push(n) }
|
|
1036
|
+
if (!names.length) return null
|
|
1037
|
+
const shown = names.slice(0, 3)
|
|
1038
|
+
const rest = names.slice(3)
|
|
1039
|
+
return (
|
|
1040
|
+
<>
|
|
1041
|
+
{shown.map((n, i) => <span key={i} className="dsm-tagchip" title={'标签:' + n}>{n}</span>)}
|
|
1042
|
+
{rest.length > 0 && <span className="dsm-tagchip dsm-tagchip-more" title={'另有标签:' + rest.join('、')}>+{rest.length}</span>}
|
|
1043
|
+
</>
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
618
1047
|
const renderKids = (parentId, depth) => {
|
|
619
1048
|
const kids = kidsOf.get(String(parentId)) || []
|
|
620
|
-
if (!kids.length || !openKids[parentId]) return null
|
|
1049
|
+
if (!kids.length || !(openKids[parentId] || kidsHit(parentId))) return null
|
|
621
1050
|
return (
|
|
622
1051
|
<div className="dsm-kids">
|
|
623
1052
|
{kids.map((k) => (
|
|
@@ -643,6 +1072,58 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
643
1072
|
)
|
|
644
1073
|
}
|
|
645
1074
|
|
|
1075
|
+
// 分支组头徽标 + 组列表:完整复用子代理折叠的语言(.dsm-kids-toggle / .dsm-kids
|
|
1076
|
+
// 竖线),成员行照抄子代理子行——打开走 dsmOpenSessionById(成员各自的直接父,
|
|
1077
|
+
// 官方地址契约),归档/删除同权。成员自己的 kidsBadge/分支 chip 保留在行内。
|
|
1078
|
+
const branchGroupBadge = (sessionId) => {
|
|
1079
|
+
if (!groupByLineage) return null
|
|
1080
|
+
const members = branchGroupsOf.get(String(sessionId)) || []
|
|
1081
|
+
if (!members.length) return null
|
|
1082
|
+
const open = !!openBranches[sessionId] || branchHit(sessionId)
|
|
1083
|
+
return (
|
|
1084
|
+
<button
|
|
1085
|
+
type="button"
|
|
1086
|
+
className="dsm-kids-toggle"
|
|
1087
|
+
aria-expanded={open}
|
|
1088
|
+
title={(open ? '收起 ' : '展开 ') + members.length + ' 个分支会话'}
|
|
1089
|
+
onClick={() => toggleBranch(sessionId)}
|
|
1090
|
+
>
|
|
1091
|
+
{open ? '▾' : '▸'} {members.length} 分支
|
|
1092
|
+
</button>
|
|
1093
|
+
)
|
|
1094
|
+
}
|
|
1095
|
+
const renderBranchGroup = (rootId) => {
|
|
1096
|
+
const members = branchGroupsOf.get(String(rootId)) || []
|
|
1097
|
+
if (!members.length || !(openBranches[rootId] || branchHit(rootId))) return null
|
|
1098
|
+
return (
|
|
1099
|
+
<div className="dsm-kids" aria-label="分支会话">
|
|
1100
|
+
{members.map((k) => (
|
|
1101
|
+
<div className="dsm-kid" key={k.sessionId}>
|
|
1102
|
+
<div className="dsm-kid-row">
|
|
1103
|
+
<span className="dsm-kid-name" title={k.title || k.sessionId}>{k.title || '(无标题)'}</span>
|
|
1104
|
+
{branchBadge(k.sessionId)}
|
|
1105
|
+
{emptyBadge(k.sessionId)}
|
|
1106
|
+
{kidsBadge(k.sessionId)}
|
|
1107
|
+
<span className="dsm-kid-meta">{k.archived ? '已归档' : '活动'}{fmtDate(k.createdAt) ? ` · ${fmtDate(k.createdAt)}` : ''}</span>
|
|
1108
|
+
<span className="dsm-kid-acts">
|
|
1109
|
+
<button type="button" className="archv-btn" disabled={busy !== null} title="切换到这个分支会话(设置面板挡着会话区,关掉即可看到)" onClick={async () => {
|
|
1110
|
+
const name = k.title || (String(k.sessionId).slice(0, 8) + '…')
|
|
1111
|
+
const li = lineage[String(k.sessionId)]
|
|
1112
|
+
const res = await dsmOpenSessionById(k.sessionId, (li && li.parentSession) || rootId)
|
|
1113
|
+
const msg = openSubagentToast(res, name, 'panel')
|
|
1114
|
+
showToast(msg.text, msg.kind)
|
|
1115
|
+
}}>打开</button>
|
|
1116
|
+
<button type="button" className="archv-btn" disabled={busy !== null} onClick={() => act(k.archived ? 'restore' : 'archive', k)}>{k.archived ? '恢复' : '归档'}</button>
|
|
1117
|
+
<button type="button" className="archv-btn archv-del" disabled={busy !== null} title="移入回收站,可在回收站恢复" onClick={() => setDelTarget(k)}>删除</button>
|
|
1118
|
+
</span>
|
|
1119
|
+
</div>
|
|
1120
|
+
{renderKids(k.sessionId, 1)}
|
|
1121
|
+
</div>
|
|
1122
|
+
))}
|
|
1123
|
+
</div>
|
|
1124
|
+
)
|
|
1125
|
+
}
|
|
1126
|
+
|
|
646
1127
|
const toggle = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }))
|
|
647
1128
|
const clearSel = () => setSelected({})
|
|
648
1129
|
const selectAll = () => {
|
|
@@ -896,6 +1377,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
896
1377
|
const openMoveFor = (it) => {
|
|
897
1378
|
if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
|
|
898
1379
|
if (openMove === it.sessionId) { setOpenMove(null); return }
|
|
1380
|
+
setOpenTags(null)
|
|
899
1381
|
setTargetWs(workspaces.length ? (targetWs || workspaces[0].workspaceId) : '')
|
|
900
1382
|
setMoveMode('existing')
|
|
901
1383
|
setNewPath('')
|
|
@@ -917,10 +1399,22 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
917
1399
|
|
|
918
1400
|
const toggleDetails = (it) => {
|
|
919
1401
|
if (openDetails === it.sessionId) { setOpenDetails(null); return }
|
|
920
|
-
if (details[it.sessionId]) {
|
|
1402
|
+
if (details[it.sessionId]) {
|
|
1403
|
+
// v3.6.2 #11:详情是同生命周期内永不失效的快照,重开永远显示旧数。
|
|
1404
|
+
// 加 30s TTL:过期即后台重取(先展示旧数据不闪空,回来再替换)。
|
|
1405
|
+
setOpenDetails(it.sessionId)
|
|
1406
|
+
const at = detailsAt.current[it.sessionId] || 0
|
|
1407
|
+
if (Date.now() - at > 30000) {
|
|
1408
|
+
postJSON('/archived-sessions/details', { sessionId: it.sessionId })
|
|
1409
|
+
.then((d) => { detailsAt.current[it.sessionId] = Date.now(); setDetails((m) => ({ ...m, [it.sessionId]: d })) })
|
|
1410
|
+
.catch(() => {})
|
|
1411
|
+
}
|
|
1412
|
+
return
|
|
1413
|
+
}
|
|
921
1414
|
setDetailsLoading(it.sessionId)
|
|
922
1415
|
postJSON('/archived-sessions/details', { sessionId: it.sessionId })
|
|
923
1416
|
.then((d) => {
|
|
1417
|
+
detailsAt.current[it.sessionId] = Date.now()
|
|
924
1418
|
setDetails((m) => ({ ...m, [it.sessionId]: d }))
|
|
925
1419
|
setDetailsLoading(null)
|
|
926
1420
|
setOpenDetails(it.sessionId)
|
|
@@ -937,12 +1431,21 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
937
1431
|
return <span className="archv-wtag">未分组</span>
|
|
938
1432
|
}
|
|
939
1433
|
|
|
1434
|
+
const openTagsFor = (it) => {
|
|
1435
|
+
if (!tagsReady) { showToast(tagsState === 'failed' ? '标签数据未能加载,暂时无法编辑标签;重新打开设置面板可重试' : '标签数据还在加载中,稍候再试', 'err'); return }
|
|
1436
|
+
if (openTags === it.sessionId) { setOpenTags(null); return }
|
|
1437
|
+
setOpenMove(null)
|
|
1438
|
+
setCardTagName('')
|
|
1439
|
+
setOpenTags(it.sessionId)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
940
1442
|
const runMenu = (id, it) => {
|
|
941
1443
|
setOpenMenu(null)
|
|
942
1444
|
if (id === 'restore') act('restore', it)
|
|
943
1445
|
else if (id === 'archive') act('archive', it)
|
|
944
1446
|
else if (id === 'delete') setDelTarget(it)
|
|
945
1447
|
else if (id === 'move') openMoveFor(it)
|
|
1448
|
+
else if (id === 'tags') openTagsFor(it)
|
|
946
1449
|
else if (id === 'details') toggleDetails(it)
|
|
947
1450
|
}
|
|
948
1451
|
|
|
@@ -951,10 +1454,11 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
951
1454
|
? [
|
|
952
1455
|
['restore', '恢复'],
|
|
953
1456
|
['move', openMove === it.sessionId ? '收起移动' : '移动'],
|
|
1457
|
+
['tags', '标签'],
|
|
954
1458
|
['details', openDetails === it.sessionId ? '收起详情' : '详情'],
|
|
955
1459
|
['delete', '删除'],
|
|
956
1460
|
]
|
|
957
|
-
: [['archive', '归档'], ['move', '移动'], ['details', '详情'], ['delete', '删除']]
|
|
1461
|
+
: [['archive', '归档'], ['move', '移动'], ['tags', openTags === it.sessionId ? '收起标签' : '标签'], ['details', '详情'], ['delete', '删除']]
|
|
958
1462
|
return (
|
|
959
1463
|
<div ref={openMenu === it.sessionId ? menuRef : null} className="more-wrap">
|
|
960
1464
|
<button
|
|
@@ -968,7 +1472,8 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
968
1472
|
{openMenu === it.sessionId && (
|
|
969
1473
|
<div className="more-menu" role="menu">
|
|
970
1474
|
{items.map(([id, label]) => {
|
|
971
|
-
const blocked = id === 'move' && !canMove.available
|
|
1475
|
+
const blocked = (id === 'move' && !canMove.available) || (id === 'tags' && !tagsReady)
|
|
1476
|
+
const blockedTitle = id === 'move' ? canMove.reason : id === 'tags' ? tagsBlockedTitle : undefined
|
|
972
1477
|
return (
|
|
973
1478
|
<button
|
|
974
1479
|
key={id}
|
|
@@ -976,7 +1481,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
976
1481
|
role="menuitem"
|
|
977
1482
|
className={'more-item' + (id === 'delete' ? ' more-item-danger' : '')}
|
|
978
1483
|
disabled={blocked}
|
|
979
|
-
title={blocked ?
|
|
1484
|
+
title={blocked ? blockedTitle : undefined}
|
|
980
1485
|
onClick={() => runMenu(id, it)}
|
|
981
1486
|
>{label}</button>
|
|
982
1487
|
)
|
|
@@ -1034,9 +1539,104 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1034
1539
|
<button type="button" className="sess-farrow" aria-expanded={moreOpen} aria-label={moreOpen ? '收起更多筛选' : '展开更多筛选'} title={moreOpen ? '收起更多筛选' : '展开更多筛选(已收藏 / 空白 / 回收站)'} onClick={() => setMoreOpen(!moreOpen)}>
|
|
1035
1540
|
<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
1541
|
</button>
|
|
1542
|
+
{/* T4:标签管理入口——低频操作,做成折叠区之后的普通按钮,
|
|
1543
|
+
不新增顶层视图 tab(UI 红线)。 */}
|
|
1544
|
+
<button
|
|
1545
|
+
type="button"
|
|
1546
|
+
className="archv-btn"
|
|
1547
|
+
aria-expanded={openTagMgr}
|
|
1548
|
+
disabled={!tagsReady && !openTagMgr}
|
|
1549
|
+
title={tagsReady ? '新建 / 重命名 / 合并 / 删除标签' : tagsBlockedTitle}
|
|
1550
|
+
onClick={() => { setOpenTagMgr(!openTagMgr); setRenamingTagId(null) }}
|
|
1551
|
+
>标签管理{tagsReady && tagDefs.length ? ` (${tagDefs.length})` : ''}</button>
|
|
1037
1552
|
</div>
|
|
1038
1553
|
</div>
|
|
1039
1554
|
|
|
1555
|
+
{/* T4 标签管理 sheet:照自动归档/待移动队列的 mv-sheet 先例。
|
|
1556
|
+
每行 = 名字 / 用量 / 重命名(内联输入)/ 并入 select + 确认 / 删除。
|
|
1557
|
+
删除确认文案含「只删标签,不会删除会话」红线(logic.tagDeleteConfirm)。 */}
|
|
1558
|
+
{openTagMgr && (
|
|
1559
|
+
<div className="mv-sheet" aria-label="标签管理">
|
|
1560
|
+
<div className="mv-sheet-head">
|
|
1561
|
+
<h3 className="mv-sheet-title">标签管理 · {tagDefs.length}</h3>
|
|
1562
|
+
<button type="button" className="mv-sheet-close" aria-label="关闭" onClick={() => { setOpenTagMgr(false); setRenamingTagId(null) }}>×</button>
|
|
1563
|
+
</div>
|
|
1564
|
+
<div className="mv-field">
|
|
1565
|
+
<label className="mv-field-label" htmlFor="dsm-tag-new">新建标签</label>
|
|
1566
|
+
<div className="mv-browse-row">
|
|
1567
|
+
<input
|
|
1568
|
+
id="dsm-tag-new"
|
|
1569
|
+
type="text"
|
|
1570
|
+
value={mgrTagName}
|
|
1571
|
+
disabled={mgrBusy}
|
|
1572
|
+
maxLength={24}
|
|
1573
|
+
placeholder="标签名(不能含斜杠,最长 24 字)"
|
|
1574
|
+
onChange={(e) => setMgrTagName(e.target.value)}
|
|
1575
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); createTag() } }}
|
|
1576
|
+
/>
|
|
1577
|
+
<button type="button" className="archv-btn" disabled={mgrBusy || !mgrTagName.trim()} onClick={createTag}>新建</button>
|
|
1578
|
+
</div>
|
|
1579
|
+
</div>
|
|
1580
|
+
{tagDefs.length === 0 ? (
|
|
1581
|
+
<div className="dtl-note">还没有标签。在上方输入名字即可创建;给会话贴标签也可以在会话卡片的「⋯ → 标签」里进行。</div>
|
|
1582
|
+
) : (
|
|
1583
|
+
<div>
|
|
1584
|
+
{tagDefs.map((t) => {
|
|
1585
|
+
const others = tagDefs.filter((o) => String(o.id) !== String(t.id))
|
|
1586
|
+
return (
|
|
1587
|
+
<div key={String(t.id)} className="dsm-tagrow">
|
|
1588
|
+
{renamingTagId === String(t.id) ? (
|
|
1589
|
+
<>
|
|
1590
|
+
<input
|
|
1591
|
+
className="dsm-tag-input"
|
|
1592
|
+
type="text"
|
|
1593
|
+
value={renameTagValue}
|
|
1594
|
+
disabled={mgrBusy}
|
|
1595
|
+
maxLength={24}
|
|
1596
|
+
aria-label={'重命名标签 ' + t.name}
|
|
1597
|
+
autoFocus
|
|
1598
|
+
onChange={(e) => setRenameTagValue(e.target.value)}
|
|
1599
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitTagRename(t) } if (e.key === 'Escape') setRenamingTagId(null) }}
|
|
1600
|
+
/>
|
|
1601
|
+
<span className="dsm-tag-acts">
|
|
1602
|
+
<button type="button" className="archv-btn archv-go" disabled={mgrBusy || !renameTagValue.trim()} onClick={() => commitTagRename(t)}>保存</button>
|
|
1603
|
+
<button type="button" className="archv-btn" disabled={mgrBusy} onClick={() => setRenamingTagId(null)}>取消</button>
|
|
1604
|
+
</span>
|
|
1605
|
+
</>
|
|
1606
|
+
) : (
|
|
1607
|
+
<>
|
|
1608
|
+
<span className="dsm-tag-name" title={t.name}>{t.name}</span>
|
|
1609
|
+
<span className="maint-note">{tagUsage[String(t.id)] || 0} 个会话</span>
|
|
1610
|
+
<span className="dsm-tag-acts">
|
|
1611
|
+
<button type="button" className="archv-btn" disabled={mgrBusy || renamingTagId !== null} title="重命名这个标签" onClick={() => { setRenamingTagId(String(t.id)); setRenameTagValue(t.name) }}>重命名</button>
|
|
1612
|
+
{others.length > 0 && (
|
|
1613
|
+
<>
|
|
1614
|
+
<select
|
|
1615
|
+
aria-label={'把标签「' + t.name + '」并入'}
|
|
1616
|
+
value={mergeTarget[String(t.id)] || ''}
|
|
1617
|
+
disabled={mgrBusy}
|
|
1618
|
+
title="把这个标签的所有会话并入另一个标签(并入后本标签删除)"
|
|
1619
|
+
onChange={(e) => setMergeTarget((m) => Object.assign({}, m, { [String(t.id)]: e.target.value }))}
|
|
1620
|
+
>
|
|
1621
|
+
<option value="">并入…</option>
|
|
1622
|
+
{others.map((o) => <option key={String(o.id)} value={String(o.id)}>{o.name}</option>)}
|
|
1623
|
+
</select>
|
|
1624
|
+
<button type="button" className="archv-btn" disabled={mgrBusy || !mergeTarget[String(t.id)]} title={mergeTarget[String(t.id)] ? '确认并入所选标签' : '先在左侧选择并入的目标标签'} onClick={() => mergeTag(t)}>确认</button>
|
|
1625
|
+
</>
|
|
1626
|
+
)}
|
|
1627
|
+
<button type="button" className="archv-btn archv-del" disabled={mgrBusy} title="删除标签(只删标签,不会删除会话)" onClick={() => deleteTag(t)}>删除</button>
|
|
1628
|
+
</span>
|
|
1629
|
+
</>
|
|
1630
|
+
)}
|
|
1631
|
+
</div>
|
|
1632
|
+
)
|
|
1633
|
+
})}
|
|
1634
|
+
</div>
|
|
1635
|
+
)}
|
|
1636
|
+
<div className="dtl-note">标签是会话的自定义标记:新建、重命名、合并、删除都只改标记本身,不会删除或移动任何会话。单个会话的标签数量与标签总数都有服务端上限,超限时会提示并自动回退本地改动。</div>
|
|
1637
|
+
</div>
|
|
1638
|
+
)}
|
|
1639
|
+
|
|
1040
1640
|
{/* 维护栏是面板级工具,与当前查看哪一组会话无关,故所有视图都显示。 */}
|
|
1041
1641
|
<div className="maint-bar">
|
|
1042
1642
|
<button type="button" className="archv-btn" aria-expanded={storageOpen} onClick={() => { const next = !storageOpen; setStorageOpen(next); if (next && (!storage || storageDirty.current) && !storageBusy) loadStorage() }}>
|
|
@@ -1047,6 +1647,23 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1047
1647
|
</button>
|
|
1048
1648
|
{aa.lastRunAt ? <span className="maint-note">上次检查 {fmtDate(aa.lastRunAt)},归档 {aa.lastArchivedCount} 个</span> : <span className="maint-note">尚未检查</span>}
|
|
1049
1649
|
</div>
|
|
1650
|
+
{pendingQueue.length > 0 && (
|
|
1651
|
+
<div className="mv-sheet" aria-label="待移动队列">
|
|
1652
|
+
<div className="mv-sheet-head">
|
|
1653
|
+
<h3 className="mv-sheet-title">待移动队列 · {pendingQueue.length}</h3>
|
|
1654
|
+
</div>
|
|
1655
|
+
<div className="dtl-note">这些会话正被 DSH 打开着,官方只在进程退出时释放写权限;释放后插件会自动完成移动,也可重启 DSH 让它在启动头几秒抢先补跑。</div>
|
|
1656
|
+
{pendingQueue.map((q) => (
|
|
1657
|
+
<div key={String(q.sessionId)} className="dsm-kid-row">
|
|
1658
|
+
<span className="dsm-kid-name" title={`${q.sessionId} → ${q.targetPath || ''}`}>{shortId(q.sessionId)}… → {pathTail(q.targetPath) || '目标工作区'}</span>
|
|
1659
|
+
<span className="dsm-kid-acts">
|
|
1660
|
+
{Number.isSafeInteger(q.attempts) && q.attempts > 0 ? <span className="maint-note">已失败 {q.attempts} 次</span> : null}
|
|
1661
|
+
<button type="button" className="archv-btn" onClick={() => cancelQueuedMove(q.sessionId)}>取消</button>
|
|
1662
|
+
</span>
|
|
1663
|
+
</div>
|
|
1664
|
+
))}
|
|
1665
|
+
</div>
|
|
1666
|
+
)}
|
|
1050
1667
|
{aaOpen && (
|
|
1051
1668
|
<div className="mv-sheet" aria-label="自动归档设置">
|
|
1052
1669
|
<div className="mv-sheet-head">
|
|
@@ -1128,7 +1745,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1128
1745
|
)}
|
|
1129
1746
|
{showSessionList && (
|
|
1130
1747
|
<>
|
|
1131
|
-
<div className="sess-tools sess-tools-
|
|
1748
|
+
<div className="sess-tools sess-tools-5" aria-label="查找和整理会话">
|
|
1132
1749
|
<div className="sess-field">
|
|
1133
1750
|
<label htmlFor="dsm-search">搜索会话</label>
|
|
1134
1751
|
<input id="dsm-search" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="标题、会话 ID 或工作区" />
|
|
@@ -1138,6 +1755,25 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1138
1755
|
<select id="dsm-workspace-filter" value={workspaceFilter} onChange={(e) => setWorkspaceFilter(e.target.value)}>
|
|
1139
1756
|
<option value="all">全部工作区</option>
|
|
1140
1757
|
{workspaces.map((w) => <option key={w.workspaceId} value={w.path}>{w.title}</option>)}
|
|
1758
|
+
{/* 已存筛选可能指向已删除的工作区:补一个兜底 option,select 永不显示空白。 */}
|
|
1759
|
+
{workspaceFilter !== 'all' && !workspaces.some((w) => w.path === workspaceFilter) && (
|
|
1760
|
+
<option value={workspaceFilter}>已删工作区 · {pathName(workspaceFilter) || '?'}</option>
|
|
1761
|
+
)}
|
|
1762
|
+
</select>
|
|
1763
|
+
</div>
|
|
1764
|
+
<div className="sess-field">
|
|
1765
|
+
{/* T4 标签筛选(单选)。v1 明确不做多选 AND:见 logic.applyTagFilter 注释。 */}
|
|
1766
|
+
<label htmlFor="dsm-tag-filter">标签</label>
|
|
1767
|
+
<select
|
|
1768
|
+
id="dsm-tag-filter"
|
|
1769
|
+
value={tagFilter}
|
|
1770
|
+
disabled={!tagsReady}
|
|
1771
|
+
title={!tagsReady ? tagsBlockedTitle : tagFilter ? '当前按「' + (tagMap.get(tagFilter) || '所选标签') + '」筛选;多选组合可用「保存当前筛选」固化' : '按标签筛选(单选)'}
|
|
1772
|
+
onChange={(e) => setTagFilter(e.target.value)}
|
|
1773
|
+
>
|
|
1774
|
+
<option value="">全部标签</option>
|
|
1775
|
+
{tagDefs.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
1776
|
+
{tagFilter && !tagMap.has(tagFilter) && <option value={tagFilter}>(已删标签)</option>}
|
|
1141
1777
|
</select>
|
|
1142
1778
|
</div>
|
|
1143
1779
|
<div className="sess-field">
|
|
@@ -1148,23 +1784,73 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1148
1784
|
</div>
|
|
1149
1785
|
<div className="sess-field">
|
|
1150
1786
|
<label htmlFor="dsm-group">分组</label>
|
|
1151
|
-
<select id="dsm-group" value={groupByLineage ? 'lineage' : 'flat'} onChange={(e) => setGroupByLineage(e.target.value === 'lineage')} title="
|
|
1152
|
-
<option value="lineage"
|
|
1787
|
+
<select id="dsm-group" value={groupByLineage ? 'lineage' : 'flat'} onChange={(e) => setGroupByLineage(e.target.value === 'lineage')} title="血缘分组:子代理折叠、分支聚拢成组;平铺:与 DSH 原生一致,全部并列">
|
|
1788
|
+
<option value="lineage">血缘(折叠分组)</option>
|
|
1153
1789
|
<option value="flat">平铺(全部并列)</option>
|
|
1154
1790
|
</select>
|
|
1155
1791
|
</div>
|
|
1156
1792
|
</div>
|
|
1157
|
-
<div className="sess-results"
|
|
1793
|
+
<div className="sess-results">
|
|
1158
1794
|
{/* 一句话说清现状:有子代理折叠时,折叠数取代「共 X 个」尾巴
|
|
1159
|
-
出现在同一句里;没有折叠时维持「显示 N 个,共 M 个」。
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
{
|
|
1163
|
-
|
|
1164
|
-
:
|
|
1795
|
+
出现在同一句里;没有折叠时维持「显示 N 个,共 M 个」。
|
|
1796
|
+
role=status 移到这句正文上,右侧保存控件组不进播报区。 */}
|
|
1797
|
+
<span className="sess-results-main" role="status">
|
|
1798
|
+
显示 {topList.length - syntheticCount} 个会话
|
|
1799
|
+
{foldedCount || branchFolded
|
|
1800
|
+
? `,另有 ${[foldedCount ? `${foldedCount} 个子代理折叠在父会话下` : '', branchFolded ? `${branchFolded} 个分支聚成 ${branchGroupCount} 组` : ''].filter(Boolean).join('、')}`
|
|
1801
|
+
: query || workspaceFilter !== 'all' || tagFilter
|
|
1165
1802
|
? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : filter === 'empty' ? emptyList.length : sessions.length} 个`
|
|
1166
1803
|
: (topList.length !== list.length ? `,共 ${list.length} 个` : '')}
|
|
1167
1804
|
</span>
|
|
1805
|
+
{/* T4:计数行右侧的保存筛选控件组。保存 = 抓 {view,workspace,
|
|
1806
|
+
sort,tag} 快照(logic.filterSnapshotOf)落盘;已存下拉
|
|
1807
|
+
「选中即应用」(logic.filterShapeFromSaved 还原,非法字段
|
|
1808
|
+
回落默认并 toast 标注),行内删除作用于当前选中项。 */}
|
|
1809
|
+
<span className="dsm-fbar">
|
|
1810
|
+
{saveFilterOpen ? (
|
|
1811
|
+
<>
|
|
1812
|
+
<input
|
|
1813
|
+
type="text"
|
|
1814
|
+
value={saveFilterName}
|
|
1815
|
+
maxLength={40}
|
|
1816
|
+
placeholder="筛选名称"
|
|
1817
|
+
aria-label="为当前筛选命名"
|
|
1818
|
+
autoFocus
|
|
1819
|
+
disabled={mgrBusy}
|
|
1820
|
+
onChange={(e) => setSaveFilterName(e.target.value)}
|
|
1821
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveCurrentFilter() } if (e.key === 'Escape') { setSaveFilterOpen(false); setSaveFilterName('') } }}
|
|
1822
|
+
/>
|
|
1823
|
+
<button type="button" className="archv-btn archv-go" disabled={mgrBusy || !saveFilterName.trim()} onClick={saveCurrentFilter}>保存</button>
|
|
1824
|
+
<button type="button" className="archv-btn" disabled={mgrBusy} onClick={() => { setSaveFilterOpen(false); setSaveFilterName('') }}>取消</button>
|
|
1825
|
+
</>
|
|
1826
|
+
) : (
|
|
1827
|
+
<button
|
|
1828
|
+
type="button"
|
|
1829
|
+
className="archv-btn"
|
|
1830
|
+
title="把当前视图 / 工作区 / 标签 / 排序存为一个可复用的筛选"
|
|
1831
|
+
onClick={() => { setSaveFilterOpen(true); setSaveFilterName('') }}
|
|
1832
|
+
>保存当前筛选</button>
|
|
1833
|
+
)}
|
|
1834
|
+
<select
|
|
1835
|
+
aria-label="已存筛选"
|
|
1836
|
+
value={savedFilters.some((f) => String(f.id) === appliedSavedId) ? appliedSavedId : ''}
|
|
1837
|
+
disabled={!savedFilters.length || mgrBusy}
|
|
1838
|
+
title={!savedFilters.length ? '还没有已存筛选;点左侧「保存当前筛选」创建' : '选择一个已存筛选并立即应用;选中后右侧可删除'}
|
|
1839
|
+
onChange={(e) => applySavedFilter(e.target.value)}
|
|
1840
|
+
>
|
|
1841
|
+
<option value="">{savedFilters.length ? '已存筛选…' : '无已存筛选'}</option>
|
|
1842
|
+
{savedFilters.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
|
|
1843
|
+
</select>
|
|
1844
|
+
{appliedSavedId && (
|
|
1845
|
+
<button
|
|
1846
|
+
type="button"
|
|
1847
|
+
className="archv-btn archv-del"
|
|
1848
|
+
disabled={mgrBusy}
|
|
1849
|
+
title={'删除已存筛选「' + ((savedFilters.find((f) => String(f.id) === appliedSavedId) || {}).name || appliedSavedId) + '」(只删这条保存的筛选,不动会话)'}
|
|
1850
|
+
onClick={() => deleteSavedFilter(appliedSavedId)}
|
|
1851
|
+
>删除</button>
|
|
1852
|
+
)}
|
|
1853
|
+
</span>
|
|
1168
1854
|
</div>
|
|
1169
1855
|
</>
|
|
1170
1856
|
)}
|
|
@@ -1253,12 +1939,34 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1253
1939
|
)}
|
|
1254
1940
|
|
|
1255
1941
|
{showSessionList && list.length === 0 ? (
|
|
1256
|
-
<div className="archv-empty">{query || workspaceFilter !== 'all' ? '
|
|
1942
|
+
<div className="archv-empty">{query || workspaceFilter !== 'all' || tagFilter ? '没有匹配的会话。请调整搜索词、标签或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : filter === 'empty' ? '没有空白会话。新开会话还没产生内容时会归到这里,侧栏会自动隐藏它们。' : '暂无可管理的会话。'}</div>
|
|
1257
1943
|
) : showSessionList ? (
|
|
1258
1944
|
<div className="archv-list" role="list">
|
|
1259
1945
|
{topList.map((it) => {
|
|
1946
|
+
if (it && it.syntheticRoot) {
|
|
1947
|
+
// 缺失父的分支组头(合成行):无勾选/星标/菜单/打开,不可导航——
|
|
1948
|
+
// 它代表的来源会话不在当前列表(被筛掉/已归档/已删除),只作归属展示。
|
|
1949
|
+
const root = String(it.syntheticRoot)
|
|
1950
|
+
const members = branchGroupsOf.get(root) || []
|
|
1951
|
+
const open = !!openBranches[root] || branchHit(root)
|
|
1952
|
+
const srcTitle = dsmAuthoritativeTitles.get(root)
|
|
1953
|
+
return (
|
|
1954
|
+
<div key={it.sessionId} className="dsm-src-head" role="listitem">
|
|
1955
|
+
<button
|
|
1956
|
+
type="button"
|
|
1957
|
+
className="dsm-kids-toggle"
|
|
1958
|
+
aria-expanded={open}
|
|
1959
|
+
title={(open ? '收起 ' : '展开 ') + members.length + ' 个分支会话(来源会话不在当前列表)'}
|
|
1960
|
+
onClick={() => toggleBranch(root)}
|
|
1961
|
+
>
|
|
1962
|
+
{open ? '▾' : '▸'} {members.length} 分支
|
|
1963
|
+
</button>
|
|
1964
|
+
<span className="dsm-src-name" title={root}>{'来源:' + (srcTitle || shortId(root))}</span>
|
|
1965
|
+
</div>
|
|
1966
|
+
)
|
|
1967
|
+
}
|
|
1260
1968
|
const date = fmtDate(it.createdAt)
|
|
1261
|
-
const expanded = openMove === it.sessionId
|
|
1969
|
+
const expanded = openMove === it.sessionId || openTags === it.sessionId
|
|
1262
1970
|
return (
|
|
1263
1971
|
<div key={it.sessionId} className={'archv-card' + (expanded ? ' archv-card-exp' : '')} role="listitem">
|
|
1264
1972
|
<div className="archv-row">
|
|
@@ -1285,7 +1993,9 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1285
1993
|
<span className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</span>
|
|
1286
1994
|
{branchBadge(it.sessionId)}
|
|
1287
1995
|
{emptyBadge(it.sessionId)}
|
|
1996
|
+
{tagChips(it.sessionId)}
|
|
1288
1997
|
{kidsBadge(it.sessionId)}
|
|
1998
|
+
{branchGroupBadge(it.sessionId)}
|
|
1289
1999
|
<span className="archv-id" title={it.sessionId}>{shortId(it.sessionId)}</span>
|
|
1290
2000
|
</div>
|
|
1291
2001
|
<div className="archv-meta">
|
|
@@ -1298,6 +2008,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1298
2008
|
</div>
|
|
1299
2009
|
</div>
|
|
1300
2010
|
{groupByLineage && renderKids(it.sessionId, 0)}
|
|
2011
|
+
{groupByLineage && renderBranchGroup(it.sessionId)}
|
|
1301
2012
|
{expanded && (
|
|
1302
2013
|
<div className="mv-sheet" role="region" aria-label="移动到工作区">
|
|
1303
2014
|
<div className="mv-sheet-head">
|
|
@@ -1349,6 +2060,53 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1349
2060
|
</div>
|
|
1350
2061
|
</div>
|
|
1351
2062
|
)}
|
|
2063
|
+
{openTags === it.sessionId && (() => {
|
|
2064
|
+
// T4 卡内标签编辑 sheet(mv-sheet 同型):勾选即时全量
|
|
2065
|
+
// /tags/set,乐观 + 回滚;死 id 行不渲染。底部「新建标签
|
|
2066
|
+
// 并贴上」回车即提交。
|
|
2067
|
+
const cur = Array.isArray(assignments[String(it.sessionId)]) ? assignments[String(it.sessionId)] : []
|
|
2068
|
+
const locked = tagBusy !== null || !tagsReady
|
|
2069
|
+
return (
|
|
2070
|
+
<div className="mv-sheet" role="region" aria-label="会话标签">
|
|
2071
|
+
<div className="mv-sheet-head">
|
|
2072
|
+
<h3 className="mv-sheet-title">会话标签 · {it.title || shortId(it.sessionId)}</h3>
|
|
2073
|
+
<button type="button" className="mv-sheet-close" aria-label="关闭" onClick={() => setOpenTags(null)}>×</button>
|
|
2074
|
+
</div>
|
|
2075
|
+
{tagDefs.length === 0 ? (
|
|
2076
|
+
<div className="dtl-note">还没有标签,在下方新建第一个。</div>
|
|
2077
|
+
) : (
|
|
2078
|
+
<div className="dsm-taglist">
|
|
2079
|
+
{tagDefs.map((t) => {
|
|
2080
|
+
const checked = cur.some((id) => String(id) === String(t.id))
|
|
2081
|
+
return (
|
|
2082
|
+
<label className="dsm-tagcheck" key={String(t.id)}>
|
|
2083
|
+
<input type="checkbox" checked={checked} disabled={locked} onChange={() => toggleTagFor(it.sessionId, t.id)} />
|
|
2084
|
+
<span className="dsm-tagcheck-name" title={t.name}>{t.name}</span>
|
|
2085
|
+
</label>
|
|
2086
|
+
)
|
|
2087
|
+
})}
|
|
2088
|
+
</div>
|
|
2089
|
+
)}
|
|
2090
|
+
<div className="mv-field">
|
|
2091
|
+
<label className="mv-field-label" htmlFor={'dsm-tag-attach-' + String(it.sessionId)}>新建标签并贴上</label>
|
|
2092
|
+
<div className="mv-browse-row">
|
|
2093
|
+
<input
|
|
2094
|
+
id={'dsm-tag-attach-' + String(it.sessionId)}
|
|
2095
|
+
type="text"
|
|
2096
|
+
value={cardTagName}
|
|
2097
|
+
disabled={locked}
|
|
2098
|
+
maxLength={24}
|
|
2099
|
+
placeholder="标签名(不能含斜杠,最长 24 字)"
|
|
2100
|
+
onChange={(e) => setCardTagName(e.target.value)}
|
|
2101
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); createTagAndAttach(it.sessionId) } }}
|
|
2102
|
+
/>
|
|
2103
|
+
<button type="button" className="archv-btn" disabled={locked || !cardTagName.trim()} title={locked && !tagsReady ? '标签数据未能加载,暂时无法新建标签' : '新建这个标签并立即贴到当前会话'} onClick={() => createTagAndAttach(it.sessionId)}>贴上</button>
|
|
2104
|
+
</div>
|
|
2105
|
+
</div>
|
|
2106
|
+
<div className="dtl-note">勾选即时生效;单个会话的标签数量以服务端上限为准,超时会提示并回退。</div>
|
|
2107
|
+
</div>
|
|
2108
|
+
)
|
|
2109
|
+
})()}
|
|
1352
2110
|
{openDetails === it.sessionId && (
|
|
1353
2111
|
<div className="dtl-sheet" role="region" aria-label="会话详情">
|
|
1354
2112
|
<div className="dtl-sheet-head">
|
|
@@ -1388,7 +2146,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1388
2146
|
</div>
|
|
1389
2147
|
)}
|
|
1390
2148
|
{d.lineage && (d.lineage.parentSessionId || (d.lineage.children && d.lineage.children.length > 0) || (d.lineage.subagents && d.lineage.subagents.length > 0)) && (
|
|
1391
|
-
<div className="dtl-sec"><div className="dtl-sec-t"
|
|
2149
|
+
<div className="dtl-sec"><div className="dtl-sec-t">血缘</div>
|
|
1392
2150
|
<div className="dtl-paths">
|
|
1393
2151
|
{d.lineage.parentSessionId && <div>父会话: <code>{d.lineage.parentSessionId}</code></div>}
|
|
1394
2152
|
{d.lineage.children && d.lineage.children.length > 0 && <div>子会话 ({d.lineage.children.length}): <code>{d.lineage.children.join(', ')}</code></div>}
|
|
@@ -1638,6 +2396,43 @@ let dsmCapabilities = null
|
|
|
1638
2396
|
// 血缘分层披露(issue #6):服务端血缘分类 + 本地视图状态。
|
|
1639
2397
|
// dsmLineage: id → { origin, parentSession, delegationDepth, empty }
|
|
1640
2398
|
const dsmLineage = new Map()
|
|
2399
|
+
// v3.6.2:host 预热是否还在途(sidebar-state/sessions 的 warmPending 旗标)。
|
|
2400
|
+
// 在途 → 侧栏轮询从 32s 台阶收紧到每拍(4s),标题/血缘一补齐就浮现;
|
|
2401
|
+
// 完成瞬间(true→false)触发一次性回调(面板自动补刷新,见 dsmOnWarmDone)。
|
|
2402
|
+
// T1(3.7.0):空白精判并入同一「后台忙闲」信号——两者都落地才算完成,
|
|
2403
|
+
// 面板只补刷一次(而不是 warm/refine 各刷一遍)。
|
|
2404
|
+
let dsmWarmPending = false
|
|
2405
|
+
let dsmRefinePending = false
|
|
2406
|
+
let dsmTrashRetryTimer = null
|
|
2407
|
+
const dsmWarmDoneHooks = new Set()
|
|
2408
|
+
function dsmOnWarmDone(fn) {
|
|
2409
|
+
dsmWarmDoneHooks.add(fn)
|
|
2410
|
+
return () => dsmWarmDoneHooks.delete(fn)
|
|
2411
|
+
}
|
|
2412
|
+
// T2(3.7.0):排队移动终局通知。已见集合只为本浏览器多 tab 即时去重;服务端
|
|
2413
|
+
// 保留到 ack(pop 式会被「没弹就关页面」丢通知),ack 失败无害、7 天 TTL 兜底。
|
|
2414
|
+
const DSM_KEY_SEEN_NOTICES = 'dsm-move-notices-seen-v1'
|
|
2415
|
+
let dsmSeenNotices = null
|
|
2416
|
+
function dsmLoadSeenNotices() {
|
|
2417
|
+
if (dsmSeenNotices) return dsmSeenNotices
|
|
2418
|
+
try { dsmSeenNotices = new Set(JSON.parse(localStorage.getItem(DSM_KEY_SEEN_NOTICES) || '[]').map(String)) } catch (e) { dsmSeenNotices = new Set() }
|
|
2419
|
+
return dsmSeenNotices
|
|
2420
|
+
}
|
|
2421
|
+
function dsmSaveSeenNotices(set) {
|
|
2422
|
+
try { localStorage.setItem(DSM_KEY_SEEN_NOTICES, JSON.stringify([...set].slice(-200))) } catch (e) {}
|
|
2423
|
+
}
|
|
2424
|
+
// 通知唯一出口:侧栏通道优先(页面级可见),面板 hook 兜底,都不可用时
|
|
2425
|
+
// console.warn——宁可吵,不可无声(copy-guard 锁住 moveNotices 只能走这里)。
|
|
2426
|
+
let dsmNotifySink = null
|
|
2427
|
+
const dsmNoticeHooks = new Set()
|
|
2428
|
+
function dsmNotify(text, kind) {
|
|
2429
|
+
if (!text) return
|
|
2430
|
+
if (dsmNotifySink && !sidebarAdapter.disabled) { try { dsmNotifySink(text, kind); return } catch (e) { /* fall through */ } }
|
|
2431
|
+
let done = false
|
|
2432
|
+
try { for (const fn of dsmNoticeHooks) { fn(text, kind); done = true } } catch (e) {}
|
|
2433
|
+
if (!done) { try { console.warn('[dsh-sessions-manager] ' + text) } catch (e) {} }
|
|
2434
|
+
}
|
|
2435
|
+
function dsmOnNotice(fn) { dsmNoticeHooks.add(fn); return () => dsmNoticeHooks.delete(fn) }
|
|
1641
2436
|
// 空白会话一律隐藏(原「纯净视图」的默认行为固化为唯一行为,开关已于
|
|
1642
2437
|
// 0.1.3 移除:上游本就不把空白会话渲染进侧栏,开关没有可服务的场景)。
|
|
1643
2438
|
// 只隐藏,永不删除;子代理行按 DSH 设计不进侧栏(ui-subagent README 明文
|
|
@@ -1665,8 +2460,34 @@ async function dsmLoadTrashIds() {
|
|
|
1665
2460
|
for (const [id, info] of Object.entries((r && r.lineage) || {})) {
|
|
1666
2461
|
if (info && typeof info === 'object') dsmLineage.set(String(id), info)
|
|
1667
2462
|
}
|
|
2463
|
+
// v3.6.2:预热完成的瞬间广播一次(面板挂自动补刷新;侧栏靠节拍恢复 32s)。
|
|
2464
|
+
// T1:旧 host 无 refinePending 字段 → !!undefined===false,节拍/广播语义
|
|
2465
|
+
// 与 3.6.2 完全一致(新 client 绝不在旧 host 上疯轮询)。
|
|
2466
|
+
const wasBusy = dsmWarmPending || dsmRefinePending
|
|
2467
|
+
dsmWarmPending = !!(r && r.warmPending)
|
|
2468
|
+
dsmRefinePending = !!(r && r.refinePending)
|
|
2469
|
+
if (wasBusy && !(dsmWarmPending || dsmRefinePending)) { try { for (const fn of dsmWarmDoneHooks) fn() } catch (e) { /* hook 出错不断主流程 */ } }
|
|
2470
|
+
// T2:投递计划(最多 2 条 + 省略句)——先同步写已见再弹(同浏览器第二个 tab
|
|
2471
|
+
// 立刻看不到重复),ack 尽力清理(旧 host 无此路由 → 404 静默)。
|
|
2472
|
+
{
|
|
2473
|
+
const plan = noticeToastPlan(r && r.moveNotices, dsmLoadSeenNotices(), 2)
|
|
2474
|
+
if (plan.ackIds.length) {
|
|
2475
|
+
const seen = dsmLoadSeenNotices()
|
|
2476
|
+
for (const id of plan.ackIds) seen.add(id)
|
|
2477
|
+
dsmSaveSeenNotices(seen)
|
|
2478
|
+
dsmNotify(plan.text, plan.kind)
|
|
2479
|
+
postJSON('/archived-sessions/pending-moves/notices/ack', { ids: plan.ackIds }).catch(() => {})
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
1668
2482
|
if (dsmRepaintDots) dsmRepaintDots()
|
|
1669
|
-
} catch (e) {
|
|
2483
|
+
} catch (e) {
|
|
2484
|
+
/* keep last known set */
|
|
2485
|
+
// v3.6.2 C:失败不再等下一个 32s 台阶——2s 后快速重试一次(已有待重试则跳过)。
|
|
2486
|
+
if (dsmTrashRetryTimer == null) {
|
|
2487
|
+
dsmTrashRetryTimer = setTimeout(() => { dsmTrashRetryTimer = null; dsmLoadTrashIds() }, 2000)
|
|
2488
|
+
if (typeof dsmTrashRetryTimer.unref === 'function') dsmTrashRetryTimer.unref()
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
1670
2491
|
return dsmTrashIds
|
|
1671
2492
|
}
|
|
1672
2493
|
|
|
@@ -2068,7 +2889,10 @@ function installSidebarStatusDots() {
|
|
|
2068
2889
|
error: 'var(--dsw-alias-state-error-primary)', // 红 出错/需关注 (DSH error)
|
|
2069
2890
|
}
|
|
2070
2891
|
const manualUnread = dsmLoadManual()
|
|
2071
|
-
|
|
2892
|
+
// v3.6.2 #2:原 WeakSet「首绘一次即终身免改」升级为 WeakMap 所有权记录
|
|
2893
|
+
// row → { id, text, authoritative }。权威标题**后到**时回填仍由我们持有文本的
|
|
2894
|
+
// 行;官方(重命名等)改写文本后所有权移交,绝不覆盖 DSH Core 的实时更新。
|
|
2895
|
+
const titleBaselines = new WeakMap()
|
|
2072
2896
|
// 空分组兜底:DSH 会把「日志已不在原处」的会话重新归到「未分组」组里(删
|
|
2073
2897
|
// 除后最明显)。我们把行隐藏了,但空组标题会留下来——所以组内所有行都被
|
|
2074
2898
|
// 隐藏时,把整个分组(含它的标题行)一起隐藏;组内重新出现可见行时自动
|
|
@@ -2185,20 +3009,24 @@ function installSidebarStatusDots() {
|
|
|
2185
3009
|
if (row.style.display === 'none') row.style.display = ''
|
|
2186
3010
|
if (!isEmpty && li && li.parentSession && li.origin !== 'subagent') {
|
|
2187
3011
|
// fork 分支行:绿「⑂ 分支」chip,点击滚动并高亮父会话行。
|
|
2188
|
-
|
|
2189
|
-
|
|
3012
|
+
// v3.6.2 #3:chip 每次 paint 幂等同步(存在才不建,但 tooltip/父 id
|
|
3013
|
+
// 随最新权威数据刷新)——旧实现「创建即定格」,父标题后到时 tooltip
|
|
3014
|
+
// 永远停在降级文案;点击改读 dataset,React 复用行 DOM 也不会切错父。
|
|
3015
|
+
const parentTitle = dsmAuthoritativeTitles.get(li.parentSession)
|
|
3016
|
+
const tip = parentTitle ? '分支于:' + parentTitle : '分支会话(点击查看来源会话)'
|
|
3017
|
+
let fork = row.querySelector('[data-dsm-tag="fork"]')
|
|
3018
|
+
if (!fork) {
|
|
3019
|
+
fork = document.createElement('button')
|
|
2190
3020
|
fork.type = 'button'
|
|
2191
3021
|
fork.dataset.dsmTag = 'fork'
|
|
2192
3022
|
fork.className = 'dsm-tag dsm-tag-fork'
|
|
2193
|
-
const parentTitle = dsmAuthoritativeTitles.get(li.parentSession)
|
|
2194
3023
|
fork.textContent = '⑂ 分支'
|
|
2195
|
-
fork.title = parentTitle ? '分支于:' + parentTitle : '分支会话(点击查看来源会话)'
|
|
2196
|
-
fork.setAttribute('aria-label', fork.title)
|
|
2197
3024
|
fork.addEventListener('click', (e) => {
|
|
2198
3025
|
e.stopPropagation(); e.preventDefault()
|
|
3026
|
+
const target = e.currentTarget.dataset.dsmForkParent || ''
|
|
2199
3027
|
const rows = document.querySelectorAll('[role="treeitem"]')
|
|
2200
3028
|
for (const other of rows) {
|
|
2201
|
-
if (rowId(other) ===
|
|
3029
|
+
if (rowId(other) === target) {
|
|
2202
3030
|
other.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
2203
3031
|
other.classList.add('dsm-row-flash')
|
|
2204
3032
|
setTimeout(() => other.classList.remove('dsm-row-flash'), 1200)
|
|
@@ -2208,6 +3036,8 @@ function installSidebarStatusDots() {
|
|
|
2208
3036
|
})
|
|
2209
3037
|
row.appendChild(fork)
|
|
2210
3038
|
}
|
|
3039
|
+
if (fork.dataset.dsmForkParent !== li.parentSession) fork.dataset.dsmForkParent = String(li.parentSession)
|
|
3040
|
+
if (fork.title !== tip) { fork.title = tip; fork.setAttribute('aria-label', tip) }
|
|
2211
3041
|
} else {
|
|
2212
3042
|
const staleFork = row.querySelector('[data-dsm-tag="fork"]')
|
|
2213
3043
|
if (staleFork) staleFork.remove()
|
|
@@ -2261,19 +3091,31 @@ function installSidebarStatusDots() {
|
|
|
2261
3091
|
// Session is opened. Paint the latest log-folded title from the host
|
|
2262
3092
|
// authority without materializing the Session or changing its log.
|
|
2263
3093
|
const authoritativeTitle = dsmAuthoritativeTitles.get(id)
|
|
2264
|
-
|
|
2265
|
-
const
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
const
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
3094
|
+
{
|
|
3095
|
+
const base = titleBaselines.get(row)
|
|
3096
|
+
// 零成本快路径:已定型且权威没更新 → 不查 DOM 不比较。
|
|
3097
|
+
if (!base || base.id !== id || (authoritativeTitle && authoritativeTitle !== base.authoritative)) {
|
|
3098
|
+
const node = rowNode(row) || {}
|
|
3099
|
+
const expected = new Set([node.title, node.displayTitle, node.name].filter((value) => typeof value === 'string'))
|
|
3100
|
+
const spans = [...row.children].filter((el) => el.tagName === 'SPAN' && !el.querySelector('[data-state]') && (el.textContent || '').trim())
|
|
3101
|
+
let titleEl = null
|
|
3102
|
+
if (base && base.id === id) {
|
|
3103
|
+
// 回填路径:只认我们上次写入/记录的那段文本,绝不猜别的 span。
|
|
3104
|
+
titleEl = spans.find((el) => (el.textContent || '') === base.text) || spans.find((el) => expected.has((el.textContent || '').trim())) || null
|
|
3105
|
+
} else {
|
|
3106
|
+
titleEl = spans.find((el) => expected.has((el.textContent || '').trim())) || spans[0] || null
|
|
3107
|
+
}
|
|
3108
|
+
if (titleEl) {
|
|
3109
|
+
const d = titleBackfillDecision({
|
|
3110
|
+
rendered: titleEl.textContent || '',
|
|
3111
|
+
baseline: base && base.id === id ? { text: base.text, authoritative: base.authoritative } : null,
|
|
3112
|
+
authoritative: authoritativeTitle || '',
|
|
3113
|
+
})
|
|
3114
|
+
if (d) {
|
|
3115
|
+
if (d.changed && titleEl.textContent !== d.text) titleEl.textContent = d.text
|
|
3116
|
+
titleBaselines.set(row, { id, text: d.nextBaseline.text, authoritative: d.nextBaseline.authoritative })
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
2277
3119
|
}
|
|
2278
3120
|
}
|
|
2279
3121
|
// Read DSH's REAL session status from the row's own StateDot and recolor
|
|
@@ -2307,10 +3149,15 @@ function installSidebarStatusDots() {
|
|
|
2307
3149
|
const t = document.createElement('div')
|
|
2308
3150
|
t.className = 'dsm-toast' + (kind === 'err' ? ' dsm-toast-err' : '')
|
|
2309
3151
|
t.textContent = msg
|
|
2310
|
-
t.
|
|
3152
|
+
t.setAttribute('role', 'status')
|
|
3153
|
+
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;cursor:pointer'
|
|
3154
|
+
t.addEventListener('click', () => t.remove())
|
|
2311
3155
|
document.body.appendChild(t)
|
|
2312
|
-
|
|
3156
|
+
// T2:排队结果文案动辄几十字,固定 4200ms 读不完(3.6.0 已为面板 toast 立过
|
|
3157
|
+
// toastDurationFor,这里把侧栏通道并进来)。
|
|
3158
|
+
setTimeout(() => t.remove(), toastDurationFor(msg, kind))
|
|
2313
3159
|
}
|
|
3160
|
+
dsmNotifySink = paintToast
|
|
2314
3161
|
// ---- 侧栏就地展开子代理(issue #6)------------------------------------
|
|
2315
3162
|
// DSH 按设计不渲染子代理行(ui-subagent 明文 "omitted from the ordinary
|
|
2316
3163
|
// sidebar"),所以插件自己把它们在父行下方补渲染出来:点击徽标就地展开,
|
|
@@ -2407,7 +3254,10 @@ function installSidebarStatusDots() {
|
|
|
2407
3254
|
const flat = []
|
|
2408
3255
|
const walk = (nodes, depth) => { for (const n of nodes || []) { flat.push([n, depth]); if (depth < 4) walk(n.children, depth + 1) } }
|
|
2409
3256
|
walk(rec.nodes, 0)
|
|
2410
|
-
|
|
3257
|
+
// v3.6.2 #8:树里 title 为 null(未暖/失败)的子节点用侧栏权威标题回落
|
|
3258
|
+
// 参与签名与渲染——权威后到时签名变化,展开态就地补写,不再定格短 id。
|
|
3259
|
+
const effTitle = (n) => n.title || dsmAuthoritativeTitles.get(String(n.sessionId)) || ''
|
|
3260
|
+
const sig = rec.status + '|' + flat.map(([n, d]) => [n.sessionId, effTitle(n), n.live ? 1 : 0, n.sizeBytes, d].join(':')).join(';')
|
|
2411
3261
|
if (box.dataset.dsmSig === sig) return
|
|
2412
3262
|
box.dataset.dsmSig = sig
|
|
2413
3263
|
box.textContent = ''
|
|
@@ -2436,7 +3286,7 @@ function installSidebarStatusDots() {
|
|
|
2436
3286
|
})
|
|
2437
3287
|
const name = document.createElement('span')
|
|
2438
3288
|
name.className = 'dsm-sub-name'
|
|
2439
|
-
name.textContent = n
|
|
3289
|
+
name.textContent = effTitle(n) || (String(n.sessionId).slice(0, 8) + '…')
|
|
2440
3290
|
name.title = n.sessionId
|
|
2441
3291
|
r.appendChild(name)
|
|
2442
3292
|
const meta = document.createElement('span')
|
|
@@ -2466,11 +3316,18 @@ function installSidebarStatusDots() {
|
|
|
2466
3316
|
const FALLBACK_TICK_MS = 4000
|
|
2467
3317
|
const tick = () => {
|
|
2468
3318
|
if (typeof document !== 'undefined' && document.hidden) return
|
|
2469
|
-
|
|
3319
|
+
// v3.6.2 C:预热在途时每拍(4s)都拉 sidebar-state,补齐结果立刻浮现;
|
|
3320
|
+
// 空闲时维持低频 32s(3.4.1 移除全表轮询的性能决策不回退——这里轮询的
|
|
3321
|
+
// 是轻量 authority 路由,且仅在有活可补的窗口期加速)。T1:精判同闸。
|
|
3322
|
+
const cadence = (dsmWarmPending || dsmRefinePending) ? 1 : 8
|
|
3323
|
+
if (++dsmTrashTick % cadence === 0) dsmLoadTrashIds()
|
|
2470
3324
|
paint()
|
|
2471
3325
|
}
|
|
2472
3326
|
tick()
|
|
2473
3327
|
dsmLoadTrashIds()
|
|
3328
|
+
// v3.6.2 C:冷启动快速二拍——首拉响应可能仍被空白精判/预热排队压着,
|
|
3329
|
+
// 1.5s 后再拉一次,把「⑂ 分支」等后到信息的台阶从 32s 缩到秒级。
|
|
3330
|
+
const coldSecondBeat = setTimeout(() => dsmLoadTrashIds(), 1500)
|
|
2474
3331
|
paint()
|
|
2475
3332
|
const obs = new MutationObserver(schedulePaint)
|
|
2476
3333
|
obs.observe(document.body, {
|
|
@@ -2485,7 +3342,9 @@ function installSidebarStatusDots() {
|
|
|
2485
3342
|
// 跟着旧闭包活到页面关闭,注入节点也会留在侧栏上。
|
|
2486
3343
|
return () => {
|
|
2487
3344
|
clearInterval(tickTimer)
|
|
3345
|
+
clearTimeout(coldSecondBeat)
|
|
2488
3346
|
obs.disconnect()
|
|
3347
|
+
dsmNotifySink = null
|
|
2489
3348
|
dsmTeardownSidebarAug()
|
|
2490
3349
|
}
|
|
2491
3350
|
}
|
|
@@ -2529,7 +3388,7 @@ function installSidebarWorkspaceDrag() {
|
|
|
2529
3388
|
el.setAttribute('role', 'status')
|
|
2530
3389
|
el.textContent = message
|
|
2531
3390
|
document.body.appendChild(el)
|
|
2532
|
-
setTimeout(() => el.remove(),
|
|
3391
|
+
setTimeout(() => el.remove(), toastDurationFor(message)) // T2:并轨按字数定时
|
|
2533
3392
|
}
|
|
2534
3393
|
const clearVisuals = () => {
|
|
2535
3394
|
document.querySelectorAll('.dsm-drag-source,.dsm-drop-target').forEach((el) => el.classList.remove('dsm-drag-source', 'dsm-drop-target'))
|