dsh-sessions-manager 3.6.2 → 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.
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useEffect, useMemo, useRef, useState } from 'react'
14
- import { canDropOnWorkspace, dotStateFor, effectiveTitleOf, foldSubagents, openSubagentToast, sessionForNodes, shortId, starredOf, titleBackfillDecision, toastDurationFor, workspaceForNodes } from './logic.js'
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;flex-direction:column;gap:1px;font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0 0 4px}
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,6 +383,16 @@ 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)
@@ -389,8 +428,341 @@ function SessionPanel({ workspacesSvc }) {
389
428
  // sidebar-state 的官方 header 血缘字段(parentSession / origin),零解码。
390
429
  const [lineage, setLineage] = useState({})
391
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
+ })
392
448
  const [groupByLineage, setGroupByLineage] = useState(() => initialPrefs.groupByLineage !== false)
393
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
+
394
766
  const loadLineage = () => postJSON('/archived-sessions/sidebar-state', {})
395
767
  .then((r) => setLineage((r && r.lineage) || {}))
396
768
  .catch(() => {})
@@ -428,6 +800,11 @@ function SessionPanel({ workspacesSvc }) {
428
800
  loadLineage()
429
801
  if (!targetWs && works.items && works.items.length) setTargetWs(works.items[0].workspaceId)
430
802
  loadTrash()
803
+ // T4:标签与已存筛选的旁路读取(与 pending-moves 同一先例:失败降级,
804
+ // 不进主错误条)。
805
+ loadTags()
806
+ loadSavedFilters()
807
+ postJSON('/archived-sessions/pending-moves', {}).then((q) => setPendingQueue((q && q.items) || [])).catch(() => {})
431
808
  // 会话集合变了:面板开着就同步刷新;关着则只标脏,等展开时再刷。
432
809
  if (storageOpen) loadStorage()
433
810
  else storageDirty.current = true
@@ -448,6 +825,8 @@ function SessionPanel({ workspacesSvc }) {
448
825
  const refreshRef = useRef(null)
449
826
  refreshRef.current = refresh
450
827
  useEffect(() => dsmOnWarmDone(() => { if (refreshRef.current) refreshRef.current() }), [])
828
+ // T2:侧栏适配器停用(或宿主未装侧栏)时,排队移动通知走面板 toast 通道。
829
+ useEffect(() => dsmOnNotice((text, kind) => showToast(text, kind)), [])
451
830
 
452
831
  useEffect(() => {
453
832
  try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy, groupByLineage })) } catch (e) {}
