dsh-expert-group 0.1.1 → 0.1.2
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.md +131 -10
- package/client-bundle.js +1611 -147
- package/client-expert-group-model.js +4 -1
- package/client-expert-group-sidebar.js +1307 -86
- package/client.js +300 -60
- package/desktop-uninstall.ps1 +679 -0
- package/host/fanout-runtime.js +34 -7
- package/host/system-toast.js +1 -1
- package/host/turn-wait.js +465 -0
- package/index.js +1409 -1022
- package/orchestrator.js +36 -29
- package/package.json +5 -3
- package/prompt-contracts.js +9 -7
- package/role-registry.js +6 -6
- package/shared.js +125 -784
- package/{install.ps1 → web-install.ps1} +2 -2
- package/web-uninstall.ps1 +962 -0
- package/uninstall.ps1 +0 -47
package/client-bundle.js
CHANGED
|
@@ -349,9 +349,12 @@
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
function defaultExpanded(node) {
|
|
352
|
-
//
|
|
352
|
+
// 根节点是任务树入口,默认展开;空闲分组也默认展示成员。
|
|
353
353
|
if (isObject(node) && node.kind === "root") return true;
|
|
354
354
|
if (isObject(node) && node.kind === "idle-group") return true;
|
|
355
|
+
// fan-out 始终默认展示子代理,不因完成或取消自动收起;
|
|
356
|
+
// 这里只改变缺省值,createExpansionStore 仍优先保留用户手动折叠的选择。
|
|
357
|
+
if (isObject(node) && node.kind === "fanout") return true;
|
|
355
358
|
const status = normalizeStatus(isObject(node) ? (node.status ?? node.taskStatus) : undefined);
|
|
356
359
|
return !DEFAULT_COLLAPSED_STATUSES.has(status);
|
|
357
360
|
}
|
|
@@ -456,6 +459,34 @@
|
|
|
456
459
|
*/
|
|
457
460
|
const RIGHTBAR_WIDTH_RATIO = 0.2;
|
|
458
461
|
const OTHER_RIGHTBAR_WIDTH_RATIO = 0.5;
|
|
462
|
+
/**
|
|
463
|
+
* 「宿主里那份右栏宽度是否仍是插件写的」的匹配容差(px)。
|
|
464
|
+
*
|
|
465
|
+
* 面板的实际渲染宽度会被浏览器做子像素取整与布局舍入,不可能与插件写入的整数 target 逐字节
|
|
466
|
+
* 相等;1px 足以覆盖这类规范化,又远小于任何一次真实用户拖拽(拖拽是数十像素量级),因此
|
|
467
|
+
* 不会把「用户已经改过宽度」误判成「还是插件那份值」。
|
|
468
|
+
*/
|
|
469
|
+
const RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX = 1;
|
|
470
|
+
/**
|
|
471
|
+
* 同一个「未收敛的宽度 mismatch」允许的**恢复写入**上限(次)。
|
|
472
|
+
*
|
|
473
|
+
* 背景:宿主可能持续 clamp(请求 640,只渲染 320),或 React 延迟若干帧才把新宽度提交到 DOM。
|
|
474
|
+
* 这两种情况下「面板实际渲染宽度 != 插件 target」会一直是真,而它同时又是「宿主夺回了那份 px」
|
|
475
|
+
* 的证据;若每条同步路径(面板 observer / 2.5 秒轮询 / sessions 通知)都据此 force 重写,
|
|
476
|
+
* 就会与宿主的 clamp 无限拉锯:layout store 被同一个值反复改写、宿主反复重渲染(用户看到抖动)。
|
|
477
|
+
*
|
|
478
|
+
* 因此用一份 per-session 状态跟踪同一个恢复事件(session、**当前视口下预期**的 target、
|
|
479
|
+
* 观测到的实际宽度),同一个未收敛 mismatch 最多执行 2 次恢复写入:一次立即恢复 + 一次重试,
|
|
480
|
+
* 足以覆盖「宿主延迟一帧才提交」这类真实时序;再多就只是拉锯。上限**不能**被正常轮询自行重置——
|
|
481
|
+
* 只有观测到实际宽度已经等于 target(收敛)、**预期 target 真的改变**、所有权周期变化或 dispose
|
|
482
|
+
* 时才清除状态。
|
|
483
|
+
*
|
|
484
|
+
* 事件同一性里的 target 必须是**按当前视口算出来的预期值**,而不是「上次成功写入的值」:宿主持续
|
|
485
|
+
* clamp 时,视口从 3200 挪到 3201 / 3202,20% 取整后仍是同一个 640,那份 px 依然代表 20%——
|
|
486
|
+
* 这不是新的一次 mismatch,绝不能因此重新获得预算;只有窗口真正最大化(3200 → 4000,预期
|
|
487
|
+
* 640 → 800)才是一次新事件。否则用户轻微拖动窗口边缘就能无限重写,正是本预算要挡住的形状。
|
|
488
|
+
*/
|
|
489
|
+
const RIGHTBAR_WIDTH_RECOVERY_MAX_ATTEMPTS = 2;
|
|
459
490
|
/**
|
|
460
491
|
* DEG 的品牌图标 artwork:与宿主「智能体团队」入口同源的双人(users)图标。
|
|
461
492
|
*
|
|
@@ -561,6 +592,20 @@
|
|
|
561
592
|
const SIDEBAR_SPLIT_TRANSACTION_TIMEOUT_MS = 15000;
|
|
562
593
|
/** RAF 被后台节流时的短 fallback;只负责放行一次 split 尝试,不改变事务提交 deadline。 */
|
|
563
594
|
const SIDEBAR_SPLIT_LAYOUT_WAIT_FALLBACK_MS = 250;
|
|
595
|
+
/**
|
|
596
|
+
* 「分栏配比暂不可读」时的短延迟重试参数。
|
|
597
|
+
*
|
|
598
|
+
* 场景:DSH 重启后宿主从持久化布局恢复右栏分栏,第二列的 tab 条 / inventory 归属会**比
|
|
599
|
+
* split 容器晚若干帧**才提交。此时既不能证明组成仍成立,也不能证明组成已失效;旧实现把
|
|
600
|
+
* 「读不出来」直接当成「组成失效」立刻交还宿主,用户看到 40/60 → 50/50 的闪回。
|
|
601
|
+
*
|
|
602
|
+
* 因此这类「暂不可读」只在**已经写进过目标配比**之后进入 pending:保持当前样式并安排一次
|
|
603
|
+
* 短延迟重试(去重:同一时刻最多一个待执行重试),读面恢复后由重试或任何一次既有同步自动
|
|
604
|
+
* 重新落回目标配比。重试预算用尽仍读不出来时按「组成确实失效」交还宿主——预算有界,绝不
|
|
605
|
+
* 无限重排定时器,也不会把 0.4/0.6 无限期留在真实的单列结构上。
|
|
606
|
+
*/
|
|
607
|
+
const SIDEBAR_SPLIT_PENDING_RETRY_DELAY_MS = 400;
|
|
608
|
+
const SIDEBAR_SPLIT_PENDING_MAX_ATTEMPTS = 4;
|
|
564
609
|
/** 侧栏打开状态的复合键分隔符:会话 id、rootSessionId 与资源地址都是单行文本,NUL 不会出现在其中。 */
|
|
565
610
|
const SIDEBAR_TAB_KEY_SEPARATOR = "\u0000";
|
|
566
611
|
|
|
@@ -573,6 +618,120 @@
|
|
|
573
618
|
return value.trim();
|
|
574
619
|
}
|
|
575
620
|
|
|
621
|
+
/**
|
|
622
|
+
* 读取当前视口宽度(px);读不到(没有 window、非有限数、非正数)时返回 null。
|
|
623
|
+
*
|
|
624
|
+
* 右栏宽度是**视口比例**(Expert Group 页 20%、其它右侧分页 50%),但写进宿主 layout store 的
|
|
625
|
+
* 是像素值;因此「换算」与「视口是否变了」必须共用这一个读面,绝不能在两处各读一次
|
|
626
|
+
* (那会让比例换算与变化检测读到不同的视口)。读不到时返回 null,调用方一律放弃写入,
|
|
627
|
+
* 保留宿主当前值,绝不猜一个宽度。
|
|
628
|
+
*/
|
|
629
|
+
function viewportWidthOf() {
|
|
630
|
+
let value = null;
|
|
631
|
+
try { value = globalThis.window?.innerWidth ?? globalThis.innerWidth; } catch { value = null; }
|
|
632
|
+
const width = Number(value);
|
|
633
|
+
return Number.isFinite(width) && width > 0 ? width : null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* 元素查询的三种结果。**必须区分「没命中」与「读面有歧义」**,两者对调用方的含义完全不同:
|
|
638
|
+
*
|
|
639
|
+
* - `missing`:这个选择器在当前 DOM 里确实一个都没命中。它表示「当前宿主版本没有这个读法」,
|
|
640
|
+
* 调用方可以继续尝试其它选择器;
|
|
641
|
+
* - `unique`:恰好命中一个元素,可以当作证据使用;
|
|
642
|
+
* - `ambiguous`:命中多个(读面有歧义,例如新旧 frame 同时残留),或查询本身抛错 / 读面不可用
|
|
643
|
+
* (无 document、没有 querySelectorAll、代理抛错)。它**不是**「这个读法不存在」,因此绝不允许
|
|
644
|
+
* 被别的选择器「兜底」掩盖掉——把多命中降级成 missing 会让旧节点的唯一候选冒充当前 AppFrame。
|
|
645
|
+
*/
|
|
646
|
+
const ELEMENT_QUERY_MISSING = "missing";
|
|
647
|
+
const ELEMENT_QUERY_UNIQUE = "unique";
|
|
648
|
+
const ELEMENT_QUERY_AMBIGUOUS = "ambiguous";
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* 查询一个选择器,返回 `{ status, element }`(element 只在 unique 时非 null)。
|
|
652
|
+
*
|
|
653
|
+
* 只返回这三种状态,任何异常都归一为 `ambiguous`(读不出来 ≠ 读到了空),绝不抛给调用方,
|
|
654
|
+
* 也绝不按 DOM 顺序取第一个命中:第一个命中无法证明它就是目标元素。
|
|
655
|
+
*/
|
|
656
|
+
function elementQueryOf(doc, selector) {
|
|
657
|
+
let nodes = null;
|
|
658
|
+
try {
|
|
659
|
+
if (doc === null || doc === undefined || typeof doc.querySelectorAll !== "function") {
|
|
660
|
+
return { status: ELEMENT_QUERY_AMBIGUOUS, element: null };
|
|
661
|
+
}
|
|
662
|
+
nodes = Array.from(doc.querySelectorAll(selector));
|
|
663
|
+
} catch {
|
|
664
|
+
return { status: ELEMENT_QUERY_AMBIGUOUS, element: null };
|
|
665
|
+
}
|
|
666
|
+
if (nodes.length === 0) return { status: ELEMENT_QUERY_MISSING, element: null };
|
|
667
|
+
if (nodes.length > 1) return { status: ELEMENT_QUERY_AMBIGUOUS, element: null };
|
|
668
|
+
return { status: ELEMENT_QUERY_UNIQUE, element: nodes[0] };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* 解析当前宿主的 AppFrame 根元素;无法**唯一**定位时返回 null。
|
|
673
|
+
*
|
|
674
|
+
* 只用宿主公开的 DOM 标记,与本文件既有的 AppFrame 读法(rightbarTransitionTargets)一致:
|
|
675
|
+
* 1. 主读法 [data-shell-overlay]:覆盖层是 AppFrame 的**直接子节点**,其 parentElement 即 AppFrame;
|
|
676
|
+
* 2. 兜底 [data-rightbar-collapsed] / [data-rightbar-instant]:AppFrame 自己的公开状态属性
|
|
677
|
+
* (旧宿主可能没有覆盖层)。
|
|
678
|
+
*
|
|
679
|
+
* 歧义传播规则(本函数的 fail-closed 边界):
|
|
680
|
+
* - 任何一个选择器**多命中或查询异常** → 立刻返回 null,不再看后面的读法:此时读面已经不可信,
|
|
681
|
+
* 让另一个选择器的唯一候选去「兜底」等于用旧 frame 冒充当前 AppFrame;
|
|
682
|
+
* - 只有真正 `missing`(零命中)才继续尝试下一个选择器;
|
|
683
|
+
* - 候选元素去重后必须**恰好一个**:零个表示定位不到 AppFrame,多个不同元素表示读面有歧义
|
|
684
|
+
* (比如新旧 frame 同时残留),两者都返回 null——调用方按「读不到」处理,绝不挑一个猜。
|
|
685
|
+
*/
|
|
686
|
+
function appFrameElementOf(doc) {
|
|
687
|
+
const candidates = [];
|
|
688
|
+
const push = (element) => {
|
|
689
|
+
if (element === null || element === undefined || element.nodeType !== 1) return;
|
|
690
|
+
if (candidates.includes(element) === false) candidates.push(element);
|
|
691
|
+
};
|
|
692
|
+
const overlay = elementQueryOf(doc, "[data-shell-overlay]");
|
|
693
|
+
if (overlay.status === ELEMENT_QUERY_AMBIGUOUS) return null;
|
|
694
|
+
if (overlay.status === ELEMENT_QUERY_UNIQUE) {
|
|
695
|
+
let parent = null;
|
|
696
|
+
try { parent = overlay.element.parentElement ?? null; } catch { parent = null; }
|
|
697
|
+
push(parent);
|
|
698
|
+
}
|
|
699
|
+
for (const selector of ["[data-rightbar-collapsed]", "[data-rightbar-instant]"]) {
|
|
700
|
+
const state = elementQueryOf(doc, selector);
|
|
701
|
+
if (state.status === ELEMENT_QUERY_AMBIGUOUS) return null;
|
|
702
|
+
if (state.status === ELEMENT_QUERY_UNIQUE) push(state.element);
|
|
703
|
+
}
|
|
704
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* 读取当前宿主 AppFrame 的宽度(px);定位不到、尺寸非法时返回 null。
|
|
709
|
+
*
|
|
710
|
+
* 为什么不能拿 window.innerWidth 顶替:宿主 LayoutController 维护的 layout store 里那份
|
|
711
|
+
* viewportWidth 是用 **AppFrame 根元素的 getBoundingClientRect().width** 更新的,它是右栏宽度
|
|
712
|
+
* 上限 clamp 的参照系,并不恒等于 window.innerWidth(页面缩放、滚动条、外层留白、嵌入形态都会
|
|
713
|
+
* 让两者不等)。把 innerWidth 写进宿主那个权威槽位等于换了一个量去改宿主的判据:frame 比 window
|
|
714
|
+
* 窄时反而抬高上限,写出宿主自己永远不会写的宽度。
|
|
715
|
+
*
|
|
716
|
+
* fail-closed 边界(绝不猜测宿主尺寸):
|
|
717
|
+
* - AppFrame 定位不到或归属有歧义(见 appFrameElementOf)→ null;
|
|
718
|
+
* - 解析结果就是 document.body / document.documentElement → null:它们是「整个文档」,
|
|
719
|
+
* 不是 AppFrame,宽度语义完全不同,绝不拿它们顶替;
|
|
720
|
+
* - 没有 getBoundingClientRect、矩形读不出来、宽度不是有限正数 → null。
|
|
721
|
+
*/
|
|
722
|
+
function hostAppFrameWidthOf() {
|
|
723
|
+
try {
|
|
724
|
+
const doc = globalThis.document;
|
|
725
|
+
const frame = appFrameElementOf(doc);
|
|
726
|
+
if (frame === null) return null;
|
|
727
|
+
if (frame === doc?.body || frame === doc?.documentElement) return null;
|
|
728
|
+
const rect = typeof frame.getBoundingClientRect === "function" ? frame.getBoundingClientRect() : null;
|
|
729
|
+
if (rect === null || typeof rect !== "object") return null;
|
|
730
|
+
const width = Number(rect.width);
|
|
731
|
+
return Number.isFinite(width) && width > 0 ? width : null;
|
|
732
|
+
} catch { return null; }
|
|
733
|
+
}
|
|
734
|
+
|
|
576
735
|
/**
|
|
577
736
|
* 取一个方法而不读取它的调用形态。
|
|
578
737
|
*
|
|
@@ -1263,6 +1422,21 @@
|
|
|
1263
1422
|
".deg_expert_group_state_dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:currentColor}",
|
|
1264
1423
|
".deg_expert_group_state_dot[data-state=running]{color:var(--dsw-static-deepseek-450,var(--dsw-alias-state-business-primary))}",
|
|
1265
1424
|
".deg_expert_group_state_dot[data-state=ready]{color:var(--dsw-alias-state-success-primary)}",
|
|
1425
|
+
// 固定 30px 批量控件紧跟标题文字;不占独立 grid 尾列,也不使用自动外边距推向行尾。
|
|
1426
|
+
// 标题自身 min-width:0 + 单行省略,窄宽度下只收缩文字,箭头与批量控件保持固定占位。
|
|
1427
|
+
".deg_expert_group_trailing{display:flex;flex:0 0 30px;align-items:center;justify-content:center;width:30px;height:20px;min-width:30px}",
|
|
1428
|
+
".deg_expert_group_task_line_trailing{justify-content:flex-start}",
|
|
1429
|
+
".deg_expert_group_idle_title_line{display:flex;align-items:center;min-width:0;gap:2px}",
|
|
1430
|
+
".deg_expert_group_idle_title_line>.deg_expert_group_task{flex:0 1 auto;min-width:0}",
|
|
1431
|
+
// 批量关闭按钮:与单会话箭头同尺寸(30×20)与同色系(主题 business/info,缺 token 退回 cyan),
|
|
1432
|
+
// 形状是同一枚「两个左尖括号」图标。pending 期间 disabled + aria-busy,绝不重复发出关闭序列。
|
|
1433
|
+
".deg_expert_group_sidebar_batch_action{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;flex:0 0 30px;width:30px;height:20px;margin:0;padding:0;border:0;border-radius:5px;background:transparent;cursor:pointer;color:var(--dsw-alias-state-business-primary,var(--dsw-alias-button-info-fill,#22d3ee))}",
|
|
1434
|
+
".deg_expert_group_sidebar_batch_action:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-business-primary,var(--dsw-alias-button-info-fill,#22d3ee))}",
|
|
1435
|
+
".deg_expert_group_sidebar_batch_action:focus-visible{outline:2px solid var(--dsw-alias-interactive-border-focus,var(--dsw-alias-label-primary));outline-offset:1px}",
|
|
1436
|
+
".deg_expert_group_sidebar_batch_action:disabled{opacity:.45;cursor:wait}",
|
|
1437
|
+
// 「零」占位:inventory 已知为空时的非交互蓝色实心圆(直径 12px)。它只占位:
|
|
1438
|
+
// 没有 button / hover / tabindex,不进入键盘与可访问性交互面。
|
|
1439
|
+
".deg_expert_group_sidebar_batch_zero{display:inline-block;width:12px;height:12px;border-radius:50%;background:var(--dsw-static-deepseek-450,var(--dsw-alias-state-business-primary,#22d3ee))}",
|
|
1266
1440
|
// 树线锚点固定在每一级父行的 20px 状态列中心,避免依赖窗口测量。
|
|
1267
1441
|
".deg_expert_group_children{position:relative;min-width:0;margin:0;padding:0;--deg-parent-row-height:60px}",
|
|
1268
1442
|
".deg_expert_group_children>.deg_expert_group_node,.deg_expert_group_children>.deg_expert_group_fanout,.deg_expert_group_children>.deg_expert_group_idle_group{--deg-tree-anchor:14px;position:relative}",
|
|
@@ -1276,7 +1450,11 @@
|
|
|
1276
1450
|
// 空闲分组没有隐式首项 spacer;折叠、展开和空列表都保持直接相邻。
|
|
1277
1451
|
".deg_expert_group_idle_group{position:relative;min-width:0;padding-left:16px;--deg-tree-anchor:14px}",
|
|
1278
1452
|
".deg_expert_group_idle_group>.deg_expert_group_children{margin:0;padding:0;--deg-parent-row-height:28px}",
|
|
1279
|
-
|
|
1453
|
+
// 空闲分组标题行同样是 grid:状态列 + chevron 列 + 可收缩标题列 + 批量 trailing slot。
|
|
1454
|
+
// 第四列用 auto(与 .deg_expert_group_fanout_header 的 actions 列同款):slot 存在时按它自己的
|
|
1455
|
+
// 30px 宽占位,slot 为空(没有批量能力)时该列宽度为 0——标题宽度因此与改动前逐像素一致,
|
|
1456
|
+
// 绝不会因为「能力缺失」而平白被压掉 30px。空 idleChildren 时该 slot 承载不可交互的零圆点。
|
|
1457
|
+
".deg_expert_group_idle_group>.deg_expert_group_row.deg_expert_group_idle_header{display:grid;grid-template-columns:20px 20px minmax(0,1fr);align-items:center;height:28px;min-height:28px;padding:4px 0}",
|
|
1280
1458
|
".deg_expert_group_idle_group>.deg_expert_group_row{margin:0}",
|
|
1281
1459
|
".deg_expert_group_fanout{position:relative;margin-top:0;padding-left:16px}",
|
|
1282
1460
|
".deg_expert_group_fanout_header{display:grid;grid-template-columns:20px 20px minmax(0,1fr) auto;align-items:center;min-width:0;min-height:28px;gap:4px}",
|
|
@@ -1349,49 +1527,103 @@
|
|
|
1349
1527
|
}
|
|
1350
1528
|
|
|
1351
1529
|
/**
|
|
1352
|
-
*
|
|
1530
|
+
* 读一个 dock host 的 DOM 布尔 `hidden`。
|
|
1353
1531
|
*
|
|
1354
|
-
* 真实
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1532
|
+
* 真实 DockLayout(app.asar 里 US 组件渲染 host 的那一处)写的是 React 的 `hidden: !w`,
|
|
1533
|
+
* 其中 w = 「这个 host 的 tab 就是它所属 pane 的 activeTabId」:同一 pane 只有**一个** host
|
|
1534
|
+
* 不带 hidden,其余 host 全部带 hidden。hidden 是 HTMLElement 的布尔 IDL 属性,因此这里只认
|
|
1535
|
+
* 真正的布尔值;属性读面缺失、抛错或返回非布尔(替身没实现、宿主换了渲染方式)一律返回 null,
|
|
1536
|
+
* 调用方按「读不出来」fail-closed,绝不把 undefined 当成「可见」。
|
|
1358
1537
|
*/
|
|
1359
|
-
function
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1538
|
+
function hostHiddenStateOf(host) {
|
|
1539
|
+
try {
|
|
1540
|
+
const value = host?.hidden;
|
|
1541
|
+
return typeof value === "boolean" ? value : null;
|
|
1542
|
+
} catch { return null; }
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* 读取一个 dock **逻辑列**(= 同一 data-dockkit-column 下的全部 host)的**唯一活跃 tab 条**。
|
|
1547
|
+
*
|
|
1548
|
+
* 真实 DockLayout 对**每个 tab** 渲染一个 div[data-dockkit-host="dock"],同一 pane 的多个 tab
|
|
1549
|
+
* 共享同一个 data-dockkit-column;而 tab 条本身**只挂在活跃 tab 的那个 host 上**:
|
|
1550
|
+
* hidden: !w ← 非活跃 host 带 DOM hidden
|
|
1551
|
+
* children: [ w && <TabStrip …/>, … ] ← 非活跃 host 连 strip 都不渲染
|
|
1552
|
+
* 活跃 host 的那条 strip 里列出的是**该 pane 的全部 tab**(TabStrip 渲染 pane.tabs),所以只要
|
|
1553
|
+
* 取到唯一活跃 strip,这一逻辑列的 tab 组成就是完整的——多开一个子代理 tab 只会多出一个
|
|
1554
|
+
* hidden 的空 host,绝不能让整列因此变成「读不出来」而退回宿主的 1:1。
|
|
1555
|
+
*
|
|
1556
|
+
* 判定(任一条不成立即整列 fail-closed,绝不猜):
|
|
1557
|
+
* - 每个 host 的 hidden 必须是可读的布尔值;
|
|
1558
|
+
* - hidden === true 且该 host 没有自己的 strip → 真实非活跃 host,跳过;
|
|
1559
|
+
* - hidden === false 且**恰好一条**自己的 strip → 活跃 host,收集这条 strip;
|
|
1560
|
+
* - 可见 host 没有 strip、hidden host 却带着自己的 strip、querySelectorAll 或父链读面抛错
|
|
1561
|
+
* → 结构不是本插件认识的那一种,fail-closed;
|
|
1562
|
+
* - 一个逻辑列出现多个活跃 strip host,或整列一个活跃 strip 都没有 → fail-closed。
|
|
1563
|
+
*
|
|
1564
|
+
* 列内容区里嵌着的另一个 dock 也有自己的 strip,靠 withinSameDockScope 排除(父链上跨过
|
|
1565
|
+
* host/split 的 strip 不算本列)。返回 null 表示读不出来;可读时返回**恰好一条** strip 的数组。
|
|
1566
|
+
*/
|
|
1567
|
+
function cellStripNodesOf(column) {
|
|
1568
|
+
const hosts = column?.hosts ?? null;
|
|
1569
|
+
if (!Array.isArray(hosts) || hosts.length === 0) return null;
|
|
1570
|
+
let activeStrip = null;
|
|
1571
|
+
let activeCount = 0;
|
|
1572
|
+
for (const host of hosts) {
|
|
1573
|
+
if (host === null || host === undefined || typeof host.querySelectorAll !== "function") return null;
|
|
1574
|
+
const hidden = hostHiddenStateOf(host);
|
|
1575
|
+
if (hidden === null) return null;
|
|
1576
|
+
let nodes = null;
|
|
1577
|
+
try { nodes = Array.from(host.querySelectorAll(RIGHTBAR_STRIP_TABS_SELECTOR)); } catch { return null; }
|
|
1578
|
+
const owned = [];
|
|
1579
|
+
for (const node of nodes) {
|
|
1580
|
+
const scoped = withinSameDockScope(node, host, [RIGHTBAR_DOCK_HOST_ATTRIBUTE, RIGHTBAR_SPLIT_ATTRIBUTE]);
|
|
1581
|
+
if (scoped === null) return null;
|
|
1582
|
+
if (scoped === true) owned.push(node);
|
|
1583
|
+
}
|
|
1584
|
+
if (hidden === true) {
|
|
1585
|
+
// 真实非活跃 host 完全没有 strip;它若带着自己的 tab 条,说明结构不是这一种,fail-closed。
|
|
1586
|
+
if (owned.length !== 0) return null;
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
// 可见 host 必须恰好有一条自己的 tab 条:零条(tab 条没渲染出来)或多条都无法证明组成。
|
|
1590
|
+
if (owned.length !== 1) return null;
|
|
1591
|
+
activeCount += 1;
|
|
1592
|
+
if (activeCount > 1) return null;
|
|
1593
|
+
activeStrip = owned[0];
|
|
1368
1594
|
}
|
|
1369
|
-
|
|
1595
|
+
// 整列必须恰好一个活跃 host:整列全 hidden(一条 strip 都没有)同样读不出组成。
|
|
1596
|
+
if (activeCount !== 1 || activeStrip === null) return null;
|
|
1597
|
+
return [activeStrip];
|
|
1370
1598
|
}
|
|
1371
1599
|
|
|
1372
1600
|
/**
|
|
1373
|
-
*
|
|
1601
|
+
* 读取一个逻辑列自己的 tabId 集合。
|
|
1374
1602
|
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1603
|
+
* 只认**唯一活跃 strip** 里的 tab 芯片(strip 内部更深一层的 strip 属于嵌套 dock,不算本列)。
|
|
1604
|
+
* 读面抛错、活跃 strip 不是恰好一条、strip 里没有任何 tab、未知或空 tabId 都让整列读不出来,
|
|
1377
1605
|
* 调用方据此 fail-closed,绝不把「读不到」当成空列。
|
|
1606
|
+
*
|
|
1607
|
+
* 同一条 strip 里出现重复 tabId 同样是结构异常(一个 tab 不可能在同一个 tablist 里出现两次):
|
|
1608
|
+
* 这里绝不能用 Set 静默吞掉重复,否则「读面不完整」会被误判成正常组成。
|
|
1378
1609
|
*/
|
|
1379
|
-
function cellTabIdsOf(
|
|
1610
|
+
function cellTabIdsOf(column) {
|
|
1380
1611
|
const ids = new Set();
|
|
1381
|
-
const strips = cellStripNodesOf(
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
}
|
|
1612
|
+
const strips = cellStripNodesOf(column);
|
|
1613
|
+
// 读面契约:可读时恰好一条活跃 strip;null 或长度不为 1 都表示这一列读不出来。
|
|
1614
|
+
if (strips === null || strips.length !== 1) return { ids, readable: false };
|
|
1615
|
+
const strip = strips[0];
|
|
1616
|
+
if (typeof strip.querySelectorAll !== "function") return { ids, readable: false };
|
|
1617
|
+
let tabNodes = null;
|
|
1618
|
+
try { tabNodes = Array.from(strip.querySelectorAll(RIGHTBAR_TAB_SELECTOR)); } catch { return { ids, readable: false }; }
|
|
1619
|
+
for (const node of tabNodes) {
|
|
1620
|
+
const scoped = withinSameDockScope(node, strip, [RIGHTBAR_STRIP_TABS_ATTRIBUTE]);
|
|
1621
|
+
if (scoped === null) return { ids, readable: false };
|
|
1622
|
+
if (scoped !== true) continue;
|
|
1623
|
+
const tabId = text(attributeOf(node, RIGHTBAR_TAB_ID_ATTRIBUTE), null);
|
|
1624
|
+
if (tabId === null) return { ids, readable: false };
|
|
1625
|
+
if (ids.has(tabId)) return { ids, readable: false };
|
|
1626
|
+
ids.add(tabId);
|
|
1395
1627
|
}
|
|
1396
1628
|
if (ids.size === 0) return { ids, readable: false };
|
|
1397
1629
|
return { ids, readable: true };
|
|
@@ -1536,26 +1768,34 @@
|
|
|
1536
1768
|
}
|
|
1537
1769
|
|
|
1538
1770
|
/**
|
|
1539
|
-
* 读取 split 容器的直接 dock
|
|
1771
|
+
* 读取 split 容器的直接 dock 列,并按列号归并成**逻辑列**。
|
|
1540
1772
|
*
|
|
1541
1773
|
* 只有 split 的**直接子节点**里 `data-dockkit-host="dock"` 且带非空 `data-dockkit-column`
|
|
1542
1774
|
* 的才是真实列:浮动 host(data-dockkit-host="floating")、resize 手柄、嵌套 split 的子列
|
|
1543
|
-
*
|
|
1775
|
+
* 都不算列。列号缺失(读不出来或空串)一律整轮 fail-closed——拿不到列号的节点不能被当成一列。
|
|
1776
|
+
*
|
|
1777
|
+
* 真实 DockLayout 是「**每个 tab 一个 host**」:同一个 pane 的多个 tab 各有自己的
|
|
1778
|
+
* div[data-dockkit-host="dock"],它们共享同一个 data-dockkit-column(只有活跃 tab 的那个 host
|
|
1779
|
+
* 不带 hidden、也只有它渲染 tab 条)。因此同列多个 host 是**合法**结构,这里按列号归并成
|
|
1780
|
+
* { column, hosts }:hosts 保留 DOM 顺序,顺序本身没有语义(组成判定只看活跃 strip 里的
|
|
1781
|
+
* tab 集合),列号到 host 的归属仍然是精确的。
|
|
1544
1782
|
*/
|
|
1545
1783
|
function dockCellsOf(split) {
|
|
1546
1784
|
const cells = [];
|
|
1547
1785
|
let children = null;
|
|
1548
1786
|
try { children = split?.children ?? null; } catch { children = null; }
|
|
1549
1787
|
if (children === null || children === undefined) return { cells, readable: false };
|
|
1550
|
-
const
|
|
1788
|
+
const byColumn = new Map();
|
|
1551
1789
|
for (const child of Array.from(children)) {
|
|
1552
1790
|
if (child === null || typeof child.getAttribute !== "function") return { cells, readable: false };
|
|
1553
1791
|
if (attributeOf(child, RIGHTBAR_DOCK_HOST_ATTRIBUTE) !== RIGHTBAR_DOCK_HOST_VALUE) continue;
|
|
1554
1792
|
const column = text(attributeOf(child, RIGHTBAR_PANE_COLUMN_ATTRIBUTE), null);
|
|
1555
|
-
if (column === null
|
|
1556
|
-
|
|
1557
|
-
|
|
1793
|
+
if (column === null) return { cells, readable: false };
|
|
1794
|
+
const merged = byColumn.get(column);
|
|
1795
|
+
if (merged === undefined) byColumn.set(column, { column, hosts: [child] });
|
|
1796
|
+
else merged.hosts.push(child);
|
|
1558
1797
|
}
|
|
1798
|
+
for (const entry of byColumn.values()) cells.push(entry);
|
|
1559
1799
|
return { cells, readable: true };
|
|
1560
1800
|
}
|
|
1561
1801
|
|
|
@@ -1563,16 +1803,22 @@
|
|
|
1563
1803
|
* 判断一个 split 容器是不是「列 0 = Expert Group、列 1 = 当前 session 的另一个已知页」。
|
|
1564
1804
|
*
|
|
1565
1805
|
* fail-closed 的边界(任一条不成立就 false,插件一个字节都不写):
|
|
1566
|
-
* -
|
|
1567
|
-
* -
|
|
1806
|
+
* - 归并后的逻辑列不是**恰好两列**,或列号不是精确的 0/1(三列、缺列、未知列号都没有可证明的 40:60);
|
|
1807
|
+
* - 任一逻辑列读不出自己的活跃 strip / tabId(整列没有活跃 strip、多个活跃 strip、
|
|
1808
|
+
* 可见 host 没有 strip、hidden host 却带 strip、hidden 状态不可读、未知 tabId、读面抛错);
|
|
1568
1809
|
* - 列 0 里混进非 Expert Group 页,或列 1 里出现 Expert Group 页;
|
|
1569
|
-
* - 同一个 tabId
|
|
1810
|
+
* - 同一个 tabId 同时出现在两列,或同一条 strip 里重复出现(重复归属),
|
|
1811
|
+
* 或出现本会话 inventory 里没有的 tab(未知归属)。
|
|
1812
|
+
*
|
|
1813
|
+
* 同列多个 host(真实「每个 tab 一个 host」形态)本身合法:判定用的是归并后的逻辑列与它唯一的
|
|
1814
|
+
* 活跃 strip(该 strip 列出整个 pane 的 tab),因此多个子代理 tab 与单个子代理 tab 得到完全
|
|
1815
|
+
* 相同的 40:60 结论。
|
|
1570
1816
|
*/
|
|
1571
1817
|
function splitOwnsExpertPair(split, tabSets) {
|
|
1572
1818
|
if (tabSets === null || tabSets === undefined || typeof tabSets !== "object") return false;
|
|
1573
1819
|
const breakdown = dockCellsOf(split);
|
|
1574
1820
|
if (breakdown.readable !== true || breakdown.cells.length !== 2) return false;
|
|
1575
|
-
const byColumn = new Map(breakdown.cells.map((entry) => [entry.column, entry
|
|
1821
|
+
const byColumn = new Map(breakdown.cells.map((entry) => [entry.column, entry]));
|
|
1576
1822
|
if (!byColumn.has("0") || !byColumn.has("1")) return false;
|
|
1577
1823
|
const expertTabs = cellTabIdsOf(byColumn.get("0"));
|
|
1578
1824
|
const otherTabs = cellTabIdsOf(byColumn.get("1"));
|
|
@@ -1593,25 +1839,124 @@
|
|
|
1593
1839
|
}
|
|
1594
1840
|
|
|
1595
1841
|
/**
|
|
1596
|
-
*
|
|
1842
|
+
* 一个逻辑列的「暂不可读」判定(与 cellStripNodesOf 同一套读面,但区分失败原因)。
|
|
1597
1843
|
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1844
|
+
* 返回 "readable" | "unavailable" | "foreign":
|
|
1845
|
+
* - "readable":恰好一条活跃 tab 条且 tab 组成可读——这一列有完整组成,轮不到 pending 分支;
|
|
1846
|
+
* - "unavailable":整列只有 hidden host(tab 条还没渲染出来)。这是重启恢复窗口里第二列的
|
|
1847
|
+
* 典型形状,只有这种形状才允许 pending;
|
|
1848
|
+
* - "foreign":可见 host 没有 tab 条、非活跃 host 却带着自己的 tab 条、一个逻辑列出现多个活跃
|
|
1849
|
+
* strip host、hidden 状态不可读、host 列表为空、读面抛错。这些都不是「还没读完」而是
|
|
1850
|
+
* 「结构不是本插件认识的那一种」,必须按原有 fail-closed 语义交还宿主。
|
|
1601
1851
|
*/
|
|
1602
|
-
function
|
|
1603
|
-
|
|
1852
|
+
function cellPendingStateOf(column) {
|
|
1853
|
+
const hosts = column?.hosts ?? null;
|
|
1854
|
+
if (!Array.isArray(hosts) || hosts.length === 0) return "foreign";
|
|
1855
|
+
let activeStrip = null;
|
|
1856
|
+
let activeCount = 0;
|
|
1857
|
+
for (const host of hosts) {
|
|
1858
|
+
if (host === null || host === undefined || typeof host.querySelectorAll !== "function") return "foreign";
|
|
1859
|
+
const hidden = hostHiddenStateOf(host);
|
|
1860
|
+
if (hidden === null) return "foreign";
|
|
1861
|
+
let nodes = null;
|
|
1862
|
+
try { nodes = Array.from(host.querySelectorAll(RIGHTBAR_STRIP_TABS_SELECTOR)); } catch { return "foreign"; }
|
|
1863
|
+
const owned = [];
|
|
1864
|
+
for (const node of nodes) {
|
|
1865
|
+
const scoped = withinSameDockScope(node, host, [RIGHTBAR_DOCK_HOST_ATTRIBUTE, RIGHTBAR_SPLIT_ATTRIBUTE]);
|
|
1866
|
+
if (scoped === null) return "foreign";
|
|
1867
|
+
if (scoped === true) owned.push(node);
|
|
1868
|
+
}
|
|
1869
|
+
if (hidden === true) {
|
|
1870
|
+
// 真实非活跃 host 完全没有 strip;带了自己的 tab 条说明结构不是这一种。
|
|
1871
|
+
if (owned.length !== 0) return "foreign";
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
if (owned.length !== 1) return "foreign";
|
|
1875
|
+
activeCount += 1;
|
|
1876
|
+
if (activeCount > 1) return "foreign";
|
|
1877
|
+
activeStrip = owned[0];
|
|
1878
|
+
}
|
|
1879
|
+
if (activeCount !== 1 || activeStrip === null) return "unavailable";
|
|
1880
|
+
return cellTabIdsOf(column).readable === true ? "readable" : "foreign";
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* 区分「两列但第二列内容暂不可读」与「组成明确不再是目标」。
|
|
1885
|
+
*
|
|
1886
|
+
* 为什么需要它:重启恢复(宿主从持久化布局恢复右栏)时,split 容器往往比列内的 tab 条 /
|
|
1887
|
+
* inventory 归属早几帧出现。这段窗口里 splitOwnsExpertPair 会返回 false,但 false 同时
|
|
1888
|
+
* 覆盖了两种完全不同的现实:
|
|
1889
|
+
* - **暂不可读**(本函数返回 "unavailable"):结构上仍是两列,列 0 明确是本会话的
|
|
1890
|
+
* Expert Group 页,只有列 1 整列还没有活跃 tab 条(tab 条比 split 容器晚若干帧提交)。
|
|
1891
|
+
* 此时组成只是「还没读完」,插件应当保持已写下的配比并短延迟重试,绝不闪回宿主默认值;
|
|
1892
|
+
* - **明确非目标 / 单列**(返回 "foreign"):列数不是精确的 0/1 两列、列 0 不是 Expert Group
|
|
1893
|
+
* 页、列 0 的 tab 无法归属、或两列里混进重复/未知归属。这些都没有可证明的 40:60,必须
|
|
1894
|
+
* 按原有 fail-closed 语义交还宿主。
|
|
1895
|
+
*
|
|
1896
|
+
* 归属判定与 splitOwnsExpertPair 完全同源(同一份 tabSets、同一条唯一活跃 strip 读法),
|
|
1897
|
+
* 因此这里**不引入任何新的宽松证据**:pending 只在「目标那一侧已经被证明」时才成立,
|
|
1898
|
+
* 首次接管(从未写过目标配比)也绝不会因为列 1 读不出来就去猜页面归属。
|
|
1899
|
+
*/
|
|
1900
|
+
function splitPairPendingOf(split, tabSets) {
|
|
1901
|
+
if (tabSets === null || tabSets === undefined || typeof tabSets !== "object") return "foreign";
|
|
1902
|
+
const breakdown = dockCellsOf(split);
|
|
1903
|
+
if (breakdown.readable !== true || breakdown.cells.length !== 2) return "foreign";
|
|
1904
|
+
const byColumn = new Map(breakdown.cells.map((entry) => [entry.column, entry]));
|
|
1905
|
+
if (!byColumn.has("0") || !byColumn.has("1")) return "foreign";
|
|
1906
|
+
// 列 0 必须是可读且明确属于本会话的 Expert Group 页:读不出来就没有「已确认的目标」。
|
|
1907
|
+
const expertTabs = cellTabIdsOf(byColumn.get("0"));
|
|
1908
|
+
if (expertTabs.readable !== true || expertTabs.ids.size === 0) return "foreign";
|
|
1909
|
+
for (const tabId of expertTabs.ids) {
|
|
1910
|
+
if (tabSets.known.has(tabId) !== true) return "foreign";
|
|
1911
|
+
if (tabSets.expert.has(tabId) !== true) return "foreign";
|
|
1912
|
+
}
|
|
1913
|
+
// 列 1 只有「整列还没有活跃 tab 条」这一种形状算暂不可读;其余(组成读得出来、可见 host
|
|
1914
|
+
// 没有 strip、一个逻辑列多个活跃 strip、hidden 不可读等)都是「明确不是目标」,交回严格判定。
|
|
1915
|
+
return cellPendingStateOf(byColumn.get("1")) === "unavailable" ? "unavailable" : "foreign";
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* 在面板里按给定判据挑出**唯一**的分栏容器。
|
|
1920
|
+
*
|
|
1921
|
+
* 面板内部可能同时存在多个 split(列的内容区里嵌着的另一个 dock 也有自己的 split),因此
|
|
1922
|
+
* 判据命中多个时一律视为归属不明确并返回 null(调用方 fail-closed,绝不挑第一个猜测);
|
|
1923
|
+
* 返回数组的形态让调用方可以自行决定「零个 / 一个 / 多个」的处理方式(见下面的两个包装)。
|
|
1924
|
+
*/
|
|
1925
|
+
function uniqueSplitBy(panel, matches) {
|
|
1926
|
+
if (panel === null || panel === undefined || typeof panel.querySelectorAll !== "function") return [];
|
|
1604
1927
|
let nodes = null;
|
|
1605
|
-
try { nodes = Array.from(panel.querySelectorAll(RIGHTBAR_SPLIT_SELECTOR)); } catch { return
|
|
1606
|
-
|
|
1928
|
+
try { nodes = Array.from(panel.querySelectorAll(RIGHTBAR_SPLIT_SELECTOR)); } catch { return []; }
|
|
1929
|
+
const resolved = [];
|
|
1607
1930
|
for (const node of nodes) {
|
|
1608
|
-
if (
|
|
1609
|
-
|
|
1610
|
-
resolved
|
|
1931
|
+
if (matches(node) !== true) continue;
|
|
1932
|
+
resolved.push(node);
|
|
1933
|
+
if (resolved.length > 1) break;
|
|
1611
1934
|
}
|
|
1612
1935
|
return resolved;
|
|
1613
1936
|
}
|
|
1614
1937
|
|
|
1938
|
+
/**
|
|
1939
|
+
* 在面板里找出**唯一**可解析的分栏容器。
|
|
1940
|
+
*
|
|
1941
|
+
* 只有满足 splitOwnsExpertPair 的那一个才是本插件的目标:零个表示还没分栏或形态不认识,
|
|
1942
|
+
* 多个表示归属不明确——两种情况都返回 null(调用方 fail-closed)。
|
|
1943
|
+
*/
|
|
1944
|
+
function resolveSplitContainer(panel, tabSets) {
|
|
1945
|
+
const resolved = uniqueSplitBy(panel, (node) => splitOwnsExpertPair(node, tabSets) === true);
|
|
1946
|
+
return resolved.length === 1 ? resolved[0] : null;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* 找出面板里**唯一**的「暂不可读 pending」分栏容器(见 splitPairPendingOf 的说明)。
|
|
1951
|
+
*
|
|
1952
|
+
* 零个表示这一轮没有任何可保持目标配比的两列结构,多个表示归属不明确——调用方据此走
|
|
1953
|
+
* 原有的交还宿主分支,绝不挑第一个猜测。返回数组(而不是元素或 null),因为「零个」与
|
|
1954
|
+
* 「多个」在这里都必须与「恰好一个」区分开。
|
|
1955
|
+
*/
|
|
1956
|
+
function splitPairPendingContainers(panel, tabSets) {
|
|
1957
|
+
return uniqueSplitBy(panel, (node) => splitPairPendingOf(node, tabSets) === "unavailable");
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1615
1960
|
/**
|
|
1616
1961
|
* 解析 grid-template-columns 的三轨形态。
|
|
1617
1962
|
*
|
|
@@ -1821,6 +2166,33 @@
|
|
|
1821
2166
|
let hostCapture = null;
|
|
1822
2167
|
/** dispose 之后所有同步(含已经排队的迟到回调)一律短路,绝不再碰宿主样式。 */
|
|
1823
2168
|
let disposed = false;
|
|
2169
|
+
/**
|
|
2170
|
+
* 「暂不可读」pending 的记账(**只在已经写进过目标配比之后**才可能成立)。
|
|
2171
|
+
*
|
|
2172
|
+
* 语义:容器上当前就是插件写下的目标配比,但这一轮读面读不出「列 1 是本会话的另一个已知页」,
|
|
2173
|
+
* 也没有任何证据说明组成真的失效了(结构仍是两列、列 0 仍是 Expert Group 页)。
|
|
2174
|
+
* 此时用 applyRatio 维持/修复目标配比,并安排一次短延迟重试去重新求值——这正是重启恢复
|
|
2175
|
+
* 窗口里避免 target → host default 闪回的机制。
|
|
2176
|
+
*
|
|
2177
|
+
* attempts 的语义是「**实际执行过的** pending retry timer 轮数」:只有 schedulePendingRetry
|
|
2178
|
+
* 的回调真正跑起来才自增,外部同步(sessions 广播 / observer / 轮询)只读取它、绝不修改,
|
|
2179
|
+
* 因此同一个 400ms 等待期内的任意多次通知都不会提前耗尽预算。
|
|
2180
|
+
*/
|
|
2181
|
+
let pending = null;
|
|
2182
|
+
/** 去重用的待执行重试定时器句柄:同一时刻最多一个,重复同步绝不堆积。 */
|
|
2183
|
+
let pendingRetryTimer = null;
|
|
2184
|
+
/**
|
|
2185
|
+
* 最近一次同步传入的**真实读面函数**(install 收尾接线的 splitTrackReader.readTarget)。
|
|
2186
|
+
*
|
|
2187
|
+
* 重试 timer 必须用它重新读一遍当前 document / session / panel / split / inventory 状态,
|
|
2188
|
+
* 绝不能把上一轮那个 "pending" 结论原样回放:回放的话 timer 自己永远看不到「读面已经恢复」,
|
|
2189
|
+
* 收敛就只能依赖外部的 sessions / observer 通知,而重启恢复窗口里恰恰没有这些通知——
|
|
2190
|
+
* 结果是重试预算被白白烧完,配比反而被交还给宿主默认值(闪回)。
|
|
2191
|
+
*
|
|
2192
|
+
* 读面本身是纯函数式的现读(不依赖 this,也不缓存 DOM),因此这里只保存函数引用即可,
|
|
2193
|
+
* 每次调用都会重新求值。读面为 null(install 收尾前)时 pending 不可能被建立。
|
|
2194
|
+
*/
|
|
2195
|
+
let lastReader = null;
|
|
1824
2196
|
|
|
1825
2197
|
/** 读一条内联值与优先级;任何能力缺失/抛错都返回 null,由调用方按「读不到」处理。 */
|
|
1826
2198
|
const readInline = (style, name, readerName) => {
|
|
@@ -1936,16 +2308,91 @@
|
|
|
1936
2308
|
} catch { /* 还原失败只影响外观,后续同步仍按同一规则重试。 */ }
|
|
1937
2309
|
};
|
|
1938
2310
|
|
|
2311
|
+
/** 取消待执行的重试(去重的唯一出口):dispose、容器更换、状态离开 pending 时都必须调用。 */
|
|
2312
|
+
const cancelPendingRetry = () => {
|
|
2313
|
+
if (pendingRetryTimer === null) return;
|
|
2314
|
+
const timer = pendingRetryTimer;
|
|
2315
|
+
pendingRetryTimer = null;
|
|
2316
|
+
try {
|
|
2317
|
+
// 句柄由所在环境的 setTimeout 生成,必须用同一环境的 clearTimeout 释放。
|
|
2318
|
+
globalThis.clearTimeout?.(timer);
|
|
2319
|
+
} catch { /* 取消失败不能阻止其它所有权释放。 */ }
|
|
2320
|
+
};
|
|
2321
|
+
|
|
2322
|
+
/** 结束 pending:清掉容器身份、重试预算与待执行定时器(三个字段必须一起复位,绝不留半状态)。 */
|
|
2323
|
+
const clearPending = () => {
|
|
2324
|
+
pending = null;
|
|
2325
|
+
cancelPendingRetry();
|
|
2326
|
+
};
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* 为「暂不可读」安排一次短延迟重试。
|
|
2330
|
+
*
|
|
2331
|
+
* 三条硬约束(对应契约里的「有界、去重、可取消」):
|
|
2332
|
+
* - 去重:pendingRetryTimer 非空就直接返回,重复同步(面板 observer / 2.5 秒轮询 / 宿主广播)
|
|
2333
|
+
* 绝不堆积定时器;
|
|
2334
|
+
* - 有界:**实际执行过的 timer 轮数**达到上限后不再重排,按「组成确实失效」交还宿主样式;
|
|
2335
|
+
* - 可取消:dispose、容器更换、状态离开 pending 时都会 cancelPendingRetry。
|
|
2336
|
+
*
|
|
2337
|
+
* 预算的记账点只有一个:**这个回调真正开始执行的那一刻**。外部同步(sessions 广播 /
|
|
2338
|
+
* observer / 轮询)无论触发多少次都不消耗预算——它们只是在同一个 400ms 等待期内重复求值,
|
|
2339
|
+
* 若也去自增 attempts,真实 timer 一轮都没跑预算就被烧完,重启恢复窗口会提前闪回宿主比例。
|
|
2340
|
+
*
|
|
2341
|
+
* 回调里先出队,把这一轮计入预算,再用**真实读面**重新求值一次(sync(lastReader)):
|
|
2342
|
+
* 若读面已经恢复,sync 会走正常路径重新落到目标配比并立即清空 pending;若仍是 pending,
|
|
2343
|
+
* sync 会在预算内重排下一个 timer,预算耗尽时则 clearPending + restore(有界收敛)。
|
|
2344
|
+
* 没有可用调度器时只是「这一轮不重试」,任何一次既有同步仍会重新求值,绝不影响页面其它行为。
|
|
2345
|
+
*/
|
|
2346
|
+
const schedulePendingRetry = (container) => {
|
|
2347
|
+
if (disposed || pendingRetryTimer !== null) return;
|
|
2348
|
+
try {
|
|
2349
|
+
pendingRetryTimer = globalThis.setTimeout(() => {
|
|
2350
|
+
pendingRetryTimer = null;
|
|
2351
|
+
if (disposed) return;
|
|
2352
|
+
// 迟到 timer:容器/状态已经变过(session 切换、容器重挂载、已收敛),一律不再动作。
|
|
2353
|
+
if (pending === null || pending.container !== container) return;
|
|
2354
|
+
const entry = pending;
|
|
2355
|
+
// 读面函数缺失时不做任何猜测性写入:这一轮什么都不做,等下一次真实同步。
|
|
2356
|
+
if (typeof lastReader !== "function") return;
|
|
2357
|
+
// 这一轮 timer 确实执行了 → 只有这里消耗一轮预算(外部同步绝不走这条路径)。
|
|
2358
|
+
pending = { container, attempts: entry.attempts + 1 };
|
|
2359
|
+
sync(lastReader);
|
|
2360
|
+
// sync 自己会在「仍是 pending 且预算未耗尽」时重排。这里只兜底它提前返回的情况
|
|
2361
|
+
// (例如 document 读面不可用):pending 还在、预算还有、却没有 timer,必须补一个,
|
|
2362
|
+
// 否则有界重试会静默中断;attempts 已经自增,因此补排也必然有界。
|
|
2363
|
+
if (pending !== null && pending.container === container
|
|
2364
|
+
&& pendingRetryTimer === null
|
|
2365
|
+
&& pending.attempts < SIDEBAR_SPLIT_PENDING_MAX_ATTEMPTS) {
|
|
2366
|
+
schedulePendingRetry(container);
|
|
2367
|
+
}
|
|
2368
|
+
}, SIDEBAR_SPLIT_PENDING_RETRY_DELAY_MS);
|
|
2369
|
+
} catch {
|
|
2370
|
+
// 没有可用调度器:放弃这一轮重试(pending 保留,下一次同步会重新求值)。
|
|
2371
|
+
pendingRetryTimer = null;
|
|
2372
|
+
}
|
|
2373
|
+
};
|
|
2374
|
+
|
|
1939
2375
|
/**
|
|
1940
2376
|
* 按当前右栏 DOM 组成重估一次列宽配比。
|
|
1941
2377
|
*
|
|
1942
2378
|
* 读面全部由调用方以参数交进来(readTarget 每次现读 session / panel / split 容器):
|
|
1943
2379
|
* 这里**不引用** install 体内的任何后置 const(那是 TDZ 陷阱的来源),所有输入都是显式依赖,
|
|
1944
2380
|
* 调用点必须自己保证它们就绪。
|
|
1945
|
-
*
|
|
2381
|
+
*
|
|
2382
|
+
* readTarget 返回 { status, container }:
|
|
2383
|
+
* - status 缺省 / "target":组成可证明,按 applyRatio 施加目标配比;
|
|
2384
|
+
* - status "pending":**已经写过目标配比**、但这一轮读不出列 1 的组成(重启恢复窗口)——
|
|
2385
|
+
* 继续用 applyRatio 维持/修复目标配比并短延迟重试,绝不闪回宿主默认值,也绝不覆盖外部值;
|
|
2386
|
+
* - 其余(null、单列、明确非目标):交还宿主样式。
|
|
2387
|
+
*
|
|
2388
|
+
* 返回 true 表示这一轮之后插件仍持有 pending 或配比确实落在容器上(含幂等命中),
|
|
2389
|
+
* false 表示已还原 / 让位 / 未写入。调用方(requestSplitTrackSync 的各入口)不使用这个布尔值,
|
|
2390
|
+
* 它只服务于重试回调的兜底重排。
|
|
1946
2391
|
*/
|
|
1947
2392
|
const sync = (readTarget) => {
|
|
1948
2393
|
if (disposed) return false;
|
|
2394
|
+
// 记住本次的真实读面:pending 重试 timer 必须用它重新现读状态,而不是回放上一轮结论。
|
|
2395
|
+
if (typeof readTarget === "function") lastReader = readTarget;
|
|
1949
2396
|
// document 读面不可用:连「有没有分栏容器」都无法证明,保持现状(绝不猜测性还原)。
|
|
1950
2397
|
let doc = null;
|
|
1951
2398
|
try { doc = globalThis.document ?? null; } catch { doc = null; }
|
|
@@ -1957,19 +2404,57 @@
|
|
|
1957
2404
|
// (那些调用点只包了「失败不影响宿主」的兜底,异常外溢会让后面的宽度/手柄逻辑一起被跳过)。
|
|
1958
2405
|
try { target = typeof readTarget === "function" ? readTarget(doc) : null; } catch { target = null; }
|
|
1959
2406
|
if (target === null || target.container === null || target.container === undefined) {
|
|
2407
|
+
clearPending();
|
|
1960
2408
|
restore();
|
|
1961
2409
|
return false;
|
|
1962
2410
|
}
|
|
1963
|
-
// 容器换了(宿主重挂载 / 切到另一条 surface
|
|
2411
|
+
// 容器换了(宿主重挂载 / 切到另一条 surface):先作废上一轮的 pending 记账,再交还旧容器。
|
|
2412
|
+
if (pending !== null && pending.container !== target.container) clearPending();
|
|
1964
2413
|
if (applied !== null && applied.container !== target.container) restore();
|
|
2414
|
+
// 「暂不可读」只在**已经写过目标配比**之后才成立:没有 applied 就没有需要保持的样式,
|
|
2415
|
+
// 首次接管必须继续走原有 fail-closed 语义——绝不因为列 1 读不出来就去猜页面归属,
|
|
2416
|
+
// 也绝不凭空列或缺失 inventory 把未知页面当成目标(这条边界不能放松)。
|
|
2417
|
+
if (target.status === "pending" && applied !== null && applied.container === target.container) {
|
|
2418
|
+
// attempts 只统计**实际执行过的 pending retry timer 轮数**(由 schedulePendingRetry 的
|
|
2419
|
+
// 回调自增)。外部同步——sessions 广播、面板 observer、2.5 秒轮询——无论在同一等待期内
|
|
2420
|
+
// 触发多少次,都既不消耗预算也不重置预算,只维持 pending 并保证最多一个 timer。
|
|
2421
|
+
const attempts = pending !== null && pending.container === target.container ? pending.attempts : 0;
|
|
2422
|
+
if (attempts < SIDEBAR_SPLIT_PENDING_MAX_ATTEMPTS) {
|
|
2423
|
+
// 暂不可读期间同样要维持配比,不能只「保持当前样式」:宿主可能在这段窗口里重渲染回
|
|
2424
|
+
// 它自己的等分形态(甚至清掉内联声明),那样错误比例会一直留在屏幕上,直到预算耗尽
|
|
2425
|
+
// 也回不到 40/60。这里复用与「组成可证明」时**完全相同**的 applyRatio:
|
|
2426
|
+
// - 目标值 / 宿主自己的等分 / 宿主清空内联声明 → 保持或修复回目标配比;
|
|
2427
|
+
// - 外部刻意写下的非等分值 → 由 shouldApply 让位,绝不覆盖(不引入第二套写入规则)。
|
|
2428
|
+
applyRatio(target.container);
|
|
2429
|
+
// 首次进入 pending 才建立记账(attempts 从 0 起);已存在时原样保留已消耗的预算,
|
|
2430
|
+
// 绝不因外部同步累加(那会让预算在 timer 一轮都没跑时就被烧完)或重置。
|
|
2431
|
+
if (pending === null) pending = { container: target.container, attempts: 0 };
|
|
2432
|
+
schedulePendingRetry(target.container);
|
|
2433
|
+
// 保持 pending:组成还没恢复,继续有界重试(applyRatio 让位时也不改写外部值)。
|
|
2434
|
+
return true;
|
|
2435
|
+
}
|
|
2436
|
+
// 预算已经被**实际 timer 轮次**耗尽仍读不出来:按「组成确实失效」交还宿主样式并停止重排。
|
|
2437
|
+
clearPending();
|
|
2438
|
+
restore();
|
|
2439
|
+
return false;
|
|
2440
|
+
}
|
|
2441
|
+
// 组成可证明:清空 pending 记账后按目标配比重新求值(读面恢复时这一步会清掉重试预算)。
|
|
2442
|
+
clearPending();
|
|
2443
|
+
// 「暂不可读」但还没有可保持的样式:这一轮什么都不写(不是目标,也不猜测),
|
|
2444
|
+
// 由后续同步在组成可证明时再施加。
|
|
2445
|
+
if (target.status === "pending") return false;
|
|
1965
2446
|
return applyRatio(target.container);
|
|
1966
2447
|
};
|
|
1967
2448
|
|
|
1968
2449
|
return {
|
|
1969
2450
|
sync,
|
|
1970
|
-
/**
|
|
2451
|
+
/**
|
|
2452
|
+
* 卸载收尾:取消待执行的重试、还原内联声明并置位 disposed,之后任何迟到同步都不再写入。
|
|
2453
|
+
* 定时器必须在这里释放——卸载后插件不得再碰宿主样式,也不留下泄漏的 timer。
|
|
2454
|
+
*/
|
|
1971
2455
|
dispose() {
|
|
1972
2456
|
disposed = true;
|
|
2457
|
+
clearPending();
|
|
1973
2458
|
restore();
|
|
1974
2459
|
},
|
|
1975
2460
|
};
|
|
@@ -2275,6 +2760,33 @@
|
|
|
2275
2760
|
const sidebarSplitIntentRef = typeof react.useRef === "function" ? react.useRef(null) : { current: null };
|
|
2276
2761
|
/** 侧栏箭头动作的同 key 在途集合(打开/关闭共用一把锁):重复点击只允许一次动作穿过。 */
|
|
2277
2762
|
const sidebarTabPendingRef = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
|
|
2763
|
+
/**
|
|
2764
|
+
* 批量关闭动作的聚合锁,按 (sidebar session, root) 组合键隔离(主代理行 / 空闲分组标题行共用同一把)。
|
|
2765
|
+
*
|
|
2766
|
+
* 一次点击会顺序 await 多个 close(tabId):整段序列在途期间必须拒绝第二次点击,否则双击会
|
|
2767
|
+
* 让同一个 tabId 被关闭两次(第二次必然抛错),也会让两次序列交错发出关闭请求。
|
|
2768
|
+
*
|
|
2769
|
+
* 锁键必须含 root + sidebar session,而不是一个组件级 boolean:宿主 close 返回的 Promise
|
|
2770
|
+
* 可能长时间不 settle,而用户完全可以在它挂起期间切换 root。单一 boolean 会让旧 root 的
|
|
2771
|
+
* 在途序列把**新 root** 的批量按钮一并置成 disabled —— 一次与它无关的切换被旧锁阻塞,
|
|
2772
|
+
* 且旧序列一旦永不 settle 就永久阻塞。集合里只保留仍在途的 scope,结算时精确删除自己那一项。
|
|
2773
|
+
*/
|
|
2774
|
+
const sidebarBatchPendingRef = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
|
|
2775
|
+
/**
|
|
2776
|
+
* 批量关闭锁的复合键:当前 sidebar session + 当前 root。
|
|
2777
|
+
*
|
|
2778
|
+
* 两者缺一不可:同一个 root 的 root/child surface 各有自己的右栏,不同 root 更是完全独立的
|
|
2779
|
+
* surface;只有完全相同的两者才共享同一把锁(同一 scope 内 running/idle 两个批量入口互斥)。
|
|
2780
|
+
*/
|
|
2781
|
+
const sidebarBatchScopeKey = (sessionId, rootId) => String(sessionId) + SIDEBAR_TAB_KEY_SEPARATOR + String(rootId);
|
|
2782
|
+
/**
|
|
2783
|
+
* 最近一次同步算出的「批量候选 tabId 签名」。
|
|
2784
|
+
*
|
|
2785
|
+
* 主代理行与空闲分组标题行的批量控件/零圆点由渲染期现读的 openTabs inventory 决定,
|
|
2786
|
+
* 因此 inventory 变化必须触发一次重渲染——但 2.5 秒轮询在没有变化时绝不能再 bump,
|
|
2787
|
+
* 否则空闲时会变成定时重渲染。这里只在签名真的变化时 bump(见 syncSidebarTabs)。
|
|
2788
|
+
*/
|
|
2789
|
+
const sidebarBatchSignatureRef = typeof react.useRef === "function" ? react.useRef(null) : { current: null };
|
|
2278
2790
|
/**
|
|
2279
2791
|
* 最近一次渲染的右栏 tab 同步器(每次渲染同步写入,写法与 refreshRef 一致)。
|
|
2280
2792
|
*
|
|
@@ -2838,6 +3350,149 @@
|
|
|
2838
3350
|
for (const child of children) add(child, rootSessionId);
|
|
2839
3351
|
return addresses;
|
|
2840
3352
|
};
|
|
3353
|
+
/**
|
|
3354
|
+
* 当前 root 专家树里的「运行中」资源地址(普通专家、嵌套专家与最新 fan-out 分片)。
|
|
3355
|
+
*
|
|
3356
|
+
* 与 renderThreeLines 使用**同一个**规范地址 helper(subagentAddressOfChild + subagentChatAddress),
|
|
3357
|
+
* 因此批量候选与单会话箭头指向的是同一个 contentId,绝不存在第二套地址推导。
|
|
3358
|
+
*
|
|
3359
|
+
* 纳入条件只有一条:runtimeStatusOfNode(...) === "running"(与行内圆点、第三行摘要同源)。
|
|
3360
|
+
* ready/unknown 一律不纳入——未知状态绝不混进「可以关闭」的集合,空闲专家由空闲分组单独负责。
|
|
3361
|
+
*
|
|
3362
|
+
* parent 证据缺失或非法时 fail-closed:subagentAddressOfChild 会返回 null,该节点直接跳过,
|
|
3363
|
+
* 绝不用猜测的 parent 补出一个地址(猜出来的地址可能关掉另一个会话的 tab)。
|
|
3364
|
+
*/
|
|
3365
|
+
const sidebarExpertResourceAddresses = (node, parentId, addresses) => {
|
|
3366
|
+
const source = object(node);
|
|
3367
|
+
const kind = text(source.kind, null);
|
|
3368
|
+
if (kind === "fanout") {
|
|
3369
|
+
const shards = Array.isArray(source.shards) ? source.shards : [];
|
|
3370
|
+
for (const shard of shards) {
|
|
3371
|
+
const shardSource = object(shard);
|
|
3372
|
+
// 只认最新 attempt 的 childId:旧 attempt 的子会话不再由这一行代表,也不是可导航目标。
|
|
3373
|
+
const childId = text(shardSource.childId, null);
|
|
3374
|
+
if (childId === null) continue;
|
|
3375
|
+
if (runtimeStatusOfNode(shardSource, parentId, childId) !== "running") continue;
|
|
3376
|
+
const address = subagentChatAddress(subagentAddressOfChild(childId, parentId, {
|
|
3377
|
+
...shardSource,
|
|
3378
|
+
kind: "expert",
|
|
3379
|
+
}));
|
|
3380
|
+
if (address !== null) addresses.add(address);
|
|
3381
|
+
}
|
|
3382
|
+
return;
|
|
3383
|
+
}
|
|
3384
|
+
if (kind !== "expert") return;
|
|
3385
|
+
const childId = text(source.sessionId, text(source.childId, null));
|
|
3386
|
+
if (childId !== null && runtimeStatusOfNode(source, parentId, childId) === "running") {
|
|
3387
|
+
const address = subagentChatAddress(subagentAddressOfChild(childId, parentId, source));
|
|
3388
|
+
if (address !== null) addresses.add(address);
|
|
3389
|
+
}
|
|
3390
|
+
// 嵌套专家:parent 就是它的宿主专家会话;宿主缺失时内层地址推导 fail-closed。
|
|
3391
|
+
if (Array.isArray(source.children)) {
|
|
3392
|
+
for (const child of source.children) sidebarExpertResourceAddresses(child, childId, addresses);
|
|
3393
|
+
}
|
|
3394
|
+
};
|
|
3395
|
+
|
|
3396
|
+
/** 当前快照的 root 直接子节点(快照缺失时为空数组)。 */
|
|
3397
|
+
const sidebarRootChildren = () => (Array.isArray(snapshot?.root?.children) ? snapshot.root.children : []);
|
|
3398
|
+
|
|
3399
|
+
/**
|
|
3400
|
+
* 空闲分组的成员判定(ready 直接专家)——渲染与同步期共用这一份定义,
|
|
3401
|
+
* 避免两处各推导一次而产生第二事实源。
|
|
3402
|
+
*/
|
|
3403
|
+
const sidebarIdleChildren = () => sidebarRootChildren()
|
|
3404
|
+
.filter((node) => node.kind === "expert" && runtimeStatusOfNode(node, rootSessionId, text(node.sessionId, null)) === "ready");
|
|
3405
|
+
|
|
3406
|
+
/**
|
|
3407
|
+
* 当前 root 专家树里「运行中」的资源地址集合(主代理行批量关闭的唯一候选来源)。
|
|
3408
|
+
* 与空闲分组互斥:这里只遍历 root.children 中的专家/fan-out 节点,空闲成员不参与。
|
|
3409
|
+
*/
|
|
3410
|
+
const runningSidebarResourceAddresses = () => {
|
|
3411
|
+
const addresses = new Set();
|
|
3412
|
+
for (const child of sidebarRootChildren()) sidebarExpertResourceAddresses(child, rootSessionId, addresses);
|
|
3413
|
+
return addresses;
|
|
3414
|
+
};
|
|
3415
|
+
|
|
3416
|
+
/**
|
|
3417
|
+
* 空闲分组(ready 直接专家)的资源地址集合;成员判定严格复用当前 idleChildren,绝不重新推导。
|
|
3418
|
+
*/
|
|
3419
|
+
const idleSidebarResourceAddresses = (nodes) => {
|
|
3420
|
+
const addresses = new Set();
|
|
3421
|
+
for (const node of Array.isArray(nodes) ? nodes : []) {
|
|
3422
|
+
const source = object(node);
|
|
3423
|
+
const childId = text(source.sessionId, text(source.childId, null));
|
|
3424
|
+
if (childId === null) continue;
|
|
3425
|
+
const address = subagentChatAddress(subagentAddressOfChild(childId, rootSessionId, source));
|
|
3426
|
+
if (address !== null) addresses.add(address);
|
|
3427
|
+
}
|
|
3428
|
+
return addresses;
|
|
3429
|
+
};
|
|
3430
|
+
|
|
3431
|
+
/**
|
|
3432
|
+
* 一次批量动作的候选集合:当前 openTabs inventory ∩ 给定的规范地址集合。
|
|
3433
|
+
*
|
|
3434
|
+
* 匹配条件缺一不可(全部来自公开只读 inventory 的字段):
|
|
3435
|
+
* - candidate.sessionId === 当前 sidebar session:绝不操作别的 surface 的 tab;
|
|
3436
|
+
* - kind === "subagentchat":同 session 下别的 kind 不是本插件的资源;
|
|
3437
|
+
* - tabId 与 contentId 都非空:缺任何一个都无法精确关闭,也就没有可关闭的候选;
|
|
3438
|
+
* - contentId 精确等于集合中的地址:同一个 kind 下也可能是另一个子代理的地址。
|
|
3439
|
+
*
|
|
3440
|
+
* 结果按 tabId 去重:宿主 inventory 理论上可能有重复记录,重复 tabId 只能关一次。
|
|
3441
|
+
*/
|
|
3442
|
+
const sidebarBatchCandidates = (addresses, inventory) => {
|
|
3443
|
+
const seen = new Set();
|
|
3444
|
+
const candidates = [];
|
|
3445
|
+
for (const entry of inventory) {
|
|
3446
|
+
// 单条损坏记录(getter 抛错、非对象、缺字段)只跳过这一条:其余可关闭的 tab 照旧成候选。
|
|
3447
|
+
let candidate = null;
|
|
3448
|
+
try {
|
|
3449
|
+
candidate = object(entry);
|
|
3450
|
+
if (text(candidate.sessionId, null) !== sidebarSessionId) continue;
|
|
3451
|
+
if (text(candidate.kind, null) !== SUBAGENT_CHAT_KIND) continue;
|
|
3452
|
+
const tabId = text(candidate.tabId, null);
|
|
3453
|
+
if (tabId === null) continue;
|
|
3454
|
+
const contentId = text(candidate.contentId, null);
|
|
3455
|
+
if (contentId === null || addresses.has(contentId) !== true) continue;
|
|
3456
|
+
if (seen.has(tabId) === true) continue;
|
|
3457
|
+
seen.add(tabId);
|
|
3458
|
+
candidates.push(tabId);
|
|
3459
|
+
} catch { continue; }
|
|
3460
|
+
}
|
|
3461
|
+
return candidates;
|
|
3462
|
+
};
|
|
3463
|
+
|
|
3464
|
+
/**
|
|
3465
|
+
* 两类标题行的批量状态(渲染期纯读,一次渲染只算一次)。
|
|
3466
|
+
*
|
|
3467
|
+
* 四态语义严格区分「未知」「零」「有候选但关不掉」:
|
|
3468
|
+
* - inventory 读不出来(旧宿主 / 读面抛错 / 非数组)→ kind "none":不渲染零圆点、也不渲染按钮;
|
|
3469
|
+
* - inventory 已知是数组且候选数为 0 → kind "zero":非交互蓝色实心圆占位(需要左箭头图标可用)。
|
|
3470
|
+
* 零状态只由「候选集合为空」决定,**不依赖 close 能力**:本行确实没有可关闭的会话时,
|
|
3471
|
+
* 蓝点表达的就是这个事实,与宿主能否关闭无关;
|
|
3472
|
+
* - 候选数 > 0 且 close 能力可用 → kind "close":可点击的双左尖括号批量关闭按钮;
|
|
3473
|
+
* - 候选数 > 0 但 close 不可用 → kind "none":既不能误报「零」(inventory 里明明有本行的
|
|
3474
|
+
* 打开会话),也绝不渲染一个点了不会关的假按钮,这一行整个不出现行尾控件。
|
|
3475
|
+
*
|
|
3476
|
+
* 判定的顺序有实质意义:必须先算候选再看 close 能力。反过来(先看 close)会把「有候选但
|
|
3477
|
+
* 关不掉」误判成「零」,那正是蓝点最不该出现的场合。
|
|
3478
|
+
*/
|
|
3479
|
+
const sidebarBatchStateFor = (addresses) => {
|
|
3480
|
+
const inventory = readSidebarOpenTabs();
|
|
3481
|
+
if (inventory === null) return { kind: "none", candidates: [] };
|
|
3482
|
+
const candidates = sidebarBatchCandidates(addresses, inventory);
|
|
3483
|
+
if (candidates.length === 0) return { kind: "zero", candidates: [] };
|
|
3484
|
+
if (closeSidebarResource === null) return { kind: "none", candidates: [] };
|
|
3485
|
+
return { kind: "close", candidates };
|
|
3486
|
+
};
|
|
3487
|
+
|
|
3488
|
+
/**
|
|
3489
|
+
* 两行批量状态的合并签名:只在候选集合真的变化时触发一次重渲染(空闲轮询绝不空转重渲染)。
|
|
3490
|
+
* 两行必须一起进签名——只跟踪主代理行会让空闲分组的手动开/关 tab 迟迟不被反映。
|
|
3491
|
+
*/
|
|
3492
|
+
const sidebarBatchSignatureOf = (runningState, idleState) => {
|
|
3493
|
+
const part = (state) => (state.kind === "close" ? "close:" + state.candidates.join(",") : state.kind);
|
|
3494
|
+
return part(runningState) + "|" + part(idleState);
|
|
3495
|
+
};
|
|
2841
3496
|
|
|
2842
3497
|
/**
|
|
2843
3498
|
* 等宿主完成一次布局提交,再读取 DockLayout 的可分栏判定。
|
|
@@ -2937,6 +3592,21 @@
|
|
|
2937
3592
|
// 右栏拖拽手柄锁复用这条既有生命周期(挂载 / body 子树变更 / 2.5 秒轮询 / 可见性变化):
|
|
2938
3593
|
// 活跃 tab 一变就重估,绝不为它单独再挂一个长期 observer。请求失败只影响这一次重估。
|
|
2939
3594
|
try { requestRightbarDragSync?.(); } catch { /* 手柄锁只是体验优化,绝不影响面板。 */ }
|
|
3595
|
+
/**
|
|
3596
|
+
* 标题行批量控件/零圆点由渲染期现读的 openTabs inventory 决定,因此这里必须在**任何提前 return
|
|
3597
|
+
* 之前**比较一次候选签名:records 为空时旧实现会直接返回,inventory 变化就再也不会被反映,
|
|
3598
|
+
* 用户手动打开的 tab(没有本插件的打开记录)也会永远看不到可关闭的批量按钮。
|
|
3599
|
+
*
|
|
3600
|
+
* 只有签名真的变化才 bump:2.5 秒轮询在没有变化时绝不产生重渲染。
|
|
3601
|
+
*/
|
|
3602
|
+
const batchSignatureNow = sidebarBatchSignatureOf(
|
|
3603
|
+
sidebarBatchStateFor(runningSidebarResourceAddresses()),
|
|
3604
|
+
sidebarBatchStateFor(idleSidebarResourceAddresses(sidebarIdleChildren())),
|
|
3605
|
+
);
|
|
3606
|
+
if (sidebarBatchSignatureRef.current !== batchSignatureNow) {
|
|
3607
|
+
sidebarBatchSignatureRef.current = batchSignatureNow;
|
|
3608
|
+
bump((value) => value + 1);
|
|
3609
|
+
}
|
|
2940
3610
|
const records = sidebarTabRecordsRef.current;
|
|
2941
3611
|
const openings = sidebarTabOpeningRef.current;
|
|
2942
3612
|
const now = Date.now();
|
|
@@ -2962,6 +3632,9 @@
|
|
|
2962
3632
|
// 先完成精确 split 提交,再执行关闭同步;guide-only 或错误 pane 仍继续等待。
|
|
2963
3633
|
tryCommitSidebarSplit();
|
|
2964
3634
|
// 先完成打开记录,再执行关闭同步;同 pane 回落不再被转换成独立浮窗。
|
|
3635
|
+
// 这里保留「没有打开记录就直接返回」的既有短路(它保证空闲时不会发生任何 DOM 查询);
|
|
3636
|
+
// 批量控件的聚合状态更新已经在**这个短路之前**完成,因此 records 为空时 inventory 的变化
|
|
3637
|
+
// 照旧能被反映,而用户手动打开的 tab 也不再依赖本插件的打开记录。
|
|
2965
3638
|
if (records.size === 0) return;
|
|
2966
3639
|
const present = presentSidebarTabIds();
|
|
2967
3640
|
if (present === null) return;
|
|
@@ -3221,6 +3894,126 @@
|
|
|
3221
3894
|
finally { pending.delete(key); }
|
|
3222
3895
|
};
|
|
3223
3896
|
|
|
3897
|
+
/**
|
|
3898
|
+
* 批量关闭的唯一入口(主代理行 = 当前 root 的运行中专家/最新分片;空闲分组标题行 = ready 空闲成员)。
|
|
3899
|
+
*
|
|
3900
|
+
* 与单会话箭头共用的三条并发与生命周期边界:
|
|
3901
|
+
* 1) 聚合锁覆盖整段 await 序列:双击/连击只允许第一次穿过,pending 期间按钮 disabled + aria-busy;
|
|
3902
|
+
* 锁按 (sidebar session, root) 组合键隔离,只拒绝**同一个 surface** 的重复点击——别的
|
|
3903
|
+
* root/session 的批量按钮保持可用,绝不被一个与它无关的在途序列(甚至永不 settle 的
|
|
3904
|
+
* 宿主 Promise)阻塞;
|
|
3905
|
+
* 2) 候选集合在**点击那一刻**从同一个 inventory 快照固定下来(渲染期算好、随点击闭包传入),
|
|
3906
|
+
* 后续再点同一个按钮也不会把新出现的 tab 混进这一轮;
|
|
3907
|
+
* 3) 每次 await 前后都校验 mountedRef / currentRootRef / currentSidebarSessionRef:root 或
|
|
3908
|
+
* sidebar session 切换、组件卸载后立即停止后续关闭,绝不让旧点击继续操作新 surface。
|
|
3909
|
+
*
|
|
3910
|
+
* 单项失败严格隔离:close 同步抛错或返回 rejected Promise 只跳过这一项并继续其余项,
|
|
3911
|
+
* 也绝不把失败项标成已关闭——关闭记录一律交给宿主 inventory 与既有同步器确认(这里不删记录)。
|
|
3912
|
+
*/
|
|
3913
|
+
const handleSidebarBatchClose = async (candidates) => {
|
|
3914
|
+
if (mountedRef.current !== true
|
|
3915
|
+
|| currentRootRef.current !== rootSessionId
|
|
3916
|
+
|| currentSidebarSessionRef.current !== sidebarSessionId) return;
|
|
3917
|
+
if (closeSidebarResource === null) return;
|
|
3918
|
+
if (Array.isArray(candidates) !== true || candidates.length === 0) return;
|
|
3919
|
+
// 锁只覆盖「这一个 surface」:同一个 scope 的重复点击被拦下,别的 root/session 的批量按钮
|
|
3920
|
+
// 完全不受影响(它的键不在集合里)。
|
|
3921
|
+
const scopeKey = sidebarBatchScopeKey(sidebarSessionId, rootSessionId);
|
|
3922
|
+
const pendingScopes = sidebarBatchPendingRef.current;
|
|
3923
|
+
if (pendingScopes.has(scopeKey) === true) return;
|
|
3924
|
+
pendingScopes.add(scopeKey);
|
|
3925
|
+
// 立刻进入 pending 态:按钮 disabled + aria-busy,第二次点击在 has 检查处被拦下。
|
|
3926
|
+
bump((value) => value + 1);
|
|
3927
|
+
try {
|
|
3928
|
+
for (const tabId of candidates) {
|
|
3929
|
+
// await 之前:root/session 切换或卸载即停止后续关闭。
|
|
3930
|
+
if (mountedRef.current !== true
|
|
3931
|
+
|| currentRootRef.current !== rootSessionId
|
|
3932
|
+
|| currentSidebarSessionRef.current !== sidebarSessionId) return;
|
|
3933
|
+
try {
|
|
3934
|
+
// 单项 await:同步抛错与 rejected Promise 都在这里被隔离,序列继续。
|
|
3935
|
+
await closeSidebarResource.call(sidebarRight, tabId);
|
|
3936
|
+
} catch { /* 单项失败只影响这一项:失败项绝不被标成已关闭,也不改写任何记录。 */ }
|
|
3937
|
+
// await 之后:surface 已切换时不再继续(已经发出的那一次关闭照旧由宿主结算)。
|
|
3938
|
+
if (mountedRef.current !== true
|
|
3939
|
+
|| currentRootRef.current !== rootSessionId
|
|
3940
|
+
|| currentSidebarSessionRef.current !== sidebarSessionId) return;
|
|
3941
|
+
}
|
|
3942
|
+
} finally {
|
|
3943
|
+
// 只释放自己那一个 scope:旧序列的收尾绝不改写别的 root/session 的锁。
|
|
3944
|
+
pendingScopes.delete(scopeKey);
|
|
3945
|
+
// 重渲染与同步只服务**自己这一轮渲染**的 surface:root/session 已经切换时它们会去操作
|
|
3946
|
+
// 新 surface(旧 scope 的 bump 会让新 root 白白重渲染、旧闭包的 sync 会去同步旧 root)。
|
|
3947
|
+
// 回到同一个 root/session(键相同)时仍照常收尾,按钮不会因为缺少重渲染而停在 disabled。
|
|
3948
|
+
if (mountedRef.current === true
|
|
3949
|
+
&& currentRootRef.current === rootSessionId
|
|
3950
|
+
&& currentSidebarSessionRef.current === sidebarSessionId) {
|
|
3951
|
+
bump((value) => value + 1);
|
|
3952
|
+
// 关闭序列结束后推进一次同步:候选集合与签名都按宿主 inventory 重新读取,
|
|
3953
|
+
// 真正被宿主关掉的 tab 由既有同步器(present/active/浮窗标题证据)结算,插件不抢先删记录。
|
|
3954
|
+
syncSidebarTabs();
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
};
|
|
3958
|
+
|
|
3959
|
+
/**
|
|
3960
|
+
* 标题行批量控件(主代理行与空闲分组标题行共用)。
|
|
3961
|
+
*
|
|
3962
|
+
* 三态渲染:
|
|
3963
|
+
* - 候选 > 0 且 close 可用:与单会话箭头同款的双左尖括号按钮(30×20),
|
|
3964
|
+
* aria-label/title 说明会关闭几个 tab,type=button,preventDefault + stopPropagation 必须同时存在;
|
|
3965
|
+
* - inventory 已知且候选为 0:非交互蓝色实心圆(12px)占位,没有 button / hover / tabindex;
|
|
3966
|
+
* - inventory 不可读,或候选 > 0 但 close 不可用:什么都不渲染(未知态绝不假报「零」,
|
|
3967
|
+
* 「关不掉」也绝不退化成「零」)。
|
|
3968
|
+
*
|
|
3969
|
+
* 这里**不看 openResource**:批量关闭只读取公开 inventory 并调用 close(),不需要打开任何资源。
|
|
3970
|
+
* 以打开能力当门槛会让「宿主能关不能开」(或注入面只给了 close)的右栏整行失去控件。
|
|
3971
|
+
* 唯一的渲染能力门槛是左尖括号图标:缺它时连零占位也不渲染。
|
|
3972
|
+
*
|
|
3973
|
+
* 绝不使用 aria-pressed:这是批量命令,不是 toggle 状态。
|
|
3974
|
+
*/
|
|
3975
|
+
const renderSidebarBatchSlot = (addresses, label) => {
|
|
3976
|
+
const state = sidebarBatchStateFor(addresses);
|
|
3977
|
+
if (state.kind === "none") return null;
|
|
3978
|
+
if (state.kind === "zero") {
|
|
3979
|
+
// 左箭头图标缺失时连零占位也不渲染:没有可用的批量按钮能力就不该出现「本行有批量入口」的暗示。
|
|
3980
|
+
if (typeof primitives?.IconChevronLeftOutlineRegular !== "function") return null;
|
|
3981
|
+
return react.createElement("div", { className: "deg_expert_group_trailing" },
|
|
3982
|
+
react.createElement("span", {
|
|
3983
|
+
className: "deg_expert_group_sidebar_batch_zero",
|
|
3984
|
+
"data-zero": "true",
|
|
3985
|
+
"aria-hidden": "true",
|
|
3986
|
+
}));
|
|
3987
|
+
}
|
|
3988
|
+
if (typeof primitives?.IconChevronLeftOutlineRegular !== "function") return null;
|
|
3989
|
+
const count = state.candidates.length;
|
|
3990
|
+
const accessibleName = "在侧边栏关闭 " + count + " 个" + label;
|
|
3991
|
+
// 只看**当前** scope 的锁:别的 root/session 的在途序列绝不影响这一行的按钮状态。
|
|
3992
|
+
const pending = sidebarBatchPendingRef.current.has(sidebarBatchScopeKey(sidebarSessionId, rootSessionId));
|
|
3993
|
+
return react.createElement("div", { className: "deg_expert_group_trailing" },
|
|
3994
|
+
react.createElement("button", {
|
|
3995
|
+
type: "button",
|
|
3996
|
+
className: "deg_expert_group_sidebar_batch_action",
|
|
3997
|
+
title: accessibleName,
|
|
3998
|
+
"aria-label": accessibleName,
|
|
3999
|
+
"aria-busy": pending || undefined,
|
|
4000
|
+
disabled: pending,
|
|
4001
|
+
onClick: (event) => {
|
|
4002
|
+
event?.preventDefault?.();
|
|
4003
|
+
event?.stopPropagation?.();
|
|
4004
|
+
// 候选集合固定为这次渲染算出的那一份:点击后 inventory 再变也不影响本轮。
|
|
4005
|
+
return handleSidebarBatchClose(state.candidates);
|
|
4006
|
+
},
|
|
4007
|
+
onKeyDown: (event) => { event?.stopPropagation?.(); },
|
|
4008
|
+
}, react.createElement("span", {
|
|
4009
|
+
className: "deg_expert_group_double_arrow",
|
|
4010
|
+
"data-direction": "close",
|
|
4011
|
+
"aria-hidden": "true",
|
|
4012
|
+
},
|
|
4013
|
+
makeIcon(primitives, "IconChevronLeftOutlineRegular", { size: 20, className: "deg_expert_group_double_arrow_icon", "aria-hidden": true }),
|
|
4014
|
+
makeIcon(primitives, "IconChevronLeftOutlineRegular", { size: 20, className: "deg_expert_group_double_arrow_icon", "aria-hidden": true }))));
|
|
4015
|
+
};
|
|
4016
|
+
|
|
3224
4017
|
/**
|
|
3225
4018
|
* 标题后「在右侧栏打开 / 关闭」箭头按钮:与 DSH 原生子代理行同款箭头图标。
|
|
3226
4019
|
*
|
|
@@ -3350,10 +4143,19 @@
|
|
|
3350
4143
|
const metadata = metadataOf(source);
|
|
3351
4144
|
const taskClassName = "deg_expert_group_line deg_expert_group_task"
|
|
3352
4145
|
+ (oneLine ? "" : " deg_expert_group_shard_task");
|
|
4146
|
+
const trailing = options.trailing === undefined ? null : options.trailing;
|
|
4147
|
+
const taskChildren = [
|
|
4148
|
+
react.createElement("div", { className: taskClassName, title: fullName, onClick: onNavigate, ...navigateProps }, fullName),
|
|
4149
|
+
renderSidebarOpenAction(address, fullName),
|
|
4150
|
+
];
|
|
4151
|
+
if (trailing !== null) taskChildren.push(trailing);
|
|
4152
|
+
const taskLine = react.createElement("div", {
|
|
4153
|
+
className: "deg_expert_group_task_line" + (trailing === null ? "" : " deg_expert_group_task_line_trailing"),
|
|
4154
|
+
}, ...taskChildren);
|
|
4155
|
+
// 主代理批量控件作为标题行的相邻 flex 项,标题收缩省略时控件仍固定在标题末尾。
|
|
4156
|
+
// 其它专家 / 分片 / 空闲成员不传 trailing,单会话箭头结构保持不变。
|
|
3353
4157
|
return react.createElement("div", { className: "deg_expert_group_row_main", "data-kind": source.kind || "expert", "data-expanded": true },
|
|
3354
|
-
|
|
3355
|
-
react.createElement("div", { className: taskClassName, title: fullName, onClick: onNavigate, ...navigateProps }, fullName),
|
|
3356
|
-
renderSidebarOpenAction(address, fullName)),
|
|
4158
|
+
taskLine,
|
|
3357
4159
|
react.createElement("div", { className: "deg_expert_group_meta", title: metadata }, metadata),
|
|
3358
4160
|
react.createElement("div", { className: "deg_expert_group_line deg_expert_group_content", title: summary, onClick: onNavigate, ...summaryProps }, summary));
|
|
3359
4161
|
};
|
|
@@ -3386,16 +4188,24 @@
|
|
|
3386
4188
|
* 空闲分组是容器而非代理:它的圆点直接用规范值 ready(绿色静态圆点),
|
|
3387
4189
|
* 分组内成员各自按官方目录证据决定 running/ready。
|
|
3388
4190
|
*/
|
|
3389
|
-
const renderIdleGroup = (nodes) => {
|
|
3390
|
-
|
|
3391
|
-
|
|
4191
|
+
const renderIdleGroup = (nodes, trailing) => {
|
|
4192
|
+
// 空列表同样要渲染:snapshot 已经加载(调用方只在有快照时调用)时「空闲子代理(0)」与零圆点
|
|
4193
|
+
// 是用户看得到的零状态;加载中 / 没有快照时调用方根本不会走到这里,绝不伪造零。
|
|
4194
|
+
const members = Array.isArray(nodes) ? nodes : [];
|
|
4195
|
+
const group = { kind: "idle-group", id: rootSessionId + "__idle__", taskName: "空闲子代理", status: "idle", children: members };
|
|
3392
4196
|
const open = expand(group);
|
|
4197
|
+
const idleTitleChildren = [
|
|
4198
|
+
react.createElement("div", { className: "deg_expert_group_line deg_expert_group_task" }, "空闲子代理(" + members.length + ")"),
|
|
4199
|
+
];
|
|
4200
|
+
// 空闲分组的批量候选只来自当前 idleChildren(ready 直接专家);active 成员保留。
|
|
4201
|
+
// slot 与计数标题同属第三列的 inline flex,标题按可用宽度省略,slot 不会被推到行尾。
|
|
4202
|
+
if (trailing !== undefined && trailing !== null) idleTitleChildren.push(trailing);
|
|
3393
4203
|
return react.createElement("div", { className: "deg_expert_group_idle_group", "data-expanded": open },
|
|
3394
4204
|
react.createElement("div", { className: "deg_expert_group_row deg_expert_group_idle_header", "data-status": "ready", "data-expanded": open },
|
|
3395
4205
|
statusIcon(react, primitives, "ready"),
|
|
3396
4206
|
renderChevron(group, open),
|
|
3397
|
-
react.createElement("div", { className: "
|
|
3398
|
-
open ? react.createElement("div", { className: "deg_expert_group_children deg_expert_group_idle_children" },
|
|
4207
|
+
react.createElement("div", { className: "deg_expert_group_idle_title_line" }, ...idleTitleChildren)),
|
|
4208
|
+
open ? react.createElement("div", { className: "deg_expert_group_children deg_expert_group_idle_children" }, members.map((node, index) => renderExpert(node, index, rootSessionId))) : null);
|
|
3399
4209
|
};
|
|
3400
4210
|
|
|
3401
4211
|
const pendingSet = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
|
|
@@ -3523,8 +4333,13 @@
|
|
|
3523
4333
|
// 默认折叠依据展示层的 running/ready,而不是 Host 内部的旧任务 status。
|
|
3524
4334
|
// 这样 status=unknown 但官方目录 activity=inactive 的专家也会进入空闲分组;
|
|
3525
4335
|
// fan-out 仍只在自己的取消/重试逻辑中读取 statusOf(task)。
|
|
3526
|
-
const idleChildren =
|
|
4336
|
+
const idleChildren = sidebarIdleChildren();
|
|
3527
4337
|
const visibleChildren = children.filter((node) => !idleChildren.includes(node));
|
|
4338
|
+
// 两行标题后的批量 slot 内容:主代理行只覆盖运行中的普通专家/最新分片,空闲标题行只覆盖
|
|
4339
|
+
// idleChildren。两者都只在渲染期纯读 openTabs inventory 与 close 能力(inventory 读不出来、
|
|
4340
|
+
// 或候选存在但 close 缺失时返回 null,不渲染任何东西);打开能力不参与这里的判定。
|
|
4341
|
+
const rootBatchSlot = renderSidebarBatchSlot(runningSidebarResourceAddresses(), "运行中的子代理");
|
|
4342
|
+
const idleBatchSlot = renderSidebarBatchSlot(idleSidebarResourceAddresses(idleChildren), "空闲子代理");
|
|
3528
4343
|
const root = snapshot?.root;
|
|
3529
4344
|
const rootOpen = root === null ? true : expand(root);
|
|
3530
4345
|
const body = viewState.loading && snapshot === null
|
|
@@ -3533,10 +4348,10 @@
|
|
|
3533
4348
|
? react.createElement("div", { className: "deg_expert_group_empty" }, "暂无 Expert Group 任务")
|
|
3534
4349
|
: react.createElement("div", { className: "deg_expert_group_tree" },
|
|
3535
4350
|
react.createElement("div", { className: "deg_expert_group_node deg_expert_group_node_root", "data-root": "true" },
|
|
3536
|
-
react.createElement("div", { className: "deg_expert_group_row", "data-status": runtimeStatusOf(root, null), "data-expanded": true }, statusIcon(react, primitives, runtimeStatusOf(root, null)), renderChevron(root, rootOpen), react.createElement("div", null, renderThreeLines(root, { sessionId: root.sessionId, parentId: null, expanded: true }))),
|
|
4351
|
+
react.createElement("div", { className: "deg_expert_group_row", "data-status": runtimeStatusOf(root, null), "data-expanded": true }, statusIcon(react, primitives, runtimeStatusOf(root, null)), renderChevron(root, rootOpen), react.createElement("div", null, renderThreeLines(root, { sessionId: root.sessionId, parentId: null, expanded: true, trailing: rootBatchSlot }))),
|
|
3537
4352
|
rootOpen ? react.createElement("div", { className: "deg_expert_group_children" }, [
|
|
3538
4353
|
...visibleChildren.map((node, index) => node.kind === "fanout" ? renderFanout(node, index) : renderExpert(node, index, rootSessionId)),
|
|
3539
|
-
renderIdleGroup(idleChildren),
|
|
4354
|
+
renderIdleGroup(idleChildren, idleBatchSlot),
|
|
3540
4355
|
]) : null));
|
|
3541
4356
|
return react.createElement("section", { className: "deg_expert_group", "data-version": version, "aria-label": "Expert Group" },
|
|
3542
4357
|
react.createElement("header", { className: "deg_expert_group_header" },
|
|
@@ -4369,6 +5184,14 @@
|
|
|
4369
5184
|
* B 激活时一定会写自己的 20%,两者互不覆盖。
|
|
4370
5185
|
*/
|
|
4371
5186
|
const rightbarWidths = new Map();
|
|
5187
|
+
/**
|
|
5188
|
+
* sessionId → 上次成功写入宽度时所用的**视口宽度**(px)。
|
|
5189
|
+
*
|
|
5190
|
+
* 与 rightbarWidths 同为 per-session 的目标值记账(同样只在 install 生命周期内有效、
|
|
5191
|
+
* dispose 一并清空),但记的是「那份像素值是按哪个视口算出来的」。窗口最大化 / 全屏后
|
|
5192
|
+
* 视口变化,这份记账就是常驻同步路径发现「必须重算」的判据(见 rightbarWidthViewportChanged)。
|
|
5193
|
+
*/
|
|
5194
|
+
const rightbarWidthViewports = new Map();
|
|
4372
5195
|
/**
|
|
4373
5196
|
* 最近一次成功写入宽度的 surface 会话:宿主那份全局宽度当前归谁。
|
|
4374
5197
|
*
|
|
@@ -4394,6 +5217,58 @@
|
|
|
4394
5217
|
const rightbarWidthPreparedSessions = new Set();
|
|
4395
5218
|
/** 最近一次被公开 active() 证明确实处于 Expert Group 页、且宽度已经落到宿主的会话集合。 */
|
|
4396
5219
|
const rightbarWidthActiveSessions = new Set();
|
|
5220
|
+
/**
|
|
5221
|
+
* 同一个「未收敛的宽度 mismatch」的恢复写入记账:sessionId → { target, actualWidth, attempts }。
|
|
5222
|
+
*
|
|
5223
|
+
* 只记**恢复边沿**(宿主夺回了那份 px)的写入,不记首次接管 / 从其它分页返回 Expert Group /
|
|
5224
|
+
* 分栏事务的 50% 与收口 20% 这些正常写入;因此它既不会挡住正常写入,也不会让正常轮询把预算
|
|
5225
|
+
* 重置(见 RIGHTBAR_WIDTH_RECOVERY_MAX_ATTEMPTS)。
|
|
5226
|
+
*
|
|
5227
|
+
* 事件同一性由 (target, 观测到的实际宽度) 决定,其中 target 是**按当前视口算出来的预期值**
|
|
5228
|
+
* (不是「上次成功写入的值」,见 recoverRightbarWidth):
|
|
5229
|
+
* - 没有记账、预期 target 变了、或观测到的实际宽度变了 → 新的一次 mismatch 事件,重新获得完整预算;
|
|
5230
|
+
* - 三者都相同 → 同一个未收敛事件,预算用尽后一律拒绝写入。
|
|
5231
|
+
*
|
|
5232
|
+
* 「预期 target 变了」必须用当前视口重算:宿主持续 clamp 时视口挪一两个像素、20% 取整后仍是同一个
|
|
5233
|
+
* px,那不是新事件;只有窗口真正最大化到取整结果改变才是一次新事件。
|
|
5234
|
+
*/
|
|
5235
|
+
const rightbarWidthRecoveries = new Map();
|
|
5236
|
+
/** 清除某个会话的恢复写入记账(收敛 / 所有权周期变化 / dispose 共用)。 */
|
|
5237
|
+
const clearRightbarWidthRecovery = (sessionId) => {
|
|
5238
|
+
if (sessionId === null || sessionId === undefined) return;
|
|
5239
|
+
rightbarWidthRecoveries.delete(sessionId);
|
|
5240
|
+
};
|
|
5241
|
+
/**
|
|
5242
|
+
* 恢复边沿的**有界预算**判定:返回 true 表示这一次「宿主夺回了宽度」的恢复写入允许执行。
|
|
5243
|
+
*
|
|
5244
|
+
* 只在恢复边沿调用(统一入口是 recoverRightbarWidth:recomputeOwnedWidth 的 hostReclaimed 分支
|
|
5245
|
+
* 与 confirmed 分支的同一判据),因此首次接管、从其它分页返回、分栏事务的 50% / 收口 20% 等
|
|
5246
|
+
* 正常写入完全不受影响。
|
|
5247
|
+
*
|
|
5248
|
+
* target 参数必须是**按当前视口算出来的预期值**(`Math.round(视口宽度 × 比例)`),不能传「上次
|
|
5249
|
+
* 成功写入的值」:宿主持续 clamp 时,视口 3200 → 3201 → 3202 的预期值都是 640,用预期值做事件
|
|
5250
|
+
* 同一性,这些视口变化才会被正确地归入同一个未收敛事件、不再重新获得预算。
|
|
5251
|
+
*
|
|
5252
|
+
* 预算在**调用时**消耗(而不是写入成功后):写入本身几乎不会失败,而「尝试过就计数」能保证
|
|
5253
|
+
* 即使宿主每次都把写入吞掉,也不会出现无限重试——这正是本预算要挡住的形状。
|
|
5254
|
+
*/
|
|
5255
|
+
const allowRightbarRecoveryWrite = (sessionId, target, actualWidth) => {
|
|
5256
|
+
if (sessionId === null || sessionId === undefined) return false;
|
|
5257
|
+
if (typeof target !== "number" || Number.isFinite(target) !== true) return false;
|
|
5258
|
+
if (typeof actualWidth !== "number" || Number.isFinite(actualWidth) !== true) return false;
|
|
5259
|
+
const recorded = rightbarWidthRecoveries.get(sessionId);
|
|
5260
|
+
const sameEvent = recorded !== undefined
|
|
5261
|
+
&& recorded.target === target
|
|
5262
|
+
&& Math.abs(recorded.actualWidth - actualWidth) <= RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX;
|
|
5263
|
+
if (sameEvent !== true) {
|
|
5264
|
+
// 新的一次 mismatch 事件(首次观测 / target 变了 / 观测到的实际宽度变了)。
|
|
5265
|
+
rightbarWidthRecoveries.set(sessionId, { target, actualWidth, attempts: 1 });
|
|
5266
|
+
return true;
|
|
5267
|
+
}
|
|
5268
|
+
if (recorded.attempts >= RIGHTBAR_WIDTH_RECOVERY_MAX_ATTEMPTS) return false;
|
|
5269
|
+
recorded.attempts += 1;
|
|
5270
|
+
return true;
|
|
5271
|
+
};
|
|
4397
5272
|
/** 读取某个会话当前的宽度模式;没有已确认模式时返回 null。 */
|
|
4398
5273
|
const widthModeOf = (sessionId) => (sessionId === null || sessionId === undefined
|
|
4399
5274
|
? null
|
|
@@ -4401,8 +5276,17 @@
|
|
|
4401
5276
|
/** 写入(mode 非空)或清除(mode 为 null)某个会话的宽度模式。 */
|
|
4402
5277
|
const setWidthMode = (sessionId, mode) => {
|
|
4403
5278
|
if (sessionId === null || sessionId === undefined) return;
|
|
4404
|
-
|
|
4405
|
-
|
|
5279
|
+
const previous = rightbarWidthModes.get(sessionId) ?? null;
|
|
5280
|
+
const next = mode === null || mode === undefined ? null : mode;
|
|
5281
|
+
if (next === null) rightbarWidthModes.delete(sessionId);
|
|
5282
|
+
else rightbarWidthModes.set(sessionId, next);
|
|
5283
|
+
// 模式(= 宽度所有权周期)**发生变化**时,恢复事件记账随之失效:新周期里的第一次 mismatch
|
|
5284
|
+
// 是新事件,重新获得完整预算。两条刻意收紧的边界:
|
|
5285
|
+
// - 只在**已有非空模式**被换掉(expert ↔ other、或交还成 null)时清除。null → expert 是
|
|
5286
|
+
// 「首次确认这一份 expert 所有权」,恢复记账仍属于同一个周期,不能被重置;
|
|
5287
|
+
// - 无条件清除会让 confirmed 分支每一轮同步都重置预算(它每轮都用同一个值调用本函数),
|
|
5288
|
+
// 正常轮询就能无限重写——正是本预算要挡住的形状。
|
|
5289
|
+
if (previous !== null && previous !== next) clearRightbarWidthRecovery(sessionId);
|
|
4406
5290
|
};
|
|
4407
5291
|
/** 某个会话是否刚被 openTargets 预置过宽度(同一同步栈内不重复写)。 */
|
|
4408
5292
|
const isWidthPrepared = (sessionId) => sessionId !== null && sessionId !== undefined
|
|
@@ -4427,6 +5311,8 @@
|
|
|
4427
5311
|
setWidthMode(sessionId, null);
|
|
4428
5312
|
setWidthPrepared(sessionId, false);
|
|
4429
5313
|
setWidthActive(sessionId, false);
|
|
5314
|
+
// 模式/所有权周期结束时,这个会话的「恢复事件」记账一并失效:下一次 mismatch 是新事件。
|
|
5315
|
+
clearRightbarWidthRecovery(sessionId);
|
|
4430
5316
|
};
|
|
4431
5317
|
/**
|
|
4432
5318
|
* 把宿主那份全局宽度的归属迁移到 sessionId(每次成功写入后调用)。
|
|
@@ -4491,37 +5377,274 @@
|
|
|
4491
5377
|
* 视口宽度变化导致同一会话的目标 px 变化时会重写一次。
|
|
4492
5378
|
*
|
|
4493
5379
|
* 20% 是精确比例:插件不做任何最小值 clamp(宿主 setRightbar 自己有 clamp,它才是权威边界)。
|
|
5380
|
+
*
|
|
5381
|
+
* 写入前会 best-effort 同步宿主自己那份视口缓存:宿主 clamp 用的是 AppFrame 根元素宽度,
|
|
5382
|
+
* 冷启动时它可能还停在旧值,会把目标宽度夹小(2560 视口写 1280,宿主按 1280 缓存夹成 896),
|
|
5383
|
+
* 而插件的记账已认为「这个视口写过」,偏差不会自愈。同步值只取**能唯一、可靠读到的 AppFrame
|
|
5384
|
+
* 宽度**(见 hostAppFrameWidthOf),绝不拿 window.innerWidth 顶替;读不到就完全跳过同步。
|
|
5385
|
+
* 目标像素值仍按 window.innerWidth 的比例换算,本次已读取的同一个视口宽度贯穿始终。
|
|
5386
|
+
*
|
|
4494
5387
|
* 兼容:layout 服务缺失、服务代理读属性抛错、setRightbar 不是函数或调用抛错时一律返回 false
|
|
4495
5388
|
* 且**不记账**——写入失败不算应用,后续通知仍可重试,宿主自己的默认宽度继续生效。
|
|
5389
|
+
* 可选的视口同步 action 缺失、读属性抛错或调用抛错都不阻断既有 setRightbar 路径。
|
|
4496
5390
|
*/
|
|
4497
5391
|
const applyRightbarWidth = (sessionId, ratio = RIGHTBAR_WIDTH_RATIO, force = false) => {
|
|
4498
5392
|
if (sessionId === null || sessionId === undefined) return false;
|
|
4499
5393
|
// 视口宽度读不到(无 DOM 环境)就没有可换算的比例:放弃写入,保留宿主当前值。
|
|
4500
|
-
const viewportWidth =
|
|
4501
|
-
if (
|
|
5394
|
+
const viewportWidth = viewportWidthOf();
|
|
5395
|
+
if (viewportWidth === null) return false;
|
|
4502
5396
|
const normalizedRatio = Number(ratio);
|
|
4503
5397
|
if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) return false;
|
|
4504
5398
|
const target = Math.round(viewportWidth * normalizedRatio);
|
|
4505
5399
|
if (force !== true && rightbarWidths.get(sessionId) === target) return false;
|
|
4506
5400
|
const layout = config.layout || optionalService("layout");
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
5401
|
+
// panels 与两个 action 的属性读取必须**逐项独立隔离**:可选的 setViewportWidth 读属性抛错
|
|
5402
|
+
// (Cordis 服务代理对未声明属性就会抛)绝不能把必要的 setRightbar 一起挡掉。
|
|
5403
|
+
let panels = null;
|
|
5404
|
+
try { panels = layout?.panels ?? null; } catch { panels = null; }
|
|
5405
|
+
const setRightbar = methodOf(panels, "setRightbar");
|
|
4512
5406
|
if (setRightbar === null) return false;
|
|
5407
|
+
const setViewportWidth = methodOf(panels, "setViewportWidth");
|
|
5408
|
+
// 宿主 setRightbar 按 layout store 的 viewportWidth 做上限 clamp,而那份缓存来自 AppFrame 根
|
|
5409
|
+
// 元素宽度(冷启动时可能还停在旧值,把目标宽度夹小)。只有能唯一、可靠读到 AppFrame 宽度时
|
|
5410
|
+
// 才先把它同步给宿主;读不到 AppFrame、尺寸非法时**不同步、不猜测宿主尺寸**,直接走原有写入。
|
|
5411
|
+
const appFrameWidth = hostAppFrameWidthOf();
|
|
5412
|
+
if (appFrameWidth !== null) {
|
|
5413
|
+
try { setViewportWidth?.call(panels, appFrameWidth); } catch { /* 校准不是展示侧栏的前置条件,失败时保留原有写入兼容路径。 */ }
|
|
5414
|
+
}
|
|
4513
5415
|
try {
|
|
4514
5416
|
// bound action 是闭包,不依赖 this;仍显式带上接收者,兼容将来改成方法式绑定。
|
|
4515
|
-
setRightbar.call(
|
|
5417
|
+
setRightbar.call(panels, target);
|
|
4516
5418
|
} catch {
|
|
4517
5419
|
// 调用失败不算写入成功:保持未记账(后续通知仍可重试),宿主当前宽度继续生效。
|
|
4518
5420
|
return false;
|
|
4519
5421
|
}
|
|
4520
5422
|
rightbarWidths.set(sessionId, target);
|
|
5423
|
+
// 连同「这次换算用的视口宽度」一起记账:它才是「宿主那份像素值是否还对应当前视口」的判据,
|
|
5424
|
+
// 也是常驻同步路径发现窗口最大化 / 全屏 / 拖拽后必须重新写入的唯一来源。
|
|
5425
|
+
rightbarWidthViewports.set(sessionId, viewportWidth);
|
|
4521
5426
|
// 写入成功即接管宿主那份全局宽度:旧会话的 transient 记账随归属一起失效(见 claim 说明)。
|
|
4522
5427
|
claimRightbarWidthOwnership(sessionId);
|
|
4523
5428
|
return true;
|
|
4524
5429
|
};
|
|
5430
|
+
/**
|
|
5431
|
+
* 某个会话「上次写入宽度时用的视口宽度」是否已经变了(窗口最大化 / 全屏 / 拖拽 / 旋转屏幕)。
|
|
5432
|
+
*
|
|
5433
|
+
* 右栏宽度是视口比例,视口一变,之前写下的像素值就不再代表 20% / 50%:必须按新视口重新
|
|
5434
|
+
* 换算并写入。这是常驻同步路径**唯一**的重新写入判据——同一视口下的重复通知仍然一个字节
|
|
5435
|
+
* 都不写(保留「宿主里那份值可能是用户拖拽结果」的原有去重语义)。
|
|
5436
|
+
*
|
|
5437
|
+
* 没有写过宽度(没有记账)时返回 false:那种情况由「首次接管 / 从其它分页返回」等既有
|
|
5438
|
+
* 分支负责写入,不在这里重复承担。
|
|
5439
|
+
*/
|
|
5440
|
+
const rightbarWidthViewportChanged = (sessionId) => {
|
|
5441
|
+
if (sessionId === null || sessionId === undefined) return false;
|
|
5442
|
+
const recorded = rightbarWidthViewports.get(sessionId);
|
|
5443
|
+
if (recorded === undefined) return false;
|
|
5444
|
+
const viewportWidth = viewportWidthOf();
|
|
5445
|
+
if (viewportWidth === null) return false;
|
|
5446
|
+
return recorded !== viewportWidth;
|
|
5447
|
+
};
|
|
5448
|
+
/**
|
|
5449
|
+
* 读当前会话对应右栏面板的**实际渲染宽度**(px)。
|
|
5450
|
+
*
|
|
5451
|
+
* 这是「宿主里那份宽度到底是谁写的」唯一可验证的公开证据:只读
|
|
5452
|
+
* [data-sidebar-right-panel] 与 getBoundingClientRect(),不注册 class、不读任何私有状态。
|
|
5453
|
+
*
|
|
5454
|
+
* 一律 fail-closed(返回 null):面板归属无法精确解析(零个 / 多个 / 缺
|
|
5455
|
+
* data-sidebar-right-session)、没有 getBoundingClientRect、矩形读不出来、宽度不是有限正数
|
|
5456
|
+
* (null / 0 / NaN / 负值 / 非数字)——这些都不构成「宽度等于某个值」的证据,调用方必须保留
|
|
5457
|
+
* 宿主当前值,等后续可验证的同步。
|
|
5458
|
+
*/
|
|
5459
|
+
const readRightbarPanelWidth = (sessionId) => {
|
|
5460
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
5461
|
+
let panel = null;
|
|
5462
|
+
try { panel = rightbarPanelForSession(globalThis.document, sessionId); } catch { return null; }
|
|
5463
|
+
if (panel === null || panel === undefined) return null;
|
|
5464
|
+
let rect = null;
|
|
5465
|
+
try {
|
|
5466
|
+
rect = typeof panel.getBoundingClientRect === "function" ? panel.getBoundingClientRect() : null;
|
|
5467
|
+
} catch { return null; }
|
|
5468
|
+
if (rect === null || typeof rect !== "object") return null;
|
|
5469
|
+
let width = null;
|
|
5470
|
+
try { width = Number(rect.width); } catch { return null; }
|
|
5471
|
+
if (Number.isFinite(width) !== true || width <= 0) return null;
|
|
5472
|
+
return width;
|
|
5473
|
+
};
|
|
5474
|
+
/**
|
|
5475
|
+
* 宿主里那份右栏宽度是否**仍是插件上一次成功写入的 target**(见 rightbarWidths)。
|
|
5476
|
+
*
|
|
5477
|
+
* 为什么需要它:other 模式的宽度是**可调**的,用户在其它右侧分页上拖出的宽度由宿主持有。
|
|
5478
|
+
* 只有「面板实际渲染宽度 == 插件上次写的 px」才能证明宿主里那份值仍是插件写的(或用户恰好
|
|
5479
|
+
* 拖回了同一个值,等价于没有改动),此时按模式重算视口才不会覆盖用户的拖拽结果。
|
|
5480
|
+
*
|
|
5481
|
+
* 任何一环读不出来(没有上次 target / 面板宽度读不到 / 宽度对不上)都返回 false:宁可这一次
|
|
5482
|
+
* 不重算,也绝不猜测写入——宿主当前值原样保留,后续 confirmed / other 分支仍会按证据重估。
|
|
5483
|
+
*/
|
|
5484
|
+
const hostWidthMatchesPluginTarget = (sessionId) => {
|
|
5485
|
+
const ownership = rightbarOwnershipOf(sessionId);
|
|
5486
|
+
return ownership !== null && ownership.matches === true;
|
|
5487
|
+
};
|
|
5488
|
+
/**
|
|
5489
|
+
* 一次性读出「宿主那份宽度 vs 插件 target」的**所有权观测**;读不出来时返回 null(fail-closed)。
|
|
5490
|
+
*
|
|
5491
|
+
* 返回 { target, actualWidth, matches, differs }:
|
|
5492
|
+
* - matches:面板实际渲染宽度仍等于插件上次写入的 target(所有权仍在插件手里);
|
|
5493
|
+
* - differs:面板实际渲染宽度明确不等于 target(宿主夺回了那份 px)。
|
|
5494
|
+
*
|
|
5495
|
+
* 为什么合成一个函数:同一轮同步里既要判 matches 又要判 differs,还会消费恢复预算,分开调用会
|
|
5496
|
+
* 把同一份 DOM 证据读两遍(readRightbarPanelWidth → getBoundingClientRect 会触发布局)。
|
|
5497
|
+
* 任何一环读不出来(没有 target / 面板定位不到 / 宽度不是有限正数)一律返回 null——读不出来就
|
|
5498
|
+
* 不构成任何证据,调用方必须原样保留宿主当前值。
|
|
5499
|
+
*/
|
|
5500
|
+
const rightbarOwnershipOf = (sessionId) => {
|
|
5501
|
+
const target = rightbarWidths.get(sessionId);
|
|
5502
|
+
if (typeof target !== "number" || Number.isFinite(target) !== true) return null;
|
|
5503
|
+
const actualWidth = readRightbarPanelWidth(sessionId);
|
|
5504
|
+
if (actualWidth === null) return null;
|
|
5505
|
+
const delta = Math.abs(actualWidth - target);
|
|
5506
|
+
return {
|
|
5507
|
+
target,
|
|
5508
|
+
actualWidth,
|
|
5509
|
+
matches: delta <= RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX,
|
|
5510
|
+
differs: delta > RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX,
|
|
5511
|
+
};
|
|
5512
|
+
};
|
|
5513
|
+
/**
|
|
5514
|
+
* 消费一次「所有权观测」:观测到收敛(实际宽度 == target)就清掉恢复事件记账。
|
|
5515
|
+
*
|
|
5516
|
+
* 这正是「状态清除」的唯一判据:宿主终于把 target 提交到 DOM 上之后,下一次 mismatch 必须是
|
|
5517
|
+
* **新的一次恢复事件**(重新获得完整预算),而不是被上一次已经用尽的预算永久挡住。
|
|
5518
|
+
* 读不出观测(null)时什么都不做——没有证据就不清除,保持原有记账。
|
|
5519
|
+
*/
|
|
5520
|
+
const settleRightbarRecovery = (sessionId, ownership) => {
|
|
5521
|
+
if (ownership !== null && ownership.matches === true) clearRightbarWidthRecovery(sessionId);
|
|
5522
|
+
};
|
|
5523
|
+
/**
|
|
5524
|
+
* 恢复 / 视口重写的**统一入口**:所有「实际宽度明确偏离当前视口预期 target」的写入都走这里。
|
|
5525
|
+
*
|
|
5526
|
+
* 为什么必须统一:恢复写入的判据散落在两条分支上(confirmed 分支与 recomputeOwnedWidth 的
|
|
5527
|
+
* unknown 分支)时,只要其中一条把「视口变化」当成**独立**边沿、不消费预算,宿主持续 clamp 的
|
|
5528
|
+
* 场景就能被无限绕过——视口从 3200 挪到 3201 / 3202,20% 取整后仍是同一个 target 640,
|
|
5529
|
+
* 每一次视口变化都变成「一次新的恢复事件」,用户轻微拖动窗口边缘就会与宿主的 clamp 无限拉锯。
|
|
5530
|
+
* 因此两条分支共用这一份判定(per-session,按预期 target + 观测到的实际宽度记账)。
|
|
5531
|
+
*
|
|
5532
|
+
* 三条口径:
|
|
5533
|
+
* - **没有公开 DOM 证据(ownership 为 null:面板定位不到,或 getBoundingClientRect().width
|
|
5534
|
+
* 不是有限正数)就是不写**:恢复 / 视口重算入口一律 fail-closed,一个字节都不写,既不消费
|
|
5535
|
+
* mismatch 预算,也绝不新建 / 重置一次 mismatch 事件。右栏宽度是宿主 layout store 里的**全局**
|
|
5536
|
+
* 偏好,插件只在能证明「这份 px 现在是谁的」时才允许改写它;读面不可用期间没有任何证据说明
|
|
5537
|
+
* 面板宽度偏离了 target,写下去就是无据改写宿主状态(而且是每次视口变化 / 通知 / 轮询都写的
|
|
5538
|
+
* 无界改写)。宿主那份值原样保留,等后续**可验证**的同步(readRightbarPanelWidth 恢复可读)——
|
|
5539
|
+
* 这条收紧只作用于恢复 / 视口重算入口,首次接管、从其它分页返回、分栏事务收口等正常写入
|
|
5540
|
+
* 语义完全不受影响;
|
|
5541
|
+
* - 实际宽度已经等于**当前视口下预期**的 target:这不是 mismatch,只是视口记账需要更新(例如
|
|
5542
|
+
* 宿主已经自己落到了正确宽度),正常写一次即可,不消费预算;
|
|
5543
|
+
* - 实际宽度明确偏离预期 target:同一个 (预期 target, 实际宽度) 的写入共用 per-session 预算,
|
|
5544
|
+
* 用尽后一律拒绝;预期 target 真正改变(窗口最大化导致取整结果变化)时是一次**新事件**,
|
|
5545
|
+
* 重新获得完整预算。
|
|
5546
|
+
*
|
|
5547
|
+
* 调用方负责「该不该尝试写入」的边沿判定(视口变化 / 宿主夺回宽度 / 模式证据),这里只负责
|
|
5548
|
+
* 「这次写入允不允许」:不读任何私有状态、不新增轮询或定时器。
|
|
5549
|
+
*/
|
|
5550
|
+
const recoverRightbarWidth = (sessionId, ratio, ownership) => {
|
|
5551
|
+
if (sessionId === null || sessionId === undefined) return false;
|
|
5552
|
+
// 没有任何公开 DOM 证据:直接 fail-closed。这里刻意不调用 applyRightbarWidth(更不带 force),
|
|
5553
|
+
// 因此既不会写宿主宽度,也不会触碰恢复预算记账(不新建、不重置、不消费)。
|
|
5554
|
+
if (ownership === null || ownership === undefined) return false;
|
|
5555
|
+
// 预期 target 必须与 applyRightbarWidth 用同一套换算(同一视口宽度 × 同一比例、同样取整),
|
|
5556
|
+
// 否则事件同一性会把「同一份预期值」判成两次不同事件。
|
|
5557
|
+
const viewportWidth = viewportWidthOf();
|
|
5558
|
+
if (viewportWidth === null) return false;
|
|
5559
|
+
const expectedTarget = Math.round(viewportWidth * ratio);
|
|
5560
|
+
if (Math.abs(ownership.actualWidth - expectedTarget) <= RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX) {
|
|
5561
|
+
// 实际宽度已经等于预期 target:只是视口记账需要更新,不属于 mismatch,不消费预算。
|
|
5562
|
+
return applyRightbarWidth(sessionId, ratio, true) === true;
|
|
5563
|
+
}
|
|
5564
|
+
if (allowRightbarRecoveryWrite(sessionId, expectedTarget, ownership.actualWidth) !== true) return false;
|
|
5565
|
+
return applyRightbarWidth(sessionId, ratio, true) === true;
|
|
5566
|
+
};
|
|
5567
|
+
/**
|
|
5568
|
+
* 组成证据为 unknown 时,按**本会话已经证明过的宽度模式**重算「视口变化 / 宿主夺回宽度」。
|
|
5569
|
+
*
|
|
5570
|
+
* 两条触发边沿(都只作用于 expert 模式,见下面的 other 分支说明):
|
|
5571
|
+
* 1. 视口宽度变了(窗口最大化 / 全屏 / 拖拽窗口边缘):那份 px 不再代表原来的比例;
|
|
5572
|
+
* 2. **宿主夺回了那份 px**(面板实际渲染宽度 != 插件上次写入的 target):DSH 重启后宿主从
|
|
5573
|
+
* 持久化布局恢复它自己那份旧值的时序正是「视口没变但那份值已经不是插件的」,
|
|
5574
|
+
* 只按视口判据永远等不到重算(见 rightbarOwnershipOf 的 differs)。
|
|
5575
|
+
*
|
|
5576
|
+
* 为什么「面板宽度 != 插件 target」就足以证明宿主夺回了所有权(只在 expert 模式消费):
|
|
5577
|
+
* Expert Group 页的手柄由插件锁住(rightbarDragLock),用户在这一页**拖不动**右栏;而 20%
|
|
5578
|
+
* 又是插件独占的固定比例,不存在「用户在该页自定义宽度」的语义。因此这一页上面板宽度偏离
|
|
5579
|
+
* target 只有两种可能:宿主自己重渲染回了它那份持久化值,或用户恰好拖到了同一个值(等价于
|
|
5580
|
+
* 没有改动)。两种情形都应当把 20% 写回去——但**必须有界**(见 recoverRightbarWidth)。
|
|
5581
|
+
*
|
|
5582
|
+
* 为什么 unknown 仍然可以重算:组成读面(inventory / DOM)回答的是「当前右栏里有哪些页」,
|
|
5583
|
+
* 而这里消费的是本会话**自己**的宽度记账——它记的是「宿主那份 px 是按哪个比例、哪个视口
|
|
5584
|
+
* 写下去的」,与组成读面无关。视口一变(窗口最大化 / 全屏 / 拖拽窗口边缘),那份 px 就不再
|
|
5585
|
+
* 代表原来的比例(实测:1600 视口写 320,最大化到 3200 后仍是 320,而正确值是 640),因此
|
|
5586
|
+
* 必须按同一比例重算;否则读面暂不可用的宿主会一直停在过期宽度上。
|
|
5587
|
+
*
|
|
5588
|
+
* fail-closed 边界(绝不放松):
|
|
5589
|
+
* - 没有任何模式证据(mode 为空且 prepared/active 都没有)时返回 false,一个字节都不写;
|
|
5590
|
+
* 组成读面不可用时**绝不臆测** 20% 或 50%,也绝不把 unknown 当成 confirmed;
|
|
5591
|
+
* - **没有宽度 DOM 证据(面板宽度读不出来)时同样一个字节都不写**:expert 模式虽然不要求
|
|
5592
|
+
* 「面板宽度仍等于插件 target」(那是 other 的可调保护),但**要求这条读面本身可用**——
|
|
5593
|
+
* ownership 为 null 时 recoverRightbarWidth 直接 fail-closed,既不写宽度也不动恢复预算
|
|
5594
|
+
* (见该函数第一条口径)。模式证据只回答「该写哪个比例」,不构成「可以无据改写宿主全局
|
|
5595
|
+
* 宽度」的证据;
|
|
5596
|
+
* - **other 模式额外要求宿主宽度所有权**:other 的 50% 是可调状态,用户可以在该页手动拖宽,
|
|
5597
|
+
* 仅凭「模式是 other」就重算会覆盖用户的拖拽结果。因此只有 hostWidthMatchesPluginTarget
|
|
5598
|
+
* 证明宿主里那份 px 仍是插件上次写的 target 时才重算;读不到或对不上一律不写(见该函数说明)。
|
|
5599
|
+
* expert 模式不受此限制:20% 是插件**独占**的固定比例,不存在「用户在该页拖宽」的语义;
|
|
5600
|
+
* - **other 模式只认视口变化这一条边沿**:宿主夺回宽度在 other 模式里正是「用户拖过一次」
|
|
5601
|
+
* 的典型形状,据此重算会覆盖用户的拖拽结果,因此那一模式绝不消费 hostReclaimed;
|
|
5602
|
+
* other 的视口重算还额外要求 hostWidthMatchesPluginTarget(实际宽度仍等于插件上次的 target),
|
|
5603
|
+
* 这条真实用户拖拽保护不受本预算逻辑影响,绝不放宽;
|
|
5604
|
+
* - expert 模式去重:视口没变、且宿主那份值仍等于插件 target 时什么都不做;
|
|
5605
|
+
* - 写入失败由 applyRightbarWidth 自己保证不记账,后续通知仍可重试;
|
|
5606
|
+
* - 手柄锁与组合归属语义完全不变:本函数不碰 rightbarDragLock,也不改写任何 transient 记账
|
|
5607
|
+
* (prepared / active 保持原样,因此下一次视口变化仍然有据可依)。
|
|
5608
|
+
*/
|
|
5609
|
+
const recomputeOwnedWidth = (sessionId) => {
|
|
5610
|
+
if (sessionId === null || sessionId === undefined) return false;
|
|
5611
|
+
const viewportChanged = rightbarWidthViewportChanged(sessionId) === true;
|
|
5612
|
+
// expert 模式下还有第二条触发边沿:宿主已经夺回那份 px(面板宽度不再等于插件 target)。
|
|
5613
|
+
// DSH 重启后宿主恢复持久化旧值的时序正是「视口没变但那份值已经不是插件的」,只按视口判据
|
|
5614
|
+
// 永远等不到重算。一次面板读数同时给出 matches / differs 两份结论(见 rightbarOwnershipOf)。
|
|
5615
|
+
const ownership = rightbarOwnershipOf(sessionId);
|
|
5616
|
+
// 收敛(实际宽度 == target)即清除恢复事件记账:下一次 mismatch 必须是新事件。
|
|
5617
|
+
settleRightbarRecovery(sessionId, ownership);
|
|
5618
|
+
const hostReclaimed = ownership !== null && ownership.differs === true;
|
|
5619
|
+
if (viewportChanged !== true && hostReclaimed !== true) return false;
|
|
5620
|
+
const mode = widthModeOf(sessionId);
|
|
5621
|
+
// 只有已确认的模式才给出比例:expert = 20%,other = 50%。split-pending 不会走到这里
|
|
5622
|
+
// (在途事务在 rightbarDragSyncImpl 里更早 return,收口时也必然被改写成 expert/other/null),
|
|
5623
|
+
// 万一出现也一律不猜。
|
|
5624
|
+
let ratio = null;
|
|
5625
|
+
if (mode === "expert") {
|
|
5626
|
+
// 20% 是插件独占的固定比例:视口变化与「宿主夺回宽度」两条边沿都必须重算。
|
|
5627
|
+
ratio = RIGHTBAR_WIDTH_RATIO;
|
|
5628
|
+
} else if (mode === "other") {
|
|
5629
|
+
// other 是可调模式,**只认视口变化这一条边沿**:
|
|
5630
|
+
// - 宿主夺回宽度(面板宽度 != 插件 target)在 other 模式里恰恰是「用户拖过一次」的
|
|
5631
|
+
// 典型形状,绝不能据此重算,否则会把用户的拖拽结果覆盖成 50%;
|
|
5632
|
+
// - 视口变化本身也必须先证明所有权(面板宽度仍等于插件 target)才允许重算;
|
|
5633
|
+
// 读不到面板宽度时所有权不成立 → 一个字节都不写,用户的宽度原样保留。
|
|
5634
|
+
if (viewportChanged !== true) return false;
|
|
5635
|
+
if (ownership === null || ownership.matches !== true) return false;
|
|
5636
|
+
ratio = OTHER_RIGHTBAR_WIDTH_RATIO;
|
|
5637
|
+
} else if (mode === null && (isWidthPrepared(sessionId) || isWidthActive(sessionId))) {
|
|
5638
|
+
// prepared / active 只由**成功的 20% 写入**置位(openTargets 预置,或 confirmed 分支 /
|
|
5639
|
+
// 事务收口时按 20% 恢复成功),因此它们证明的模式就是 expert;这里绝不据此推断 50%。
|
|
5640
|
+
ratio = RIGHTBAR_WIDTH_RATIO;
|
|
5641
|
+
}
|
|
5642
|
+
if (ratio === null) return false;
|
|
5643
|
+
// 写入判据与 confirmed 分支**共用**同一份有界预算(见 recoverRightbarWidth):视口变化本身不再
|
|
5644
|
+
// 自动等于「可以再写一次」——取整后仍是同一个 target 的视口变化不是新的 mismatch 事件,
|
|
5645
|
+
// 否则宿主持续 clamp 时轻微拖动窗口边缘就能无限重写。
|
|
5646
|
+
return recoverRightbarWidth(sessionId, ratio, ownership) === true;
|
|
5647
|
+
};
|
|
4525
5648
|
/**
|
|
4526
5649
|
* 自动展开右侧栏:按 surface 会话隔离 + 异步延迟执行。
|
|
4527
5650
|
*
|
|
@@ -5417,7 +6540,13 @@
|
|
|
5417
6540
|
// (isActiveOnlySplitCompatible),那个例外必须单独判定,绝不与这里共用 fail-open。
|
|
5418
6541
|
evidence = "confirmed";
|
|
5419
6542
|
}
|
|
5420
|
-
if (evidence === "unknown")
|
|
6543
|
+
if (evidence === "unknown") {
|
|
6544
|
+
// 组成不可判定 → 绝不改手柄锁、也绝不臆测模式(fail-closed 语义不变);但本会话
|
|
6545
|
+
// **已经接管过**的宽度模式与组成读面无关,视口变了(或宿主重启后夺回了那份 px)就必须按
|
|
6546
|
+
// 同一比例重算,否则窗口最大化后宿主会一直保留旧视口下的 px 偏好(见 recomputeOwnedWidth)。
|
|
6547
|
+
recomputeOwnedWidth(current);
|
|
6548
|
+
return;
|
|
6549
|
+
}
|
|
5421
6550
|
if (evidence === "confirmed") {
|
|
5422
6551
|
// openTargets 已在同一同步栈为这个 surface 成功预置过 20% 时,只消费 prepared 标记;
|
|
5423
6552
|
// 否则这是从其它可调分页重新进入 Expert Group,必须强制恢复 20%。只有宽度已经成功
|
|
@@ -5429,6 +6558,33 @@
|
|
|
5429
6558
|
if (!widthReady) {
|
|
5430
6559
|
// 归属由写入路径接管(applyRightbarWidth → claimRightbarWidthOwnership)。
|
|
5431
6560
|
widthReady = applyRightbarWidth(current, RIGHTBAR_WIDTH_RATIO, true);
|
|
6561
|
+
} else {
|
|
6562
|
+
const viewportChanged = rightbarWidthViewportChanged(current) === true;
|
|
6563
|
+
// 一次面板读数同时给出 matches / differs 两份结论(见 rightbarOwnershipOf)。
|
|
6564
|
+
const ownership = rightbarOwnershipOf(current);
|
|
6565
|
+
// 收敛(实际宽度 == target)即清除恢复事件记账:下一次 mismatch 必须是新事件。
|
|
6566
|
+
settleRightbarRecovery(current, ownership);
|
|
6567
|
+
const hostReclaimed = ownership !== null && ownership.differs === true;
|
|
6568
|
+
if (viewportChanged || hostReclaimed) {
|
|
6569
|
+
// 两条重新写入边沿,缺一不可:
|
|
6570
|
+
// 1. 窗口最大化 / 全屏 / 拖拽改变了视口:20% 是**视口比例**,必须按新视口重新换算并写入,
|
|
6571
|
+
// 否则右栏会一直停在最大化之前那份旧像素宽度上(1600 写 320,3200 仍是 320);
|
|
6572
|
+
// 2. **宿主夺回了那份 px**:DSH 重启后宿主从持久化布局恢复它自己那份旧值,而这份恢复
|
|
6573
|
+
// 晚于插件的首次写入(视口一直没变)。Expert Group 页的手柄被插件锁住、20% 又是插件
|
|
6574
|
+
// 独占比例,因此「面板宽度不再等于插件 target」就是宿主夺回所有权的可验证证据,必须
|
|
6575
|
+
// 把 20% 重新写回去,否则用户看到的是「重启后右栏从 640 缩到 320」。
|
|
6576
|
+
// 两条判据都为假时一个字节都不写:同一视口的重复通知仍然保持原有去重语义。
|
|
6577
|
+
//
|
|
6578
|
+
// 写入许可与 recomputeOwnedWidth(unknown 分支)**共用**同一份有界预算:视口变化不再是
|
|
6579
|
+
// 绕过预算的独立边沿——取整后仍是同一个 target 的视口变化(3200 → 3201 → 3202 都是 640)
|
|
6580
|
+
// 不是新的一次 mismatch 事件;只有预期 target 真的改变才重新获得预算。宿主持续 clamp 或
|
|
6581
|
+
// React 延迟提交时「实际宽度 != target」会一直为真,否则就会与宿主的 clamp 无限拉锯。
|
|
6582
|
+
//
|
|
6583
|
+
// 这条边沿同样要过证据关:面板宽度读不出来(ownership 为 null)时 recoverRightbarWidth
|
|
6584
|
+
// 直接 fail-closed——即使视口真的变了,这里也一个字节都不写(读面不可用期间没有任何证据
|
|
6585
|
+
// 说明面板宽度偏离了 target,写下去就是无据改写宿主那份**全局**宽度偏好)。
|
|
6586
|
+
recoverRightbarWidth(current, RIGHTBAR_WIDTH_RATIO, ownership);
|
|
6587
|
+
}
|
|
5432
6588
|
}
|
|
5433
6589
|
setWidthPrepared(current, false);
|
|
5434
6590
|
setWidthActive(current, widthReady);
|
|
@@ -5445,10 +6601,19 @@
|
|
|
5445
6601
|
// 第一次确认任一 pane 存在其它页时把总右栏预置到 50%;随后只交还手柄,用户拖动出的
|
|
5446
6602
|
// 宽度由宿主持有。重复通知不会重写 50%(只按当前会话的 other 模式去重),宽度桥接失败
|
|
5447
6603
|
// 则保持模式未提交,便于以后重试。
|
|
5448
|
-
|
|
6604
|
+
const otherModeMissing = widthModeOf(current) !== "other";
|
|
6605
|
+
// 「首次确认其它页」这条边沿无条件预置 50%;视口变化触发的重算则**必须先证明宿主宽度所有权**
|
|
6606
|
+
// (面板实际宽度仍等于插件上次写入的 target)。50% 是可调状态,用户在其它右侧分页上拖出的
|
|
6607
|
+
// 宽度由宿主持有,仅凭「视口变了」就重算会直接覆盖用户的拖拽结果——这与 unknown 分支里的
|
|
6608
|
+
// 同一判据(见 recomputeOwnedWidth)保持完全一致的口径。
|
|
6609
|
+
const otherNeedsRecompute = otherModeMissing
|
|
6610
|
+
|| (rightbarWidthViewportChanged(current) && hostWidthMatchesPluginTarget(current));
|
|
6611
|
+
if (otherNeedsRecompute) {
|
|
6612
|
+
// 写入失败时只在「首次确认其它页」这条边沿把模式置回未提交,视口重算失败不得抹掉
|
|
6613
|
+
// 已经确认的 other 模式。
|
|
5449
6614
|
if (applyRightbarWidth(current, OTHER_RIGHTBAR_WIDTH_RATIO, true)) {
|
|
5450
6615
|
setWidthMode(current, "other");
|
|
5451
|
-
} else {
|
|
6616
|
+
} else if (otherModeMissing) {
|
|
5452
6617
|
setWidthMode(current, null);
|
|
5453
6618
|
}
|
|
5454
6619
|
}
|
|
@@ -5752,6 +6917,49 @@
|
|
|
5752
6917
|
}
|
|
5753
6918
|
openTargets();
|
|
5754
6919
|
}
|
|
6920
|
+
/**
|
|
6921
|
+
* 视口尺寸变化(窗口最大化 / 全屏 / 拖拽窗口边缘 / 旋转屏幕)后重新换算右栏宽度。
|
|
6922
|
+
*
|
|
6923
|
+
* 右栏宽度是**视口比例**(Expert Group 页 20%、其它右侧分页 50%),但写进宿主 layout store
|
|
6924
|
+
* 的是像素值。没有这条边沿,窗口最大化后宿主会一直沿用最大化之前那份旧像素宽度,比例不再
|
|
6925
|
+
* 成立(1600 视口写 320,最大化到 3200 后仍是 320,而正确值是 640)。
|
|
6926
|
+
*
|
|
6927
|
+
* 四条约束:
|
|
6928
|
+
* - 只读公开的 window 事件,且全程按**可选 API** 处理:没有 window、没有 addEventListener、
|
|
6929
|
+
* 代理读取抛错时静默降级为「只靠既有 observer + 2.5 秒轮询兜底」——那条链路同样会靠
|
|
6930
|
+
* 视口记账发现变化并补写(见 rightbarWidthViewportChanged),因此老宿主不会失效;
|
|
6931
|
+
* - **add 与 remove 必须同时存在**才注册:宿主只有 addEventListener 时(旧 WebView、被冻结
|
|
6932
|
+
* 的事件面),注册会成功而 dispose 的注销永远失败,那次 install 的闭包就被永久留在宿主
|
|
6933
|
+
* window 上——每装一次泄漏一个,卸载后仍会被 resize 事件唤醒。这种宿主整条放弃快路径,
|
|
6934
|
+
* 同样退回上面那条 observer + 2.5 秒轮询兜底,功能不缺失;
|
|
6935
|
+
* - 监听在 install 注册、dispose 移除(走统一的 disposers),绝不在宿主上留下长期监听;
|
|
6936
|
+
* - 高频 resize 只在**视口宽度真的变了**时才请求同步:同一宽度的连发事件直接短路,既不做
|
|
6937
|
+
* 无谓的组成读面求值,也让行为保持确定性(同步栈内立即生效,不需要推进任何定时器)。
|
|
6938
|
+
*/
|
|
6939
|
+
let lastObservedViewportWidth = viewportWidthOf();
|
|
6940
|
+
const onViewportResize = () => {
|
|
6941
|
+
if (disposed) return;
|
|
6942
|
+
const viewportWidth = viewportWidthOf();
|
|
6943
|
+
if (viewportWidth === null) return;
|
|
6944
|
+
if (lastObservedViewportWidth === viewportWidth) return;
|
|
6945
|
+
lastObservedViewportWidth = viewportWidth;
|
|
6946
|
+
// 复用既有的宽度/手柄/配比同步链路:不新开一条并行路径,也不直接在这里写宽度。
|
|
6947
|
+
requestRightbarDragSync();
|
|
6948
|
+
};
|
|
6949
|
+
try {
|
|
6950
|
+
const viewportTarget = globalThis.window ?? null;
|
|
6951
|
+
// 注册条件必须**同时**要求 add 与 remove:宿主只有 addEventListener 时注册会成功、
|
|
6952
|
+
// dispose 的注销却永远失败,闭包被永久留在宿主 window 上(每次安装泄漏一个,且卸载后
|
|
6953
|
+
// 仍会被 resize 事件唤醒)。宁可少一条快路径,也绝不在宿主上留下摘不掉的监听——
|
|
6954
|
+
// 缺少任一 API 时完全依赖既有的 observer + 2.5 秒轮询兜底,它们同样按视口记账补写。
|
|
6955
|
+
if (typeof viewportTarget?.addEventListener === "function"
|
|
6956
|
+
&& typeof viewportTarget?.removeEventListener === "function") {
|
|
6957
|
+
viewportTarget.addEventListener("resize", onViewportResize);
|
|
6958
|
+
disposers.push(() => {
|
|
6959
|
+
try { viewportTarget.removeEventListener("resize", onViewportResize); } catch { /* 注销抛错的宿主只能放弃这条监听。 */ }
|
|
6960
|
+
});
|
|
6961
|
+
}
|
|
6962
|
+
} catch { /* 没有 window 或代理读取抛错:只靠既有 observer 与 2.5 秒轮询兜底。 */ }
|
|
5755
6963
|
// 到这里 install 函数体里的全部读面(readSurfaceOpenTabs / currentSurfaceSessionId /
|
|
5756
6964
|
// rightbarDragSyncImpl)都已初始化完成:接线配比读面,后续每一轮同步都能安全求值。
|
|
5757
6965
|
// 刻意排在 install 函数体的**最后**:任何一次同步(包括下面这次立即求值)都要等这些读面
|
|
@@ -5761,8 +6969,16 @@
|
|
|
5761
6969
|
/**
|
|
5762
6970
|
* 读当前 surface 那条 surface 的分栏容器:session → panel → split 三级读面都必须精确唯一。
|
|
5763
6971
|
*
|
|
5764
|
-
*
|
|
5765
|
-
*
|
|
6972
|
+
* 返回 { status, container }:
|
|
6973
|
+
* - "target":组成可证明,容器就是本次要施加配比的那一个;
|
|
6974
|
+
* - "pending":结构上仍是两列、列 0 明确是本会话的 Expert Group 页,但列 1 的内容这一轮
|
|
6975
|
+
* 读不出来(重启恢复窗口:tab 条 / inventory 归属还没提交)。容器照常返回,调用方据此
|
|
6976
|
+
* 保持已写下的配比并短延迟重试——这正是「不闪回宿主默认值」的唯一来源;
|
|
6977
|
+
* - null:会话读不出来、inventory 不可用、没有属于本会话的 panel、panel 归属有歧义、
|
|
6978
|
+
* 或组成明确不是目标(单列 / 三列 / Expert Group 消失 / 归属冲突)。调用方交还宿主样式。
|
|
6979
|
+
*
|
|
6980
|
+
* fail-closed 边界不因 pending 放松:pending 只在列 0 的目标身份**已被证明**时才可能返回,
|
|
6981
|
+
* 任何未知归属、重复或混合列都仍然走 null(明确非目标),绝不靠猜测进入 pending。
|
|
5766
6982
|
*/
|
|
5767
6983
|
readTarget(doc) {
|
|
5768
6984
|
let sessionId = null;
|
|
@@ -5775,8 +6991,12 @@
|
|
|
5775
6991
|
const panel = rightbarPanelForSession(doc, sessionId);
|
|
5776
6992
|
if (panel === null) return null;
|
|
5777
6993
|
const container = resolveSplitContainer(panel, tabSets);
|
|
5778
|
-
if (container
|
|
5779
|
-
|
|
6994
|
+
if (container !== null) return { status: "target", container };
|
|
6995
|
+
// 两列结构仍在、列 0 仍是 Expert Group 页,只有列 1 的内容读不出来:暂不可读。
|
|
6996
|
+
// 单列 / 三列 / 混合列 / 重复列号等形状在这里都拿不到 "unavailable",一律交还宿主。
|
|
6997
|
+
const candidates = splitPairPendingContainers(panel, tabSets);
|
|
6998
|
+
if (candidates.length === 1) return { status: "pending", container: candidates[0] };
|
|
6999
|
+
return null;
|
|
5780
7000
|
},
|
|
5781
7001
|
};
|
|
5782
7002
|
// install 收尾立即求值一次:中段那一轮 openTargets 触发的同步当时读面还是 null(已跳过),
|
|
@@ -5806,10 +7026,14 @@
|
|
|
5806
7026
|
// 宿主原本的值与优先级,并置位 disposed(此后的迟到同步一律不再写入)。
|
|
5807
7027
|
splitTrackRatio.dispose();
|
|
5808
7028
|
rightbarWidths.clear();
|
|
7029
|
+
// 视口记账与 rightbarWidths 同属本次安装的目标值状态,一并清空(resize 监听已由 disposers 移除)。
|
|
7030
|
+
rightbarWidthViewports.clear();
|
|
5809
7031
|
// per-session 的 transient 记账(模式 / 预置 / 已确认活跃)同样只属于本次安装生命周期。
|
|
5810
7032
|
rightbarWidthModes.clear();
|
|
5811
7033
|
rightbarWidthPreparedSessions.clear();
|
|
5812
7034
|
rightbarWidthActiveSessions.clear();
|
|
7035
|
+
// 恢复事件记账(target / 观测到的实际宽度 / 已用预算)同样只属于本次安装生命周期。
|
|
7036
|
+
rightbarWidthRecoveries.clear();
|
|
5813
7037
|
for (let index = disposers.length - 1; index >= 0; index -= 1) {
|
|
5814
7038
|
try { disposers[index](); } catch { /* 一个 disposer 失败不能阻止其它所有权释放。 */ }
|
|
5815
7039
|
}
|
|
@@ -11237,7 +12461,7 @@ window.__ModuleLoader__.load({
|
|
|
11237
12461
|
{ id: "pendingSettingsWrites", label: "在途 settings 写入", path: ["settings", "pendingWrites"], kind: "count" },
|
|
11238
12462
|
{ id: "settingsGeneration", label: "模型目录代次", path: ["settings", "generation"], kind: "count" },
|
|
11239
12463
|
{ id: "storageGeneration", label: "storage generation", path: ["storage", "generation"], kind: "count" },
|
|
11240
|
-
{ id: "handoffPending", label: "
|
|
12464
|
+
{ id: "handoffPending", label: "pending admission", path: ["handoff", "pending"], kind: "count" },
|
|
11241
12465
|
{ id: "notificationRoots", label: "已登记通知根会话", path: ["notifications", "roots"], kind: "count" },
|
|
11242
12466
|
{ id: "fanoutRestorePending", label: "fan-out 待恢复", path: ["fanout", "restorePending"], kind: "flag" },
|
|
11243
12467
|
{ id: "storageStatus", label: "storage 状态", path: ["storage", "status"], kind: "token" },
|
|
@@ -11245,7 +12469,7 @@ window.__ModuleLoader__.load({
|
|
|
11245
12469
|
|
|
11246
12470
|
/** 宿主能力矩阵到客户端展示面的映射(状态语义统一为 available/degraded/absent/unknown)。 */
|
|
11247
12471
|
const HOST_CAPABILITY_FIELDS = Object.freeze([
|
|
11248
|
-
{ key: "
|
|
12472
|
+
{ key: "rootTurnWait", id: "host.rootTurnWait", label: "Root turn 等待(session-activity)" },
|
|
11249
12473
|
{ key: "connectionRpc", id: "host.connectionRpc", label: "Connection RPC 通道" },
|
|
11250
12474
|
{ key: "storageDomain", id: "host.storageDomain", label: "storage domain 绑定" },
|
|
11251
12475
|
{ key: "attachments", id: "host.attachments", label: "附件/图片规范化" },
|
|
@@ -11256,7 +12480,7 @@ window.__ModuleLoader__.load({
|
|
|
11256
12480
|
function hostCapabilityStatusOf(status) {
|
|
11257
12481
|
if (status === "available" || status === "ready") return "available";
|
|
11258
12482
|
if (status === "incomplete" || status === "degraded" || status === "fail-closed" || status === "legacy" || status === "partial") return "degraded";
|
|
11259
|
-
if (status === "absent" || status === "missing" || status === "none") return "absent";
|
|
12483
|
+
if (status === "absent" || status === "missing" || status === "none" || status === "unavailable") return "absent";
|
|
11260
12484
|
return "unknown";
|
|
11261
12485
|
}
|
|
11262
12486
|
|
|
@@ -11773,6 +12997,7 @@ window.__ModuleLoader__.load({
|
|
|
11773
12997
|
const [conflict, setConflict] = react.useState(false);
|
|
11774
12998
|
const [expandedRows, setExpandedRows] = react.useState(() => new Set());
|
|
11775
12999
|
const [fetchState, setFetchState] = react.useState(null);
|
|
13000
|
+
const [candidateQuery, setCandidateQuery] = react.useState("");
|
|
11776
13001
|
const [keyDraft, setKeyDraft] = react.useState("");
|
|
11777
13002
|
// createCardSession 是可变会话;版本号把会话草稿变化桥接到 React 渲染。
|
|
11778
13003
|
const [, setDraftRevision] = react.useState(0);
|
|
@@ -11860,6 +13085,7 @@ window.__ModuleLoader__.load({
|
|
|
11860
13085
|
session.cancel();
|
|
11861
13086
|
setExpandedRows(new Set());
|
|
11862
13087
|
setFetchState(null);
|
|
13088
|
+
setCandidateQuery("");
|
|
11863
13089
|
setError(null);
|
|
11864
13090
|
setSaved(false);
|
|
11865
13091
|
setConflict(false);
|
|
@@ -11924,8 +13150,13 @@ window.__ModuleLoader__.load({
|
|
|
11924
13150
|
}
|
|
11925
13151
|
};
|
|
11926
13152
|
const fetchModels = async () => {
|
|
13153
|
+
// 手动意图必须在 await 之前同步置位:正在途中的自动探测回调据此放弃写草稿,
|
|
13154
|
+
// 否则自动结果可能晚于这次点击返回,把完整目录直接写进草稿绕过用户选择。
|
|
13155
|
+
discoveryIntent.manualIntent = true;
|
|
11927
13156
|
setSaving(true);
|
|
11928
13157
|
setError(null);
|
|
13158
|
+
setFetchState(null);
|
|
13159
|
+
setCandidateQuery("");
|
|
11929
13160
|
try {
|
|
11930
13161
|
const draft = session.draft;
|
|
11931
13162
|
const baseURL = typeof draft.baseURL === "string" && draft.baseURL.length > 0 ? draft.baseURL : undefined;
|
|
@@ -11945,17 +13176,90 @@ window.__ModuleLoader__.load({
|
|
|
11945
13176
|
setFetchState({ error: "该端点没有返回任何模型", candidates: null });
|
|
11946
13177
|
return;
|
|
11947
13178
|
}
|
|
11948
|
-
|
|
13179
|
+
// 手动候选到达后先清掉"自动 fallback 写入且用户没改过"的行:这次手动发现
|
|
13180
|
+
// 取得候选选择权,自动填充的目录不得继续留在草稿里绕过用户勾选。
|
|
13181
|
+
// 用户在自动行上做过的修改(行内容已与快照不同)与其它手工行一律保留。
|
|
13182
|
+
const pruned = pruneUneditedAutoFallbackRows();
|
|
13183
|
+
if (pruned !== null) {
|
|
13184
|
+
session.edit((draft) => ({ ...draft, models: pruned }));
|
|
13185
|
+
setDraftRevision((value) => value + 1);
|
|
13186
|
+
setSaved(false);
|
|
13187
|
+
}
|
|
13188
|
+
// known 按清理后的草稿计算:被清掉的自动行不再算"已存在",手动候选里同 id 的条目
|
|
13189
|
+
// 因此会作为可勾选候选出现,而不是被当成已有行静默跳过。
|
|
13190
|
+
const known = new Set(session.modelRows
|
|
13191
|
+
.map((model) => (typeof model.id === "string" ? model.id.trim() : ""))
|
|
13192
|
+
.filter((id) => id.length > 0));
|
|
13193
|
+
setFetchState({
|
|
13194
|
+
error: null,
|
|
13195
|
+
candidates: answer.models.map((model) => ({
|
|
13196
|
+
...model,
|
|
13197
|
+
// 手动发现的候选默认只勾选"草稿里还没有"的模型;自动 fallback 已先写入的行
|
|
13198
|
+
// 在手动发现命中后(除非用户改过)已被上面清掉,因此这里会重新作为候选出现,
|
|
13199
|
+
// 由用户显式勾选决定是否保留,而不是被自动目录静默带入。
|
|
13200
|
+
picked: typeof model.id === "string" && model.id.trim().length > 0
|
|
13201
|
+
&& !known.has(model.id.trim()),
|
|
13202
|
+
})),
|
|
13203
|
+
});
|
|
11949
13204
|
} finally {
|
|
11950
13205
|
setSaving(false);
|
|
11951
13206
|
}
|
|
11952
13207
|
};
|
|
13208
|
+
/**
|
|
13209
|
+
* 自动 fallback 探测与手动「获取模型」之间的共享意图状态。
|
|
13210
|
+
*
|
|
13211
|
+
* 挂在稳定的 `session` 依赖的 useMemo 上:卡片重渲染不会重建它,因此晚到的自动探测
|
|
13212
|
+
* 回调看到的是**同一次**手动意图,而不是快照里的旧值。两个字段各司其职:
|
|
13213
|
+
* - `manualIntent`:用户点过「获取模型」就置位。自动探测晚于点击返回时必须放弃,
|
|
13214
|
+
* 绝不能把完整目录直接写进草稿(否则用户还没选模型就被隐式写入了目录)。
|
|
13215
|
+
* - `autoFallback`:最近一次自动探测写进草稿的行快照。手动候选到达后据此只清掉
|
|
13216
|
+
* "仍与该快照完全相同"的未编辑行,用户在自动 fallback 上做过的修改一律保留。
|
|
13217
|
+
*/
|
|
13218
|
+
const discoveryIntent = react.useMemo(() => ({ manualIntent: false, autoFallback: null }), [session]);
|
|
13219
|
+
/** 记录自动 fallback 快照(同一批自动行只记一次,供之后的手动探测做"未编辑"判定)。 */
|
|
13220
|
+
const noteAutoFallback = (models) => {
|
|
13221
|
+
if (discoveryIntent.autoFallback !== null) return;
|
|
13222
|
+
discoveryIntent.autoFallback = Array.isArray(models) ? models.map((model) => ({ ...model })) : [];
|
|
13223
|
+
};
|
|
13224
|
+
/** 草稿行是否与自动 fallback 快照里同 id 的行逐字段相同(即"未编辑的自动填充行")。 */
|
|
13225
|
+
const isUneditedAutoFallbackRow = (row) => {
|
|
13226
|
+
const snapshot = discoveryIntent.autoFallback;
|
|
13227
|
+
if (!Array.isArray(snapshot) || !config.isPlainObject(row)) return false;
|
|
13228
|
+
const id = typeof row.id === "string" ? row.id.trim() : "";
|
|
13229
|
+
if (id.length === 0) return false;
|
|
13230
|
+
return snapshot.some((entry) => {
|
|
13231
|
+
if (!config.isPlainObject(entry)) return false;
|
|
13232
|
+
const entryId = typeof entry.id === "string" ? entry.id.trim() : "";
|
|
13233
|
+
return entryId === id && config.jsonEqual(entry, row);
|
|
13234
|
+
});
|
|
13235
|
+
};
|
|
13236
|
+
/**
|
|
13237
|
+
* 手动候选到达后清理草稿:只移除仍与自动 fallback 快照完全相同的未编辑行。
|
|
13238
|
+
* 返回新行数组;没有任何行被移除时返回 null,调用方据此跳过无谓的草稿改写。
|
|
13239
|
+
*/
|
|
13240
|
+
const pruneUneditedAutoFallbackRows = () => {
|
|
13241
|
+
const snapshot = discoveryIntent.autoFallback;
|
|
13242
|
+
if (!Array.isArray(snapshot) || snapshot.length === 0) return null;
|
|
13243
|
+
const rows = session.modelRows;
|
|
13244
|
+
const kept = rows.filter((row) => !isUneditedAutoFallbackRow(row));
|
|
13245
|
+
return kept.length === rows.length ? null : kept;
|
|
13246
|
+
};
|
|
11953
13247
|
react.useEffect(() => {
|
|
11954
13248
|
if (!open || row.declared === true || namespace?.ns === "llm-deepseek") return undefined;
|
|
11955
13249
|
let active = true;
|
|
13250
|
+
// 每一轮自动探测都重置快照:新的自动结果必须重新记录,否则手动清理会拿旧快照做判定。
|
|
13251
|
+
discoveryIntent.autoFallback = null;
|
|
13252
|
+
// 手动意图只屏蔽"当前打开周期":走到这里说明已通过守卫、确定要发起一次全新的自动探测,
|
|
13253
|
+
// 所以清掉上一周期遗留的手动标记,卡片折叠后再展开才能恢复自动目录增强。
|
|
13254
|
+
// 同一打开周期内晚于手动点击返回的自动请求,仍会观察到 true 并放弃写草稿。
|
|
13255
|
+
discoveryIntent.manualIntent = false;
|
|
11956
13256
|
void host.operations.discoverModels(namespace.ns, { provider: row.provider }).then((answer) => {
|
|
13257
|
+
// 手动意图优先:用户已经点过「获取模型」时,这次自动结果不得写入草稿,
|
|
13258
|
+
// 由手动候选的选择流程独占草稿的模型目录。
|
|
13259
|
+
if (discoveryIntent.manualIntent) return;
|
|
11957
13260
|
if (!active || answer.kind !== "found" || answer.models.length === 0 || session.modelRows.length > 0) return;
|
|
11958
13261
|
session.setFallbackModels(answer.models);
|
|
13262
|
+
noteAutoFallback(session.modelRows);
|
|
11959
13263
|
setDraftRevision((value) => value + 1);
|
|
11960
13264
|
}).catch(() => {
|
|
11961
13265
|
// 自动目录探测是增强能力,失败时保留 settings 中的模型与手工编辑入口。
|
|
@@ -11963,14 +13267,49 @@ window.__ModuleLoader__.load({
|
|
|
11963
13267
|
return () => { active = false; };
|
|
11964
13268
|
}, [host, namespace?.ns, open, row.declared, row.provider, session]);
|
|
11965
13269
|
|
|
13270
|
+
const toggleCandidate = (id) => {
|
|
13271
|
+
setFetchState((previous) => previous === null || !Array.isArray(previous.candidates)
|
|
13272
|
+
? previous
|
|
13273
|
+
: {
|
|
13274
|
+
...previous,
|
|
13275
|
+
candidates: previous.candidates.map((candidate) => candidate.id === id
|
|
13276
|
+
? { ...candidate, picked: candidate.picked !== true }
|
|
13277
|
+
: candidate),
|
|
13278
|
+
});
|
|
13279
|
+
};
|
|
13280
|
+
/**
|
|
13281
|
+
* 弹窗「全选/取消全选」的唯一实现(弹窗按钮文案也走同一个纯函数判定)。
|
|
13282
|
+
*
|
|
13283
|
+
* - 可见项已全选 → 清空**全部**选择(含被搜索隐藏的候选),与 DSH 模型页一致;
|
|
13284
|
+
* - 否则只把可见项追加为已选,隐藏候选保留原选择。
|
|
13285
|
+
*/
|
|
13286
|
+
const toggleVisibleCandidates = (ids) => {
|
|
13287
|
+
const wanted = new Set(Array.isArray(ids) ? ids : []);
|
|
13288
|
+
setFetchState((previous) => {
|
|
13289
|
+
if (previous === null || !Array.isArray(previous.candidates)) return previous;
|
|
13290
|
+
if (clearsAllCandidateSelection(previous.candidates, wanted)) {
|
|
13291
|
+
return { ...previous, candidates: previous.candidates.map((candidate) => ({ ...candidate, picked: false })) };
|
|
13292
|
+
}
|
|
13293
|
+
return {
|
|
13294
|
+
...previous,
|
|
13295
|
+
candidates: previous.candidates.map((candidate) => wanted.has(candidate.id)
|
|
13296
|
+
? { ...candidate, picked: true }
|
|
13297
|
+
: candidate),
|
|
13298
|
+
};
|
|
13299
|
+
});
|
|
13300
|
+
};
|
|
13301
|
+
const closeModelPicker = () => {
|
|
13302
|
+
setFetchState(null);
|
|
13303
|
+
setCandidateQuery("");
|
|
13304
|
+
};
|
|
11966
13305
|
const adoptPicked = () => {
|
|
11967
|
-
const
|
|
11968
|
-
|
|
13306
|
+
const picked = (fetchState?.candidates ?? []).filter((candidate) => candidate.picked === true);
|
|
13307
|
+
if (picked.length === 0) return;
|
|
11969
13308
|
edit((draft) => ({
|
|
11970
13309
|
...draft,
|
|
11971
|
-
models: config.adoptModels(session.modelRows, picked
|
|
13310
|
+
models: config.adoptModels(session.modelRows, picked),
|
|
11972
13311
|
}));
|
|
11973
|
-
|
|
13312
|
+
closeModelPicker();
|
|
11974
13313
|
};
|
|
11975
13314
|
|
|
11976
13315
|
if (!config.providerEditable(row, namespace)) return null;
|
|
@@ -11990,13 +13329,13 @@ window.__ModuleLoader__.load({
|
|
|
11990
13329
|
style: { background: "var(--dsw-alias-state-success-primary,#22c55e)" },
|
|
11991
13330
|
})
|
|
11992
13331
|
: null,
|
|
11993
|
-
react.createElement("span", { className: "deg_mc_card_actions" },
|
|
13332
|
+
react.createElement("span", { className: "deg_mc_card_actions" },
|
|
11994
13333
|
react.createElement("button", {
|
|
11995
13334
|
type: "button",
|
|
11996
13335
|
className: "deg_btn",
|
|
11997
13336
|
"aria-label": card.displayName + " 编辑",
|
|
11998
13337
|
onClick: () => { setOpen(true); setError(null); setSaved(false); setConflict(false); },
|
|
11999
|
-
}, "编辑"))),
|
|
13338
|
+
}, "编辑"), removeAction)),
|
|
12000
13339
|
row.error === undefined ? null : react.createElement("p", { className: "deg_mc_error", role: "alert" }, row.error));
|
|
12001
13340
|
}
|
|
12002
13341
|
|
|
@@ -12100,44 +13439,12 @@ window.__ModuleLoader__.load({
|
|
|
12100
13439
|
onClick: () => edit((draft) => ({ ...draft, models: config.removeModelRow(session.modelRows, model.index) })),
|
|
12101
13440
|
}, "✕")), advancedSectionOf(model)));
|
|
12102
13441
|
|
|
12103
|
-
|
|
12104
|
-
const fetchList = candidates === null
|
|
12105
|
-
? null
|
|
12106
|
-
: react.createElement("div", null,
|
|
12107
|
-
react.createElement("p", { className: "deg_mc_hint" }, "勾选要采纳的模型(已存在的行不会被覆盖):"),
|
|
12108
|
-
react.createElement("ul", { className: "deg_mc_fetch_list" },
|
|
12109
|
-
candidates.map((candidate) => react.createElement("li", {
|
|
12110
|
-
key: candidate.id,
|
|
12111
|
-
className: "deg_mc_fetch_item",
|
|
12112
|
-
}, react.createElement("label", { className: "deg_mc_effort" },
|
|
12113
|
-
react.createElement("input", {
|
|
12114
|
-
type: "checkbox",
|
|
12115
|
-
checked: candidate.picked === true,
|
|
12116
|
-
disabled,
|
|
12117
|
-
"data-deg-candidate": candidate.id,
|
|
12118
|
-
onChange: () => setFetchState((previous) => ({
|
|
12119
|
-
...previous,
|
|
12120
|
-
candidates: previous.candidates.map((entry) => (entry.id === candidate.id
|
|
12121
|
-
? { ...entry, picked: entry.picked !== true }
|
|
12122
|
-
: entry)),
|
|
12123
|
-
})),
|
|
12124
|
-
}),
|
|
12125
|
-
react.createElement("span", null, candidate.id))))),
|
|
12126
|
-
react.createElement("div", { className: "deg_mc_conflict_actions" },
|
|
12127
|
-
react.createElement("button", { type: "button", className: "deg_btn deg_btn_primary", disabled, onClick: adoptPicked }, "采纳所选"),
|
|
12128
|
-
react.createElement("button", { type: "button", className: "deg_btn", disabled, onClick: () => setFetchState(null) }, "取消")));
|
|
13442
|
+
|
|
12129
13443
|
|
|
12130
13444
|
return react.createElement("li", { className: "deg_mc_card", "data-deg-provider": row.provider },
|
|
12131
13445
|
react.createElement("div", { className: "deg_mc_card_head" },
|
|
12132
13446
|
react.createElement("span", { className: "deg_mc_card_name" }, card.displayName),
|
|
12133
|
-
card.provider === card.displayName ? null : react.createElement("span", { className: "deg_mc_card_route" }, card.provider),
|
|
12134
|
-
react.createElement("span", { className: "deg_mc_card_actions" }, removeAction,
|
|
12135
|
-
react.createElement("button", {
|
|
12136
|
-
type: "button",
|
|
12137
|
-
className: "deg_btn",
|
|
12138
|
-
disabled: saving,
|
|
12139
|
-
onClick: () => { setOpen(false); cancel(); },
|
|
12140
|
-
}, "收起"))),
|
|
13447
|
+
card.provider === card.displayName ? null : react.createElement("span", { className: "deg_mc_card_route" }, card.provider)),
|
|
12141
13448
|
row.error === undefined ? null : react.createElement("p", { className: "deg_mc_error", role: "alert" }, row.error),
|
|
12142
13449
|
react.createElement("label", { className: "deg_mc_field" },
|
|
12143
13450
|
react.createElement("span", { className: "deg_mc_field_label" }, "API Key"),
|
|
@@ -12231,8 +13538,8 @@ window.__ModuleLoader__.load({
|
|
|
12231
13538
|
}, "添加模型"),
|
|
12232
13539
|
fetchState?.error === null || fetchState?.error === undefined
|
|
12233
13540
|
? null
|
|
12234
|
-
: react.createElement("p", { className: "deg_mc_error", role: "alert" }, fetchState.error)
|
|
12235
|
-
|
|
13541
|
+
: react.createElement("p", { className: "deg_mc_error", role: "alert" }, fetchState.error)
|
|
13542
|
+
),
|
|
12236
13543
|
error === null ? null : react.createElement("p", { className: "deg_mc_error", role: "alert", "data-deg-card-error": row.provider }, error),
|
|
12237
13544
|
conflict
|
|
12238
13545
|
? react.createElement("div", { className: "deg_mc_conflict" },
|
|
@@ -12250,23 +13557,118 @@ window.__ModuleLoader__.load({
|
|
|
12250
13557
|
: null,
|
|
12251
13558
|
react.createElement("div", { className: "deg_mc_card_footer" },
|
|
12252
13559
|
react.createElement("span", { className: "deg_mc_status deg_settings_status", role: "status" }, saved ? "已保存" : ""),
|
|
12253
|
-
react.createElement("button", { type: "button", className: "deg_btn", disabled: saving, onClick: cancel }, "取消"),
|
|
13560
|
+
react.createElement("button", { type: "button", className: "deg_btn", disabled: saving, onClick: () => { setOpen(false); cancel(); } }, "取消"),
|
|
12254
13561
|
react.createElement("button", {
|
|
12255
13562
|
type: "button",
|
|
12256
13563
|
className: "deg_btn deg_btn_primary",
|
|
12257
13564
|
disabled,
|
|
12258
13565
|
"data-deg-apply": row.provider,
|
|
12259
13566
|
onClick: () => { void apply(); },
|
|
12260
|
-
}, saving ? "应用中…" : "应用"))
|
|
13567
|
+
}, saving ? "应用中…" : "应用")),
|
|
13568
|
+
fetchState !== null && Array.isArray(fetchState.candidates)
|
|
13569
|
+
? react.createElement(ModelDiscoveryDialog, {
|
|
13570
|
+
candidates: fetchState.candidates,
|
|
13571
|
+
query: candidateQuery,
|
|
13572
|
+
onQueryChange: setCandidateQuery,
|
|
13573
|
+
onToggle: toggleCandidate,
|
|
13574
|
+
onToggleVisible: toggleVisibleCandidates,
|
|
13575
|
+
onClose: closeModelPicker,
|
|
13576
|
+
onAdopt: adoptPicked,
|
|
13577
|
+
})
|
|
13578
|
+
: null);
|
|
12261
13579
|
}
|
|
13580
|
+
/**
|
|
13581
|
+
* 弹窗标题 id 的自增序号。
|
|
13582
|
+
*
|
|
13583
|
+
* 同一个设置页可以同时挂载多个候选弹窗(例如两张 provider 卡片各自探测),
|
|
13584
|
+
* 每个实例的 `aria-labelledby` 必须指向**自己**的标题;共用一个字面量 id 会让
|
|
13585
|
+
* 后挂载的弹窗把无障碍名称解析到别人的标题上。
|
|
13586
|
+
*
|
|
13587
|
+
* 用模块级计数器 + 组件内惰性初始化,而不是 `react.useId`:本插件的 react 面
|
|
13588
|
+
* 在嵌入宿主与测试替身里都可能缺少 `useId`,计数器只依赖 useState/useRef 这类
|
|
13589
|
+
* 一直存在的 API。
|
|
13590
|
+
*/
|
|
13591
|
+
let modelDiscoveryTitleSeq = 0;
|
|
13592
|
+
|
|
13593
|
+
/**
|
|
13594
|
+
* 弹窗焦点环的候选控件:只保留真正可聚焦(启用且可见)的对话框控件。
|
|
13595
|
+
*
|
|
13596
|
+
* 判定只用声明式属性,不做布局测量(offsetParent 之类在嵌入宿主里不可靠):
|
|
13597
|
+
* - `disabled` / `aria-disabled="true"` 的控件浏览器会忽略 focus 调用;
|
|
13598
|
+
* - `hidden` / `aria-hidden="true"` 的控件不参与 Tab 顺序。
|
|
13599
|
+
* 两者都必须从焦点环里剔除——否则 handler 先 preventDefault 再 focus 一个被忽略的
|
|
13600
|
+
* 元素,焦点会原地卡死或漏到背景页面。
|
|
13601
|
+
*
|
|
13602
|
+
* @param {unknown} scope 弹窗根节点(任何提供 querySelectorAll 的元素)
|
|
13603
|
+
* @returns {Array<{focus?: Function}>} 按 DOM 顺序排列的可聚焦控件
|
|
13604
|
+
*/
|
|
13605
|
+
function focusableDialogControls(scope) {
|
|
13606
|
+
const nodes = Array.from(scope?.querySelectorAll?.("[data-deg-dialog-action]") ?? []);
|
|
13607
|
+
return nodes.filter((node) => {
|
|
13608
|
+
if (node === null || typeof node !== "object") return false;
|
|
13609
|
+
if (node.disabled === true) return false;
|
|
13610
|
+
if (node.hidden === true) return false;
|
|
13611
|
+
if (typeof node.getAttribute !== "function") return true;
|
|
13612
|
+
if (node.getAttribute("aria-disabled") === "true") return false;
|
|
13613
|
+
if (node.getAttribute("aria-hidden") === "true") return false;
|
|
13614
|
+
return true;
|
|
13615
|
+
});
|
|
13616
|
+
}
|
|
13617
|
+
|
|
13618
|
+
/**
|
|
13619
|
+
* 候选弹窗「全选/取消全选」的纯函数语义(与 DSH 模型页的可见项切换一致)。
|
|
13620
|
+
*
|
|
13621
|
+
* 关键边界(不是"只动可见项"那么简单):
|
|
13622
|
+
* - 当前可见候选**已全部勾选**时,切换等价于清空**全部**选择(含被搜索隐藏的候选);
|
|
13623
|
+
* - 否则只把当前可见候选**追加**为已选,隐藏候选保留原有选择。
|
|
13624
|
+
*
|
|
13625
|
+
* 弹窗按钮文案与父卡片的实际改写都取自这一个判定,避免两处各自推断出不同语义。
|
|
13626
|
+
*
|
|
13627
|
+
* @param {Array<{id?: unknown, picked?: unknown}>} candidates 全部候选
|
|
13628
|
+
* @param {Set<unknown>} visibleIds 当前可见候选 id
|
|
13629
|
+
* @returns {boolean} true 表示"清空全部选择",false 表示"只追加可见项"
|
|
13630
|
+
*/
|
|
13631
|
+
function clearsAllCandidateSelection(candidates, visibleIds) {
|
|
13632
|
+
const list = Array.isArray(candidates) ? candidates : [];
|
|
13633
|
+
const wanted = visibleIds instanceof Set ? visibleIds : new Set();
|
|
13634
|
+
const visible = list.filter((candidate) => wanted.has(candidate?.id));
|
|
13635
|
+
return visible.length > 0 && visible.every((candidate) => candidate.picked === true);
|
|
13636
|
+
}
|
|
13637
|
+
|
|
12262
13638
|
/**
|
|
12263
13639
|
* 自定义 provider 的可用模型选择弹窗:候选只进入当前草稿,不在探测成功时自动写 settings。
|
|
12264
13640
|
*
|
|
12265
13641
|
* 交互与 DSH 原生模型页保持一致:搜索当前候选、当前结果全选/取消全选、逐项勾选,
|
|
12266
13642
|
* 最后由“添加所选”调用父卡片的 adoptModels 合并。弹窗关闭不会丢弃卡片里已有模型。
|
|
13643
|
+
*
|
|
13644
|
+
* 模态契约(与 DSH 内置 picker 的 Modal/useModalLayer 对齐):
|
|
13645
|
+
* - 关闭按钮、点击遮罩、Escape 三条路径都只**关闭**弹窗(走 onClose),不改草稿、不采纳;
|
|
13646
|
+
* - 弹窗内 Tab / Shift+Tab 只在**真正可聚焦**(启用且可见)的控件之间循环,不把焦点
|
|
13647
|
+
* 漏到背景页面,也不会把焦点交给 disabled 的「添加所选」或空结果时的「全选」;
|
|
13648
|
+
* - 关闭(卸载)后把焦点还给打开弹窗前的那个元素;
|
|
13649
|
+
* - 标题 id 每个实例唯一,多个弹窗同时挂载时各自指向自己的标题。
|
|
13650
|
+
*
|
|
13651
|
+
* 焦点相关调用全部做能力探测:嵌入宿主(或测试替身)没有 document/focus 时静默跳过,
|
|
13652
|
+
* 绝不因为焦点不可用而让弹窗不可关闭。
|
|
12267
13653
|
*/
|
|
12268
13654
|
function ModelDiscoveryDialog({ candidates, query, onQueryChange, onToggle, onToggleVisible, onClose, onAdopt }) {
|
|
12269
13655
|
const source = Array.isArray(candidates) ? candidates : [];
|
|
13656
|
+
// 标题 id 每个实例唯一(惰性初始化只在首次渲染执行一次),dialog 的 aria-labelledby
|
|
13657
|
+
// 因此始终指向本实例自己的 h3。
|
|
13658
|
+
const [titleId] = react.useState(() => {
|
|
13659
|
+
modelDiscoveryTitleSeq += 1;
|
|
13660
|
+
return "deg-model-discovery-title-" + String(modelDiscoveryTitleSeq);
|
|
13661
|
+
});
|
|
13662
|
+
const dialogRef = react.useRef(null);
|
|
13663
|
+
// 打开前的焦点归属:只在挂载时读一次,卸载时原样还回去。
|
|
13664
|
+
const restoreTargetRef = react.useRef(typeof document === "undefined" ? null : document.activeElement ?? null);
|
|
13665
|
+
react.useEffect(() => () => {
|
|
13666
|
+
// 关闭(卸载)时恢复先前焦点;元素可能已从页面移除,因此 focus 缺失时直接跳过。
|
|
13667
|
+
const target = restoreTargetRef.current;
|
|
13668
|
+
try {
|
|
13669
|
+
if (target !== null && typeof target?.focus === "function") target.focus();
|
|
13670
|
+
} catch { /* 焦点不可用时保持弹窗关闭语义,不抛出。 */ }
|
|
13671
|
+
}, []);
|
|
12270
13672
|
const normalizedQuery = typeof query === "string" ? query.trim().toLowerCase() : "";
|
|
12271
13673
|
const visible = normalizedQuery.length === 0
|
|
12272
13674
|
? source
|
|
@@ -12275,7 +13677,32 @@ window.__ModuleLoader__.load({
|
|
|
12275
13677
|
const name = typeof candidate?.name === "string" ? candidate.name : "";
|
|
12276
13678
|
return id.toLowerCase().includes(normalizedQuery) || name.toLowerCase().includes(normalizedQuery);
|
|
12277
13679
|
});
|
|
12278
|
-
const
|
|
13680
|
+
const visibleIds = new Set(visible.map((candidate) => candidate?.id));
|
|
13681
|
+
// 按钮文案与父卡片的改写必须同源:这里只用纯函数判定"这次切换是否清空全部选择"。
|
|
13682
|
+
const clearsAll = clearsAllCandidateSelection(source, visibleIds);
|
|
13683
|
+
const pickedCount = source.filter((candidate) => candidate.picked === true).length;
|
|
13684
|
+
const handleKeyDown = (event) => {
|
|
13685
|
+
if (event?.key === "Escape") {
|
|
13686
|
+
// 关闭是模态层的独占语义:阻止默认行为并停止冒泡,避免外层再处理同一个 Escape。
|
|
13687
|
+
event.preventDefault?.();
|
|
13688
|
+
event.stopPropagation?.();
|
|
13689
|
+
onClose();
|
|
13690
|
+
return;
|
|
13691
|
+
}
|
|
13692
|
+
if (event?.key !== "Tab") return;
|
|
13693
|
+
// 焦点环:与提示词弹窗同一套 data 标记与循环算法(首尾相接,Shift+Tab 反向),
|
|
13694
|
+
// 但候选集只取**真正可聚焦**的控件:disabled 的「添加所选」与无结果时的「全选」
|
|
13695
|
+
// 必须跳过,否则 preventDefault 之后 focus 一个被浏览器忽略的元素会让焦点卡死。
|
|
13696
|
+
const controls = focusableDialogControls(dialogRef.current);
|
|
13697
|
+
// 没有任何可聚焦控件时不拦截 Tab:宁可让焦点按浏览器默认行为走,也不把焦点锁死。
|
|
13698
|
+
if (controls.length === 0) return;
|
|
13699
|
+
const current = controls.indexOf(typeof document === "undefined" ? null : document.activeElement);
|
|
13700
|
+
const nextIndex = event.shiftKey
|
|
13701
|
+
? (current <= 0 ? controls.length - 1 : current - 1)
|
|
13702
|
+
: (current >= controls.length - 1 ? 0 : current + 1);
|
|
13703
|
+
event.preventDefault?.();
|
|
13704
|
+
try { controls[nextIndex]?.focus?.(); } catch { /* 无 DOM 焦点时保持可点击控件。 */ }
|
|
13705
|
+
};
|
|
12279
13706
|
const list = visible.length === 0
|
|
12280
13707
|
? react.createElement("p", { className: "deg_mc_candidate_empty", role: "status" }, "没有匹配的模型")
|
|
12281
13708
|
: react.createElement("ul", { className: "deg_mc_candidate_list" }, visible.map((candidate) => (
|
|
@@ -12285,23 +13712,34 @@ window.__ModuleLoader__.load({
|
|
|
12285
13712
|
type: "checkbox",
|
|
12286
13713
|
checked: candidate.picked === true,
|
|
12287
13714
|
"data-deg-create-candidate": candidate.id,
|
|
13715
|
+
"data-deg-dialog-action": "candidate",
|
|
12288
13716
|
"aria-label": "选择模型 " + candidate.id,
|
|
12289
13717
|
onChange: () => onToggle(candidate.id),
|
|
12290
13718
|
}),
|
|
12291
13719
|
react.createElement("span", { className: "deg_mc_candidate_id" }, candidate.id))))));
|
|
12292
|
-
return react.createElement("div", {
|
|
13720
|
+
return react.createElement("div", {
|
|
13721
|
+
className: "deg_mc_modal_backdrop",
|
|
13722
|
+
// 点遮罩只关闭:候选状态与草稿都不受影响(采纳只能由“添加所选”触发)。
|
|
13723
|
+
onClick: () => onClose(),
|
|
13724
|
+
},
|
|
12293
13725
|
react.createElement("div", {
|
|
12294
13726
|
className: "deg_mc_modal",
|
|
12295
13727
|
role: "dialog",
|
|
12296
13728
|
"aria-modal": "true",
|
|
12297
|
-
"aria-labelledby":
|
|
13729
|
+
"aria-labelledby": titleId,
|
|
13730
|
+
ref: dialogRef,
|
|
13731
|
+
onKeyDown: handleKeyDown,
|
|
13732
|
+
// 点弹窗内部不应冒泡到遮罩,避免误关。
|
|
13733
|
+
onClick: (event) => event?.stopPropagation?.(),
|
|
12298
13734
|
},
|
|
12299
13735
|
react.createElement("div", { className: "deg_mc_modal_header" },
|
|
12300
|
-
react.createElement("h3", { className: "deg_mc_modal_title", id:
|
|
13736
|
+
react.createElement("h3", { className: "deg_mc_modal_title", id: titleId }, "选择要添加的模型"),
|
|
12301
13737
|
react.createElement("button", {
|
|
12302
13738
|
type: "button",
|
|
12303
13739
|
className: "deg_mc_modal_close",
|
|
12304
13740
|
"aria-label": "关闭",
|
|
13741
|
+
"data-deg-dialog-action": "close",
|
|
13742
|
+
autoFocus: true,
|
|
12305
13743
|
onClick: onClose,
|
|
12306
13744
|
}, "×")),
|
|
12307
13745
|
react.createElement("p", { className: "deg_mc_modal_description" }, "以下是模型提供方的可用模型,勾选要添加的模型。"),
|
|
@@ -12312,18 +13750,31 @@ window.__ModuleLoader__.load({
|
|
|
12312
13750
|
value: query,
|
|
12313
13751
|
placeholder: "搜索模型",
|
|
12314
13752
|
"aria-label": "搜索模型",
|
|
13753
|
+
"data-deg-dialog-action": "search",
|
|
12315
13754
|
onChange: (event) => onQueryChange(event.target.value),
|
|
12316
13755
|
}),
|
|
12317
13756
|
react.createElement("button", {
|
|
12318
13757
|
type: "button",
|
|
12319
13758
|
className: "deg_btn",
|
|
12320
13759
|
disabled: visible.length === 0,
|
|
13760
|
+
"data-deg-dialog-action": "toggle-visible",
|
|
12321
13761
|
onClick: () => onToggleVisible(visible.map((candidate) => candidate.id)),
|
|
12322
|
-
},
|
|
13762
|
+
}, clearsAll ? "取消全选" : "全选")),
|
|
12323
13763
|
list,
|
|
12324
13764
|
react.createElement("div", { className: "deg_mc_modal_footer" },
|
|
12325
|
-
react.createElement("button", {
|
|
12326
|
-
|
|
13765
|
+
react.createElement("button", {
|
|
13766
|
+
type: "button",
|
|
13767
|
+
className: "deg_btn",
|
|
13768
|
+
"data-deg-dialog-action": "cancel",
|
|
13769
|
+
onClick: onClose,
|
|
13770
|
+
}, "取消"),
|
|
13771
|
+
react.createElement("button", {
|
|
13772
|
+
type: "button",
|
|
13773
|
+
className: "deg_btn deg_btn_primary",
|
|
13774
|
+
disabled: pickedCount === 0,
|
|
13775
|
+
"data-deg-dialog-action": "adopt",
|
|
13776
|
+
onClick: onAdopt,
|
|
13777
|
+
}, "添加所选"))));
|
|
12327
13778
|
}
|
|
12328
13779
|
|
|
12329
13780
|
/**
|
|
@@ -12369,7 +13820,11 @@ window.__ModuleLoader__.load({
|
|
|
12369
13820
|
const disabled = card.readOnly || saving;
|
|
12370
13821
|
// 已提交后只允许重试 API Key:route 与凭据引用已经落盘,继续编辑会导致
|
|
12371
13822
|
// "旧 profile + 新 route 的凭据"错配。其余字段与模型编辑统一禁用。
|
|
12372
|
-
|
|
13823
|
+
//
|
|
13824
|
+
// fetching 一并锁定 profile:候选是用**点击那一刻**的 endpoint 参数探测出来的,
|
|
13825
|
+
// 请求在途时改 Base URL / 协议 / 模型行(或收起卡片丢弃草稿),就会让旧参数的
|
|
13826
|
+
// 结果被采纳进新配置。已有 provider 卡片用 saving 覆盖同一段窗口,这里保持同语义。
|
|
13827
|
+
const profileDisabled = card.profileLocked || saving || fetching;
|
|
12373
13828
|
// 只同步已占用 route 词表;revision 由状态机一次性固定(rebase 内部只在 undefined 时初始化)。
|
|
12374
13829
|
react.useEffect(() => {
|
|
12375
13830
|
draft.rebase({
|
|
@@ -12458,16 +13913,23 @@ window.__ModuleLoader__.load({
|
|
|
12458
13913
|
: candidate),
|
|
12459
13914
|
});
|
|
12460
13915
|
};
|
|
13916
|
+
/**
|
|
13917
|
+
* 弹窗「全选/取消全选」的唯一实现(弹窗按钮文案也走同一个纯函数判定)。
|
|
13918
|
+
*
|
|
13919
|
+
* - 可见项已全选 → 清空**全部**选择(含被搜索隐藏的候选),与 DSH 模型页一致;
|
|
13920
|
+
* - 否则只把可见项追加为已选,隐藏候选保留原选择。
|
|
13921
|
+
*/
|
|
12461
13922
|
const toggleVisibleCandidates = (ids) => {
|
|
12462
13923
|
const wanted = new Set(Array.isArray(ids) ? ids : []);
|
|
12463
13924
|
setFetchState((previous) => {
|
|
12464
13925
|
if (previous === null || !Array.isArray(previous.candidates)) return previous;
|
|
12465
|
-
|
|
12466
|
-
|
|
13926
|
+
if (clearsAllCandidateSelection(previous.candidates, wanted)) {
|
|
13927
|
+
return { ...previous, candidates: previous.candidates.map((candidate) => ({ ...candidate, picked: false })) };
|
|
13928
|
+
}
|
|
12467
13929
|
return {
|
|
12468
13930
|
...previous,
|
|
12469
13931
|
candidates: previous.candidates.map((candidate) => wanted.has(candidate.id)
|
|
12470
|
-
? { ...candidate, picked:
|
|
13932
|
+
? { ...candidate, picked: true }
|
|
12471
13933
|
: candidate),
|
|
12472
13934
|
};
|
|
12473
13935
|
});
|
|
@@ -12772,7 +14234,9 @@ window.__ModuleLoader__.load({
|
|
|
12772
14234
|
react.createElement("button", {
|
|
12773
14235
|
type: "button",
|
|
12774
14236
|
className: "deg_btn",
|
|
12775
|
-
|
|
14237
|
+
// 发现请求在途时同样禁止取消:取消会丢弃草稿并收起卡片,而响应仍会回来,
|
|
14238
|
+
// 与 profile 锁定一起构成"在途期间不改变 endpoint 与草稿归属"的同一条边界。
|
|
14239
|
+
disabled: saving || fetching,
|
|
12776
14240
|
"data-deg-create-cancel": "true",
|
|
12777
14241
|
onClick: () => { onClose(draft.settingsCommitted); },
|
|
12778
14242
|
}, "取消"),
|