@@ -561,8 +940,11 @@ function SessionPanel({ workspacesSvc }) {
561
940
  const list = useMemo(() => {
562
941
  if (filter === 'trash') return []
563
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 : '')
564
946
  const needle = query.trim().toLocaleLowerCase()
565
- const filtered = base.filter((item) => {
947
+ const filtered = tagPassed.filter((item) => {
566
948
  if (workspaceFilter !== 'all' && (item.workspacePath || '') !== workspaceFilter) return false
567
949
  if (!needle) return true
568
950
  // v3.6.2 #6:占位标题的会话按「有效标题」(列表值 → 侧栏权威回落)匹配,
@@ -574,7 +956,7 @@ function SessionPanel({ workspacesSvc }) {
574
956
  if (sortBy === 'title') return effectiveTitleOf(a, dsmAuthoritativeTitles).localeCompare(effectiveTitleOf(b, dsmAuthoritativeTitles), 'zh-CN')
575
957
  return Number(b.createdAt || 0) - Number(a.createdAt || 0)
576
958
  })
577
- }, [sessions, filter, query, workspaceFilter, sortBy, emptyList, starredList])
959
+ }, [sessions, filter, query, workspaceFilter, sortBy, emptyList, starredList, tagFilter, tagsReady])
578
960
  const selIds = Object.keys(selected).filter((k) => selected[k])
579
961
  // 「回收站」是独立视图,不共用会话列表。
580
962
  const showSessionList = filter !== 'trash'
@@ -582,10 +964,27 @@ function SessionPanel({ workspacesSvc }) {
582
964
  // 血缘分组:把 origin === 'subagent' 的会话挂到父会话卡片下(可嵌套到孙
583
965
  // 代)。父会话不在当前列表里(被筛选掉 / 已删除 / 跨组)时子会话保持顶层,
584
966
  // 保证「搜得到就一定看得见」,不会因为折叠而消失。
585
- const { topList, kidsOf, foldedCount } = useMemo(() => {
586
- if (!groupByLineage) return { topList: list, kidsOf: new Map(), foldedCount: 0 }
587
- return foldSubagents(list, lineage)
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 }
588
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
589
988
 
590
989
  // 子代理折叠分两个部件,各自贴在它该在的位置:
591
990
  // kidsBadge —— 标题行内,紧跟会话标题,是「这个会话有 N 个子代理」的身份
@@ -595,7 +994,7 @@ function SessionPanel({ workspacesSvc }) {
595
994
  if (!groupByLineage) return null
596
995
  const kids = kidsOf.get(String(sessionId)) || []
597
996
  if (!kids.length) return null
598
- const open = !!openKids[sessionId]
997
+ const open = !!openKids[sessionId] || kidsHit(sessionId)
599
998
  return (
600
999
  <button
601
1000
  type="button"
@@ -626,9 +1025,28 @@ function SessionPanel({ workspacesSvc }) {
626
1025
  return <span className="dsm-empty-chip" title="无内容的空白会话">空白</span>
627
1026
  }
628
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
+
629
1047
  const renderKids = (parentId, depth) => {
630
1048
  const kids = kidsOf.get(String(parentId)) || []
631
- if (!kids.length || !openKids[parentId]) return null
1049
+ if (!kids.length || !(openKids[parentId] || kidsHit(parentId))) return null
632
1050
  return (
633
1051
  <div className="dsm-kids">
634
1052
  {kids.map((k) => (
@@ -654,6 +1072,58 @@ function SessionPanel({ workspacesSvc }) {
654
1072
  )
655
1073
  }
656
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
+
657
1127
  const toggle = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }))
658
1128
  const clearSel = () => setSelected({})
659
1129
  const selectAll = () => {
@@ -907,6 +1377,7 @@ function SessionPanel({ workspacesSvc }) {
907
1377
  const openMoveFor = (it) => {
908
1378
  if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
909
1379
  if (openMove === it.sessionId) { setOpenMove(null); return }
1380
+ setOpenTags(null)
910
1381
  setTargetWs(workspaces.length ? (targetWs || workspaces[0].workspaceId) : '')
911
1382
  setMoveMode('existing')
912
1383
  setNewPath('')
@@ -960,12 +1431,21 @@ function SessionPanel({ workspacesSvc }) {
960
1431
  return <span className="archv-wtag">未分组</span>
961
1432
  }
962
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
+
963
1442
  const runMenu = (id, it) => {
964
1443
  setOpenMenu(null)
965
1444
  if (id === 'restore') act('restore', it)
966
1445
  else if (id === 'archive') act('archive', it)
967
1446
  else if (id === 'delete') setDelTarget(it)
968
1447
  else if (id === 'move') openMoveFor(it)
1448
+ else if (id === 'tags') openTagsFor(it)
969
1449
  else if (id === 'details') toggleDetails(it)
970
1450
  }
971
1451
 
@@ -974,10 +1454,11 @@ function SessionPanel({ workspacesSvc }) {
974
1454
  ? [
975
1455
  ['restore', '恢复'],
976
1456
  ['move', openMove === it.sessionId ? '收起移动' : '移动'],
1457
+ ['tags', '标签'],
977
1458
  ['details', openDetails === it.sessionId ? '收起详情' : '详情'],
978
1459
  ['delete', '删除'],
979
1460
  ]
980
- : [['archive', '归档'], ['move', '移动'], ['details', '详情'], ['delete', '删除']]
1461
+ : [['archive', '归档'], ['move', '移动'], ['tags', openTags === it.sessionId ? '收起标签' : '标签'], ['details', '详情'], ['delete', '删除']]
981
1462
  return (
982
1463
  <div ref={openMenu === it.sessionId ? menuRef : null} className="more-wrap">
983
1464
  <button
@@ -991,7 +1472,8 @@ function SessionPanel({ workspacesSvc }) {
991
1472
  {openMenu === it.sessionId && (
992
1473
  <div className="more-menu" role="menu">
993
1474
  {items.map(([id, label]) => {
994
- 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
995
1477
  return (
996
1478
  <button
997
1479
  key={id}
@@ -999,7 +1481,7 @@ function SessionPanel({ workspacesSvc }) {
999
1481
  role="menuitem"
1000
1482
  className={'more-item' + (id === 'delete' ? ' more-item-danger' : '')}
1001
1483
  disabled={blocked}
1002
- title={blocked ? canMove.reason : undefined}
1484
+ title={blocked ? blockedTitle : undefined}
1003
1485
  onClick={() => runMenu(id, it)}
1004
1486
  >{label}</button>
1005
1487
  )
@@ -1057,9 +1539,104 @@ function SessionPanel({ workspacesSvc }) {
1057
1539
  <button type="button" className="sess-farrow" aria-expanded={moreOpen} aria-label={moreOpen ? '收起更多筛选' : '展开更多筛选'} title={moreOpen ? '收起更多筛选' : '展开更多筛选(已收藏 / 空白 / 回收站)'} onClick={() => setMoreOpen(!moreOpen)}>
1058
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>
1059
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>
1060
1552
  </div>
1061
1553
  </div>
1062
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
+
1063
1640
  {/* 维护栏是面板级工具,与当前查看哪一组会话无关,故所有视图都显示。 */}
1064
1641
  <div className="maint-bar">
1065
1642
  <button type="button" className="archv-btn" aria-expanded={storageOpen} onClick={() => { const next = !storageOpen; setStorageOpen(next); if (next && (!storage || storageDirty.current) && !storageBusy) loadStorage() }}>
@@ -1070,6 +1647,23 @@ function SessionPanel({ workspacesSvc }) {
1070
1647
  </button>
1071
1648
  {aa.lastRunAt ? <span className="maint-note">上次检查 {fmtDate(aa.lastRunAt)},归档 {aa.lastArchivedCount} 个</span> : <span className="maint-note">尚未检查</span>}
1072
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
+ )}
1073
1667
  {aaOpen && (
1074
1668
  <div className="mv-sheet" aria-label="自动归档设置">
1075
1669
  <div className="mv-sheet-head">
@@ -1151,7 +1745,7 @@ function SessionPanel({ workspacesSvc }) {
1151
1745
  )}
1152
1746
  {showSessionList && (
1153
1747
  <>
1154
- <div className="sess-tools sess-tools-4" aria-label="查找和整理会话">
1748
+ <div className="sess-tools sess-tools-5" aria-label="查找和整理会话">
1155
1749
  <div className="sess-field">
1156
1750
  <label htmlFor="dsm-search">搜索会话</label>
1157
1751
  <input id="dsm-search" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="标题、会话 ID 或工作区" />
@@ -1161,6 +1755,25 @@ function SessionPanel({ workspacesSvc }) {
1161
1755
  <select id="dsm-workspace-filter" value={workspaceFilter} onChange={(e) => setWorkspaceFilter(e.target.value)}>
1162
1756
  <option value="all">全部工作区</option>
1163
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>}
1164
1777
  </select>
1165
1778
  </div>
1166
1779
  <div className="sess-field">
@@ -1171,23 +1784,73 @@ function SessionPanel({ workspacesSvc }) {
1171
1784
  </div>
1172
1785
  <div className="sess-field">
1173
1786
  <label htmlFor="dsm-group">分组</label>
1174
- <select id="dsm-group" value={groupByLineage ? 'lineage' : 'flat'} onChange={(e) => setGroupByLineage(e.target.value === 'lineage')} title="血缘分组:子代理折叠在父会话下;平铺:与 DSH 原生一致,全部并列">
1175
- <option value="lineage">血缘(子代理折叠)</option>
1787
+ <select id="dsm-group" value={groupByLineage ? 'lineage' : 'flat'} onChange={(e) => setGroupByLineage(e.target.value === 'lineage')} title="血缘分组:子代理折叠、分支聚拢成组;平铺:与 DSH 原生一致,全部并列">
1788
+ <option value="lineage">血缘(折叠分组)</option>
1176
1789
  <option value="flat">平铺(全部并列)</option>
1177
1790
  </select>
1178
1791
  </div>
1179
1792
  </div>
1180
- <div className="sess-results" role="status">
1793
+ <div className="sess-results">
1181
1794
  {/* 一句话说清现状:有子代理折叠时,折叠数取代「共 X 个」尾巴
1182
- 出现在同一句里;没有折叠时维持「显示 N 个,共 M 个」。 */}
1183
- <span className="sess-results-main">
1184
- 显示 {topList.length} 个会话
1185
- {foldedCount
1186
- ? `,另有 ${foldedCount} 个子代理折叠在父会话下`
1187
- : query || workspaceFilter !== 'all'
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
1188
1802
  ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : filter === 'empty' ? emptyList.length : sessions.length} 个`
1189
1803
  : (topList.length !== list.length ? `,共 ${list.length} 个` : '')}
1190
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>
1191
1854
  </div>
1192
1855
  </>
1193
1856
  )}
@@ -1276,12 +1939,34 @@ function SessionPanel({ workspacesSvc }) {
1276
1939
  )}
1277
1940
 
1278
1941
  {showSessionList && list.length === 0 ? (
1279
- <div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : filter === 'empty' ? '没有空白会话。新开会话还没产生内容时会归到这里,可在侧栏用「纯净视图」把它们一并折叠。' : '暂无可管理的会话。'}</div>
1942
+ <div className="archv-empty">{query || workspaceFilter !== 'all' || tagFilter ? '没有匹配的会话。请调整搜索词、标签或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : filter === 'empty' ? '没有空白会话。新开会话还没产生内容时会归到这里,侧栏会自动隐藏它们。' : '暂无可管理的会话。'}</div>
1280
1943
  ) : showSessionList ? (
1281
1944
  <div className="archv-list" role="list">
1282
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
+ }
1283
1968
  const date = fmtDate(it.createdAt)
1284
- const expanded = openMove === it.sessionId
1969
+ const expanded = openMove === it.sessionId || openTags === it.sessionId
1285
1970
  return (
1286
1971
  <div key={it.sessionId} className={'archv-card' + (expanded ? ' archv-card-exp' : '')} role="listitem">
1287
1972
  <div className="archv-row">
@@ -1308,7 +1993,9 @@ function SessionPanel({ workspacesSvc }) {
1308
1993
  <span className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</span>
1309
1994
  {branchBadge(it.sessionId)}
1310
1995
  {emptyBadge(it.sessionId)}
1996
+ {tagChips(it.sessionId)}
1311
1997
  {kidsBadge(it.sessionId)}
1998
+ {branchGroupBadge(it.sessionId)}
1312
1999
  <span className="archv-id" title={it.sessionId}>{shortId(it.sessionId)}</span>
1313
2000
  </div>
1314
2001
  <div className="archv-meta">
@@ -1321,6 +2008,7 @@ function SessionPanel({ workspacesSvc }) {
1321
2008
  </div>
1322
2009
  </div>
1323
2010
  {groupByLineage && renderKids(it.sessionId, 0)}
2011
+ {groupByLineage && renderBranchGroup(it.sessionId)}
1324
2012
  {expanded && (
1325
2013
  <div className="mv-sheet" role="region" aria-label="移动到工作区">
1326
2014
  <div className="mv-sheet-head">
@@ -1372,6 +2060,53 @@ function SessionPanel({ workspacesSvc }) {
1372
2060
  </div>
1373
2061
  </div>
1374
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
+ })()}
1375
2110
  {openDetails === it.sessionId && (
1376
2111
  <div className="dtl-sheet" role="region" aria-label="会话详情">
1377
2112
  <div className="dtl-sheet-head">
@@ -1411,7 +2146,7 @@ function SessionPanel({ workspacesSvc }) {
1411
2146
  </div>
1412
2147
  )}
1413
2148
  {d.lineage && (d.lineage.parentSessionId || (d.lineage.children && d.lineage.children.length > 0) || (d.lineage.subagents && d.lineage.subagents.length > 0)) && (
1414
- <div className="dtl-sec"><div className="dtl-sec-t">血统</div>
2149
+ <div className="dtl-sec"><div className="dtl-sec-t">血缘</div>
1415
2150
  <div className="dtl-paths">
1416
2151
  {d.lineage.parentSessionId && <div>父会话: <code>{d.lineage.parentSessionId}</code></div>}
1417
2152
  {d.lineage.children && d.lineage.children.length > 0 && <div>子会话 ({d.lineage.children.length}): <code>{d.lineage.children.join(', ')}</code></div>}
@@ -1664,13 +2399,40 @@ const dsmLineage = new Map()
1664
2399
  // v3.6.2:host 预热是否还在途(sidebar-state/sessions 的 warmPending 旗标)。
1665
2400
  // 在途 → 侧栏轮询从 32s 台阶收紧到每拍(4s),标题/血缘一补齐就浮现;
1666
2401
  // 完成瞬间(true→false)触发一次性回调(面板自动补刷新,见 dsmOnWarmDone)。
2402
+ // T1(3.7.0):空白精判并入同一「后台忙闲」信号——两者都落地才算完成,
2403
+ // 面板只补刷一次(而不是 warm/refine 各刷一遍)。
1667
2404
  let dsmWarmPending = false
2405
+ let dsmRefinePending = false
1668
2406
  let dsmTrashRetryTimer = null
1669
2407
  const dsmWarmDoneHooks = new Set()
1670
2408
  function dsmOnWarmDone(fn) {
1671
2409
  dsmWarmDoneHooks.add(fn)
1672
2410
  return () => dsmWarmDoneHooks.delete(fn)
1673
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) }
1674
2436
  // 空白会话一律隐藏(原「纯净视图」的默认行为固化为唯一行为,开关已于
1675
2437
  // 0.1.3 移除:上游本就不把空白会话渲染进侧栏,开关没有可服务的场景)。
1676
2438
  // 只隐藏,永不删除;子代理行按 DSH 设计不进侧栏(ui-subagent README 明文
@@ -1699,9 +2461,24 @@ async function dsmLoadTrashIds() {
1699
2461
  if (info && typeof info === 'object') dsmLineage.set(String(id), info)
1700
2462
  }
1701
2463
  // v3.6.2:预热完成的瞬间广播一次(面板挂自动补刷新;侧栏靠节拍恢复 32s)。
1702
- const wasPending = dsmWarmPending
2464
+ // T1:旧 host 无 refinePending 字段 → !!undefined===false,节拍/广播语义
2465
+ // 与 3.6.2 完全一致(新 client 绝不在旧 host 上疯轮询)。
2466
+ const wasBusy = dsmWarmPending || dsmRefinePending
1703
2467
  dsmWarmPending = !!(r && r.warmPending)
1704
- if (wasPending && !dsmWarmPending) { try { for (const fn of dsmWarmDoneHooks) fn() } catch (e) { /* hook 出错不断主流程 */ } }
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
+ }
1705
2482
  if (dsmRepaintDots) dsmRepaintDots()
1706
2483
  } catch (e) {
1707
2484
  /* keep last known set */
@@ -2372,10 +3149,15 @@ function installSidebarStatusDots() {
2372
3149
  const t = document.createElement('div')
2373
3150
  t.className = 'dsm-toast' + (kind === 'err' ? ' dsm-toast-err' : '')
2374
3151
  t.textContent = msg
2375
- 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'
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())
2376
3155
  document.body.appendChild(t)
2377
- setTimeout(() => t.remove(), 4200)
3156
+ // T2:排队结果文案动辄几十字,固定 4200ms 读不完(3.6.0 已为面板 toast 立过
3157
+ // toastDurationFor,这里把侧栏通道并进来)。
3158
+ setTimeout(() => t.remove(), toastDurationFor(msg, kind))
2378
3159
  }
3160
+ dsmNotifySink = paintToast
2379
3161
  // ---- 侧栏就地展开子代理(issue #6)------------------------------------
2380
3162
  // DSH 按设计不渲染子代理行(ui-subagent 明文 "omitted from the ordinary
2381
3163
  // sidebar"),所以插件自己把它们在父行下方补渲染出来:点击徽标就地展开,
@@ -2536,8 +3318,8 @@ function installSidebarStatusDots() {
2536
3318
  if (typeof document !== 'undefined' && document.hidden) return
2537
3319
  // v3.6.2 C:预热在途时每拍(4s)都拉 sidebar-state,补齐结果立刻浮现;
2538
3320
  // 空闲时维持低频 32s(3.4.1 移除全表轮询的性能决策不回退——这里轮询的
2539
- // 是轻量 authority 路由,且仅在有活可补的窗口期加速)。
2540
- const cadence = dsmWarmPending ? 1 : 8
3321
+ // 是轻量 authority 路由,且仅在有活可补的窗口期加速)。T1:精判同闸。
3322
+ const cadence = (dsmWarmPending || dsmRefinePending) ? 1 : 8
2541
3323
  if (++dsmTrashTick % cadence === 0) dsmLoadTrashIds()
2542
3324
  paint()
2543
3325
  }
@@ -2562,6 +3344,7 @@ function installSidebarStatusDots() {
2562
3344
  clearInterval(tickTimer)
2563
3345
  clearTimeout(coldSecondBeat)
2564
3346
  obs.disconnect()
3347
+ dsmNotifySink = null
2565
3348
  dsmTeardownSidebarAug()
2566
3349
  }
2567
3350
  }
@@ -2605,7 +3388,7 @@ function installSidebarWorkspaceDrag() {
2605
3388
  el.setAttribute('role', 'status')
2606
3389
  el.textContent = message
2607
3390
  document.body.appendChild(el)
2608
- setTimeout(() => el.remove(), 2600)
3391
+ setTimeout(() => el.remove(), toastDurationFor(message)) // T2:并轨按字数定时
2609
3392
  }
2610
3393
  const clearVisuals = () => {
2611
3394
  document.querySelectorAll('.dsm-drag-source,.dsm-drop-target').forEach((el) => el.classList.remove('dsm-drag-source', 'dsm-drop-target'))