dsh-expert-group 0.1.2 → 0.1.4
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 +12 -139
- package/client-bundle.js +1578 -74
- package/client-expert-group-sidebar.js +842 -21
- package/client.js +736 -53
- package/host/system-toast.js +3 -2
- package/host/turn-wait.js +78 -6
- package/index.js +851 -103
- package/orchestrator.js +3 -3
- package/package.json +25 -28
- package/prompt-contracts.js +2 -0
- package/scripts/assemble-client.mjs +1 -1
- package/shared.js +122 -31
- package/desktop-uninstall.ps1 +0 -679
- package/web-install.ps1 +0 -219
- package/web-uninstall.ps1 +0 -962
package/client-bundle.js
CHANGED
|
@@ -449,6 +449,8 @@
|
|
|
449
449
|
// ACTIVE_STATUSES / RETRY_STATUSES 只服务于 fan-out 管理语义(取消/重试判定),不是展示状态词表。
|
|
450
450
|
const ACTIVE_STATUSES = new Set(["queued", "waiting", "running"]);
|
|
451
451
|
const RETRY_STATUSES = new Set(["failed", "timed_out", "interrupted"]);
|
|
452
|
+
// 父任务的规范终态表示全部分片已结算,且不会把未知/展示态误判为空闲。
|
|
453
|
+
const FANOUT_SETTLED_STATUSES = new Set(["succeeded", "failed", "cancelled"]);
|
|
452
454
|
const STYLE_ID = "dsh-expert-group/expert-group-sidebar";
|
|
453
455
|
/**
|
|
454
456
|
* Expert Group 页的右侧栏宽度:**恒为视口宽度的 20%**(1600px 视口 ⇒ 320px)。
|
|
@@ -608,6 +610,31 @@
|
|
|
608
610
|
const SIDEBAR_SPLIT_PENDING_MAX_ATTEMPTS = 4;
|
|
609
611
|
/** 侧栏打开状态的复合键分隔符:会话 id、rootSessionId 与资源地址都是单行文本,NUL 不会出现在其中。 */
|
|
610
612
|
const SIDEBAR_TAB_KEY_SEPARATOR = "\u0000";
|
|
613
|
+
/**
|
|
614
|
+
* 右栏 tab 的两个来源。
|
|
615
|
+
*
|
|
616
|
+
* - `auto`:「自动打开」(Auto)本次运行亲手打开的 tab,只有它允许被自动关闭;
|
|
617
|
+
* - `manual`:用户点击箭头打开、或外部已存在但不由本插件自动动作发起的 tab。
|
|
618
|
+
* 双来源是「手动优先」判定唯一依据:自动同步绝不关闭 manual 来源的 tab。
|
|
619
|
+
*/
|
|
620
|
+
const SIDEBAR_TAB_ORIGIN_AUTO = "auto";
|
|
621
|
+
const SIDEBAR_TAB_ORIGIN_MANUAL = "manual";
|
|
622
|
+
/**
|
|
623
|
+
* 自动关闭的有界重试上限。
|
|
624
|
+
*
|
|
625
|
+
* 宿主 close(tabId) 可能同步抛错或长期失败;自动同步每 2.5 秒一轮,没有上限就会变成
|
|
626
|
+
* 后台重试风暴。达到上限后只撤销「这是 Auto 打开的」记账(tab 原样留给用户手动关闭),
|
|
627
|
+
* 绝不猜测 tabId、也绝不改用批量关闭入口。
|
|
628
|
+
*/
|
|
629
|
+
const SIDEBAR_AUTO_CLOSE_MAX_ATTEMPTS = 3;
|
|
630
|
+
/**
|
|
631
|
+
* 自动来源/抑制记集合的条目上限(按插入顺序淘汰最旧一项)。
|
|
632
|
+
*
|
|
633
|
+
* 复合键含 rootSessionId:面板实例跨多个 root 复用时,别的 root 的键在当前快照里无法判定
|
|
634
|
+
* 是否仍然存在(快照只描述当前 root),因此除了「快照里已消失就清理」之外还需要这条上限,
|
|
635
|
+
* 保证长期运行的面板不会无界增长。
|
|
636
|
+
*/
|
|
637
|
+
const SIDEBAR_AUTO_KEY_LIMIT = 128;
|
|
611
638
|
|
|
612
639
|
function object(value) {
|
|
613
640
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -1378,6 +1405,11 @@
|
|
|
1378
1405
|
".deg_expert_group_header_title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
1379
1406
|
".deg_expert_group_header_action,.deg_expert_group_chevron,.deg_expert_group_action,.deg_expert_group_sidebar_action{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}",
|
|
1380
1407
|
".deg_expert_group_header_action{width:24px;height:24px;margin-left:auto}",
|
|
1408
|
+
// 标题栏 Auto 开关:紧凑到与标题同一行,右对齐且不换行,绝不遮挡标题或右侧操作按钮。
|
|
1409
|
+
".deg_expert_group_header_auto{display:inline-flex;align-items:center;gap:4px;flex:0 0 auto;margin-left:8px;font-weight:400;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap}",
|
|
1410
|
+
".deg_expert_group_header_auto_input{margin:0;cursor:pointer}",
|
|
1411
|
+
".deg_expert_group_header_auto_input:focus-visible{outline:2px solid var(--dsw-alias-interactive-border-focus,var(--dsw-alias-label-primary));outline-offset:1px}",
|
|
1412
|
+
".deg_expert_group_header_auto_label{font-size:12px;line-height:16px}",
|
|
1381
1413
|
".deg_expert_group_header_action:hover,.deg_expert_group_chevron:hover,.deg_expert_group_action:hover:not(:disabled),.deg_expert_group_sidebar_action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
1382
1414
|
".deg_expert_group_header_action:focus-visible,.deg_expert_group_chevron:focus-visible,.deg_expert_group_action:focus-visible,.deg_expert_group_sidebar_action:focus-visible{outline:2px solid var(--dsw-alias-interactive-border-focus,var(--dsw-alias-label-primary));outline-offset:1px}",
|
|
1383
1415
|
".deg_expert_group_tree{min-width:0;padding-top:5px}",
|
|
@@ -1416,6 +1448,7 @@
|
|
|
1416
1448
|
".deg_expert_group_task:hover,.deg_expert_group_content:hover{text-decoration:underline}",
|
|
1417
1449
|
".deg_expert_group_content{grid-area:3 / 1;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));cursor:pointer}",
|
|
1418
1450
|
".deg_expert_group_chevron{width:20px;height:20px;padding:0;align-self:start}",
|
|
1451
|
+
".deg_expert_group_fanout_header>.deg_expert_group_chevron{align-self:center}",
|
|
1419
1452
|
".deg_expert_group_chevron_placeholder{display:inline-block;width:20px;height:20px}",
|
|
1420
1453
|
".deg_expert_group_status_icon{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;color:var(--dsw-alias-label-secondary)}",
|
|
1421
1454
|
// 兜底圆点:官方 primitives.StateDot 缺失时才使用,静态、无动画。
|
|
@@ -2591,6 +2624,16 @@
|
|
|
2591
2624
|
const versionPair = typeof react.useState === "function" ? react.useState(0) : [0, () => {}];
|
|
2592
2625
|
const version = versionPair[0];
|
|
2593
2626
|
const bump = versionPair[1];
|
|
2627
|
+
/**
|
|
2628
|
+
* 面板标题栏 Auto 开关的本地乐观值(null = 跟随权威读面)。
|
|
2629
|
+
*
|
|
2630
|
+
* 开关的持久化要走一次 settings-commit 往返;没有本地覆盖时,用户点击后要等下一次
|
|
2631
|
+
* 轮询(最长 2.5 秒)才会看到勾选状态变化。这里先乐观翻转,写失败或权威值变化时
|
|
2632
|
+
* 立刻回退到读面值(绝不让开关停在错误状态)。
|
|
2633
|
+
*/
|
|
2634
|
+
const autoOpenDraftPair = typeof react.useState === "function" ? react.useState(null) : [null, () => {}];
|
|
2635
|
+
const autoOpenDraft = autoOpenDraftPair[0];
|
|
2636
|
+
const setAutoOpenDraft = autoOpenDraftPair[1];
|
|
2594
2637
|
const model = modelApi();
|
|
2595
2638
|
// 展开状态只依赖 storage 与 root;缓存 store 可避免每次 RPC/通知重渲染都重复读取
|
|
2596
2639
|
// sessionStorage 和解析同一份展开键。
|
|
@@ -2738,21 +2781,91 @@
|
|
|
2738
2781
|
const refreshRef = typeof react.useRef === "function" ? react.useRef(refresh) : { current: refresh };
|
|
2739
2782
|
refreshRef.current = refresh;
|
|
2740
2783
|
/**
|
|
2741
|
-
* 子代理侧栏打开状态:复合键(sessionId + rootSessionId + 资源地址)→
|
|
2784
|
+
* 子代理侧栏打开状态:复合键(sessionId + rootSessionId + 资源地址)→
|
|
2785
|
+
* { sessionId, rootSessionId, tabId, title, misses, origin }。
|
|
2742
2786
|
*
|
|
2743
2787
|
* 用 ref 而不是 state 的理由与 pendingSet 相同:一次渲染里可能同时存在多个已打开的子代理
|
|
2744
2788
|
* (每个 root、每个资源地址各一条记录),状态必须按「哪个 root 的哪个地址」隔离,绝不使用
|
|
2745
2789
|
* 单一全局 boolean——那会让两个子代理互相顶掉,也无法区分不同会话。重渲染统一走 bump。
|
|
2746
2790
|
* misses 是该 tab 连续被观测到缺失的次数(见 SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS)。
|
|
2791
|
+
* origin 是该 tab 的来源(auto | manual,见 SIDEBAR_TAB_ORIGIN_*):只有 auto 来源才允许
|
|
2792
|
+
* 被自动同步关闭,manual 来源(用户手动打开、外部已存在的 tab)一律不碰。
|
|
2747
2793
|
*/
|
|
2748
2794
|
const sidebarTabRecordsRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2749
2795
|
/**
|
|
2750
|
-
* 已发起但尚未从 active() 得到落地证据的打开动作:
|
|
2796
|
+
* 已发起但尚未从 active() 得到落地证据的打开动作:
|
|
2797
|
+
* key → { sessionId, rootSessionId, resourceAddress, expiresAt, origin }。
|
|
2751
2798
|
*
|
|
2752
2799
|
* DSH 的 openResource 可能先提交导航意图,下一次渲染才出现 active tab;这段窗口不能让第二次
|
|
2753
2800
|
* 点击再次 openResource,也不能提前伪造 <<。observer/轮询读到匹配 active() 后才转成正式记录。
|
|
2754
2801
|
*/
|
|
2755
2802
|
const sidebarTabOpeningRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2803
|
+
/**
|
|
2804
|
+
* Auto(自动打开运行中的子代理侧栏)的来源与抑制账本,键与打开记录完全同构
|
|
2805
|
+
* (sidebar session + rootSessionId + 资源地址),因此不同会话/root 的状态互不影响。
|
|
2806
|
+
*
|
|
2807
|
+
* - 来源:Auto 本次运行亲手发出过打开动作的资源。它同时是「不要重复打开」的记账:
|
|
2808
|
+
* 命中即跳过(哪怕这次打开始终没有落地证据,也绝不再发第二次 openResource);
|
|
2809
|
+
* 只有这个集合里的 key 才允许被自动关闭。
|
|
2810
|
+
* - 抑制:用户手动关闭过的资源。Auto 不得立刻重开它;用户手动重新打开同一资源时清除
|
|
2811
|
+
* (手动意图始终优先)。
|
|
2812
|
+
* 两个集合都按当前快照与上限清理(见 syncAutoSidebarTabs 的清理阶段)。
|
|
2813
|
+
*/
|
|
2814
|
+
const sidebarAutoOriginRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2815
|
+
const sidebarAutoSuppressedRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2816
|
+
/** 自动关闭的重试记账:key → 已尝试次数(成功或资源重新变成运行中时清空)。 */
|
|
2817
|
+
const sidebarAutoCloseAttemptsRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2818
|
+
/**
|
|
2819
|
+
* 自动打开的**预留**(reservation):key → { sessionId, rootSessionId, address, epoch }。
|
|
2820
|
+
*
|
|
2821
|
+
* 与来源标记严格分离:预留只在候选选择的那一刻写入,用来挡住同一资源的重复动作;只有
|
|
2822
|
+
* openResource 真的成功发出、且宿主有可追踪能力时才升级成「Auto 亲手打开」的来源。
|
|
2823
|
+
* split busy、准备失败、layout 失效、open 抛错、root/sidebar session 变化等失败路径一律
|
|
2824
|
+
* 撤销预留(见 handleSidebarAction 的 finally):否则一次失败会被永久记成「已经开过」,
|
|
2825
|
+
* 该资源在本次运行里再也打不开。
|
|
2826
|
+
*/
|
|
2827
|
+
const sidebarAutoReservationRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2828
|
+
/**
|
|
2829
|
+
* 自动动作的级联标记与失败屏障。
|
|
2830
|
+
*
|
|
2831
|
+
* 撤销预留之后,「下一次同步」会立刻重新选中同一个资源,而自动动作收尾时又会立刻跑一次
|
|
2832
|
+
* 尾随同步。若宿主的失败是**持续**的(本会话的 split 事务仍在途 ⇒ busy;宿主持续拒绝分栏;
|
|
2833
|
+
* openResource 持续抛错),「失败 → 撤销预留 → 立刻重选」这条链路会在微任务里自转:它永远
|
|
2834
|
+
* 不让出宏任务,宿主的提交证据也就永远不会被观察到,页面与预算一起被拖住。
|
|
2835
|
+
*
|
|
2836
|
+
* 因此:同一次级联(自动动作收尾引发的那条同步链)里失败过的 key 不再入选;失败仍然可重试,
|
|
2837
|
+
* 但下一次尝试一定发生在**外部同步轮**(2.5 秒轮询 / observer / 可见性变化 / 会话通知)里,
|
|
2838
|
+
* 那里才能观察到形成失败条件的宿主状态是否已经改变。
|
|
2839
|
+
*/
|
|
2840
|
+
const sidebarAutoCascadeRef = typeof react.useRef === "function" ? react.useRef(false) : { current: false };
|
|
2841
|
+
const sidebarAutoFailedKeysRef = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
|
|
2842
|
+
/**
|
|
2843
|
+
* Auto 动作代次:Auto 开关的权威值每次变化(含关闭)时递增。
|
|
2844
|
+
*
|
|
2845
|
+
* 在途自动动作在自己那一轮渲染的闭包里捕获取代次,并在每个 await 之后、真正调用宿主
|
|
2846
|
+
* openResource()/close() 之前复核:代次不符说明用户已经关掉(或重新打开)Auto,那次自动
|
|
2847
|
+
* 动作一律放弃——绝不把「已经关掉的 Auto」变成一次迟到的用户可见打开。
|
|
2848
|
+
*/
|
|
2849
|
+
const sidebarAutoEpochRef = typeof react.useRef === "function" ? react.useRef(0) : { current: 0 };
|
|
2850
|
+
/**
|
|
2851
|
+
* 用户手动意图记录:key → { sessionId, rootSessionId, address, intent }。
|
|
2852
|
+
*
|
|
2853
|
+
* 唯一目的:Auto 的动作正在同一个 key 上等待(split/layout/open)时,手动点击不能被 pending
|
|
2854
|
+
* 锁直接丢弃。意图只记录「用户要打开还是关闭」,由在途动作在副作用之前让位,或由同一条同步
|
|
2855
|
+
* 链路在它收尾后结算(见 applyPendingManualIntent):手动关闭绝不被 Auto 随后重开,手动打开
|
|
2856
|
+
* 的最终 tab 一律记为 manual 来源。
|
|
2857
|
+
*/
|
|
2858
|
+
const sidebarManualIntentRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2859
|
+
/**
|
|
2860
|
+
* Auto 动作串行闸门。
|
|
2861
|
+
*
|
|
2862
|
+
* 自动同步可能同时看到多个候选(多个运行中的子代理),但同一 tick 里连发多个
|
|
2863
|
+
* split()/openResource() 会互相抢 pane(第二个动作看到的 pane 证据仍是第一帧之前的)。
|
|
2864
|
+
* 因此一次只放行一个动作,动作收尾后由同一条同步链路推进下一个候选。
|
|
2865
|
+
*/
|
|
2866
|
+
const sidebarAutoBusyRef = typeof react.useRef === "function" ? react.useRef(false) : { current: false };
|
|
2867
|
+
/** 最近一次同步观察到的 Auto 开关值;变化时 bump 一次(空闲轮询绝不重渲染)。 */
|
|
2868
|
+
const sidebarAutoSignatureRef = typeof react.useRef === "function" ? react.useRef(null) : { current: null };
|
|
2756
2869
|
/**
|
|
2757
2870
|
* 当前首次 split 的用户意图;它跨越 split → openResource → openTabs/DOM 提交窗口,
|
|
2758
2871
|
* 只能在目标 tab 已经落到返回的 pane 后才交给 install 侧结算。
|
|
@@ -2921,6 +3034,41 @@
|
|
|
2921
3034
|
const commitSidebarSplit = typeof config.commitSidebarSplit === "function" ? config.commitSidebarSplit : null;
|
|
2922
3035
|
const cancelSidebarSplit = typeof config.cancelSidebarSplit === "function" ? config.cancelSidebarSplit : null;
|
|
2923
3036
|
const openSidebarResource = methodOf(sidebarRight, "openResource");
|
|
3037
|
+
/**
|
|
3038
|
+
* Auto(自动打开运行中的子代理侧栏)的实时读面:install 注入的 getter,每次同步现读。
|
|
3039
|
+
*
|
|
3040
|
+
* 直接 createExpertGroupPanel(旧宿主/单测)没有该依赖时按**关闭**处理:默认行为必须
|
|
3041
|
+
* 与历史逐字节一致(不做任何自动 open/close)。读取抛错同样按关闭处理——自动行为只是
|
|
3042
|
+
* 体验增强,绝不能影响面板渲染或既有的手动箭头。
|
|
3043
|
+
*/
|
|
3044
|
+
const readAutoOpenSetting = typeof config.getAutoOpenSubagentSidebar === "function"
|
|
3045
|
+
? config.getAutoOpenSubagentSidebar
|
|
3046
|
+
: null;
|
|
3047
|
+
/** 面板标题栏 Auto 开关的写面;缺失时开关只读(渲染 disabled),绝不假装能改。 */
|
|
3048
|
+
const writeAutoOpenSetting = typeof config.setAutoOpenSubagentSidebar === "function"
|
|
3049
|
+
? config.setAutoOpenSubagentSidebar
|
|
3050
|
+
: null;
|
|
3051
|
+
const autoOpenEnabled = () => {
|
|
3052
|
+
if (readAutoOpenSetting === null) return false;
|
|
3053
|
+
try { return readAutoOpenSetting() === true; } catch { return false; }
|
|
3054
|
+
};
|
|
3055
|
+
/**
|
|
3056
|
+
* 标题栏 Auto 开关的点击处理:先本地乐观翻转,再把显式布尔交给写面持久化。
|
|
3057
|
+
*
|
|
3058
|
+
* 写面缺失或返回非 true(只读文档、宿主校验/并发失败)时立刻清掉本地值,开关回退到权威读面;
|
|
3059
|
+
* 成功时本地值与读面一致,下一次同步观察到权威值变化会再清一次(幂等,绝不闪回)。
|
|
3060
|
+
*/
|
|
3061
|
+
const toggleAutoOpen = (next) => {
|
|
3062
|
+
const value = next === true;
|
|
3063
|
+
if (writeAutoOpenSetting === null) return;
|
|
3064
|
+
setAutoOpenDraft(value);
|
|
3065
|
+
void Promise.resolve()
|
|
3066
|
+
.then(() => writeAutoOpenSetting(value))
|
|
3067
|
+
.then(
|
|
3068
|
+
(accepted) => { if (accepted !== true) setAutoOpenDraft(null); },
|
|
3069
|
+
() => { setAutoOpenDraft(null); },
|
|
3070
|
+
);
|
|
3071
|
+
};
|
|
2924
3072
|
// 新版宿主提供公开 split(),返回新 pane 的 PaneId;只有拿到这个回执后才打开资源,
|
|
2925
3073
|
// 避免 preferNewPane 失败时静默回落到 Expert Group 所在 pane。旧宿主没有 split() 时
|
|
2926
3074
|
// 保留 preferNewPane 兼容路径,但绝不调用 float(),不制造用户可见浮窗。
|
|
@@ -3397,11 +3545,15 @@
|
|
|
3397
3545
|
const sidebarRootChildren = () => (Array.isArray(snapshot?.root?.children) ? snapshot.root.children : []);
|
|
3398
3546
|
|
|
3399
3547
|
/**
|
|
3400
|
-
*
|
|
3401
|
-
*
|
|
3548
|
+
* 空闲分组收纳 ready 直接专家和已结算的 fan-out 父任务。
|
|
3549
|
+
* 父任务必须命中 manager 终态,不能仅凭子会话暂时未运行或未知状态提前移动。
|
|
3402
3550
|
*/
|
|
3403
3551
|
const sidebarIdleChildren = () => sidebarRootChildren()
|
|
3404
|
-
.filter((node) =>
|
|
3552
|
+
.filter((node) => {
|
|
3553
|
+
if (node.kind === "fanout") return FANOUT_SETTLED_STATUSES.has(statusOf(node));
|
|
3554
|
+
return node.kind === "expert"
|
|
3555
|
+
&& runtimeStatusOfNode(node, rootSessionId, text(node.sessionId, null)) === "ready";
|
|
3556
|
+
});
|
|
3405
3557
|
|
|
3406
3558
|
/**
|
|
3407
3559
|
* 当前 root 专家树里「运行中」的资源地址集合(主代理行批量关闭的唯一候选来源)。
|
|
@@ -3414,12 +3566,26 @@
|
|
|
3414
3566
|
};
|
|
3415
3567
|
|
|
3416
3568
|
/**
|
|
3417
|
-
*
|
|
3569
|
+
* 空闲分组的资源地址集合;成员判定严格复用当前 idleChildren。
|
|
3570
|
+
* fan-out 父任务代表其最新 attempt 的分片会话,只收录当前未运行的会话地址。
|
|
3418
3571
|
*/
|
|
3419
3572
|
const idleSidebarResourceAddresses = (nodes) => {
|
|
3420
3573
|
const addresses = new Set();
|
|
3421
3574
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
|
3422
3575
|
const source = object(node);
|
|
3576
|
+
if (source.kind === "fanout") {
|
|
3577
|
+
for (const shard of Array.isArray(source.shards) ? source.shards : []) {
|
|
3578
|
+
const shardSource = object(shard);
|
|
3579
|
+
const shardChildId = text(shardSource.childId, null);
|
|
3580
|
+
if (shardChildId === null || runtimeStatusOfNode(shardSource, rootSessionId, shardChildId) !== "ready") continue;
|
|
3581
|
+
const shardAddress = subagentChatAddress(subagentAddressOfChild(shardChildId, rootSessionId, {
|
|
3582
|
+
...shardSource,
|
|
3583
|
+
kind: "expert",
|
|
3584
|
+
}));
|
|
3585
|
+
if (shardAddress !== null) addresses.add(shardAddress);
|
|
3586
|
+
}
|
|
3587
|
+
continue;
|
|
3588
|
+
}
|
|
3423
3589
|
const childId = text(source.sessionId, text(source.childId, null));
|
|
3424
3590
|
if (childId === null) continue;
|
|
3425
3591
|
const address = subagentChatAddress(subagentAddressOfChild(childId, rootSessionId, source));
|
|
@@ -3588,7 +3754,13 @@
|
|
|
3588
3754
|
}
|
|
3589
3755
|
};
|
|
3590
3756
|
|
|
3591
|
-
|
|
3757
|
+
/**
|
|
3758
|
+
* 第一阶段:既有「右栏 tab 记录/库存同步」(外部关闭观察、在途打开结算、批量签名更新)。
|
|
3759
|
+
*
|
|
3760
|
+
* 它保留了原有的提前 return(records 为空时不做任何 DOM 查询);自动同步因此被拆到
|
|
3761
|
+
* 独立的第二阶段(syncSidebarTabs),那些短路不会再吞掉自动打开/关闭。
|
|
3762
|
+
*/
|
|
3763
|
+
const syncSidebarTabRecords = () => {
|
|
3592
3764
|
// 右栏拖拽手柄锁复用这条既有生命周期(挂载 / body 子树变更 / 2.5 秒轮询 / 可见性变化):
|
|
3593
3765
|
// 活跃 tab 一变就重估,绝不为它单独再挂一个长期 observer。请求失败只影响这一次重估。
|
|
3594
3766
|
try { requestRightbarDragSync?.(); } catch { /* 手柄锁只是体验优化,绝不影响面板。 */ }
|
|
@@ -3631,6 +3803,22 @@
|
|
|
3631
3803
|
}
|
|
3632
3804
|
// 先完成精确 split 提交,再执行关闭同步;guide-only 或错误 pane 仍继续等待。
|
|
3633
3805
|
tryCommitSidebarSplit();
|
|
3806
|
+
/**
|
|
3807
|
+
* 手动意图的兜底结算扫描(在任何提前 return 之前):
|
|
3808
|
+
* 在途动作收尾、打开记录落地或超时都可能让一个意图变成可结算状态。数量级等于用户点击次数,
|
|
3809
|
+
* 因此每轮扫描的成本可以忽略;仍然在途的意图由 applyPendingManualIntent 自己跳过。
|
|
3810
|
+
* 别的 session/root 的条目在这里永远结算不了(它属于那个 surface 自己的闭包),直接删除,
|
|
3811
|
+
* 保证面板跨 root 复用时不泄漏手动意图。
|
|
3812
|
+
*/
|
|
3813
|
+
for (const key of [...sidebarManualIntentRef.current.keys()]) {
|
|
3814
|
+
const entry = sidebarManualIntentRef.current.get(key);
|
|
3815
|
+
if (entry === undefined) continue;
|
|
3816
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) {
|
|
3817
|
+
sidebarManualIntentRef.current.delete(key);
|
|
3818
|
+
continue;
|
|
3819
|
+
}
|
|
3820
|
+
applyPendingManualIntent(key);
|
|
3821
|
+
}
|
|
3634
3822
|
// 先完成打开记录,再执行关闭同步;同 pane 回落不再被转换成独立浮窗。
|
|
3635
3823
|
// 这里保留「没有打开记录就直接返回」的既有短路(它保证空闲时不会发生任何 DOM 查询);
|
|
3636
3824
|
// 批量控件的聚合状态更新已经在**这个短路之前**完成,因此 records 为空时 inventory 的变化
|
|
@@ -3660,6 +3848,433 @@
|
|
|
3660
3848
|
}
|
|
3661
3849
|
bump((value) => value + 1);
|
|
3662
3850
|
};
|
|
3851
|
+
|
|
3852
|
+
/**
|
|
3853
|
+
* 当前 sidebar session 的公开 inventory 里,某个资源地址对应的已提交 tabId。
|
|
3854
|
+
*
|
|
3855
|
+
* 返回值必须区分两种「没有」——这是能力降级 fail-closed 的唯一判据:
|
|
3856
|
+
* - readable === false:inventory 不可读(旧宿主 / 读取抛错)。此时既证明不了资源已打开,
|
|
3857
|
+
* 也证明不了它没打开,调用方一律不得据此自动打开(否则可能开出重复页);
|
|
3858
|
+
* - readable === true && tabId === null:读到了 inventory,确认这个地址没有已提交 tab。
|
|
3859
|
+
* 判定字段与 recordSidebarTab 逐条同源(同 session、kind、contentId),因此「自动打开前的
|
|
3860
|
+
* 存在性检查」与「关闭用的 tabId」永远来自同一个来源,绝不按「当前 active 是谁」猜 tab。
|
|
3861
|
+
*/
|
|
3862
|
+
const sidebarResourceTabStateOf = (resourceAddress) => {
|
|
3863
|
+
const inventory = readSidebarOpenTabs();
|
|
3864
|
+
if (inventory === null) return { readable: false, tabId: null };
|
|
3865
|
+
for (const entry of inventory) {
|
|
3866
|
+
const candidate = object(entry);
|
|
3867
|
+
if (text(candidate.sessionId, null) !== sidebarSessionId) continue;
|
|
3868
|
+
if (text(candidate.kind, null) !== SUBAGENT_CHAT_KIND) continue;
|
|
3869
|
+
if (text(candidate.contentId, null) !== resourceAddress) continue;
|
|
3870
|
+
return { readable: true, tabId: text(candidate.tabId, null) };
|
|
3871
|
+
}
|
|
3872
|
+
return { readable: true, tabId: null };
|
|
3873
|
+
};
|
|
3874
|
+
/**
|
|
3875
|
+
* active() 是否已经是这个资源(kind 与 contentId 同时匹配)。
|
|
3876
|
+
*
|
|
3877
|
+
* inventory 不可读或提交延迟时,它仍能证明「这个资源已经开着」:这样的 tab 只算手动/未知
|
|
3878
|
+
* 来源,Auto 既不接管(不写 auto 来源)也不重复打开;它只在「可追踪」判定里作为证据使用。
|
|
3879
|
+
*/
|
|
3880
|
+
const sidebarActiveResourceMatches = (resourceAddress) => {
|
|
3881
|
+
if (readActiveSidebarResource === null) return false;
|
|
3882
|
+
let tab = null;
|
|
3883
|
+
try { tab = object(readActiveSidebarResource.call(sidebarRight)); } catch { return false; }
|
|
3884
|
+
if (text(tab.kind, null) !== SUBAGENT_CHAT_KIND) return false;
|
|
3885
|
+
return text(tab.contentId, null) === resourceAddress;
|
|
3886
|
+
};
|
|
3887
|
+
/**
|
|
3888
|
+
* 自动打开必须同时具备的能力(缺一项即 fail-closed,绝不自动打开):
|
|
3889
|
+
* 1. 公开 close():没有它,Auto 打开的 tab 将来无法被精确回收(只能用 tabId 关闭,绝不改用
|
|
3890
|
+
* 批量入口或按 active 猜);
|
|
3891
|
+
* 2. 落地证据读面(active() 或 openTabs inventory)与左箭头图标:没有证据就无法确认这次打开
|
|
3892
|
+
* 有没有落地,也就没有可关闭的 << 状态;
|
|
3893
|
+
* 3. 公开 openResource():没有它连打开动作都发不出去。
|
|
3894
|
+
* 只约束自动路径:这些能力缺失时手动箭头与历史行为逐条不变。
|
|
3895
|
+
*/
|
|
3896
|
+
const sidebarAutoOpenCapable = () => openSidebarResource !== null
|
|
3897
|
+
&& closeSidebarResource !== null
|
|
3898
|
+
&& (readActiveSidebarResource !== null || sidebarOpenTabsAvailable === true)
|
|
3899
|
+
&& typeof primitives?.IconChevronLeftOutlineRegular === "function";
|
|
3900
|
+
|
|
3901
|
+
/** 当前 surface 自己的手动意图记录;别的 session/root 的键在当前闭包里一律不可见。 */
|
|
3902
|
+
const sidebarManualIntentOf = (key) => {
|
|
3903
|
+
const entry = sidebarManualIntentRef.current.get(key);
|
|
3904
|
+
if (entry === undefined) return undefined;
|
|
3905
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) return undefined;
|
|
3906
|
+
return entry;
|
|
3907
|
+
};
|
|
3908
|
+
/**
|
|
3909
|
+
* Auto 同步第二阶段的候选选择:先给一个「该关的」候选,再给一个「该开的」候选。
|
|
3910
|
+
*
|
|
3911
|
+
* 一个同步轮次最多产出一个候选(见 sidebarAutoBusyRef):多个候选由动作收尾后的同一条
|
|
3912
|
+
* 同步链路逐个推进,绝不并发发多个 split/openResource。
|
|
3913
|
+
*/
|
|
3914
|
+
const autoCloseCandidate = (running) => {
|
|
3915
|
+
const origin = sidebarAutoOriginRef.current;
|
|
3916
|
+
const records = sidebarTabRecordsRef.current;
|
|
3917
|
+
const openings = sidebarTabOpeningRef.current;
|
|
3918
|
+
const attempts = sidebarAutoCloseAttemptsRef.current;
|
|
3919
|
+
for (const [key, entry] of origin) {
|
|
3920
|
+
// 只处理当前 surface 的记账:别的 session/root 的键在当前快照里无法判定,绝不越权操作。
|
|
3921
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
|
|
3922
|
+
if (running.has(entry.address) === true) {
|
|
3923
|
+
// 资源重新变成运行中:它已经开着就保持开着,重试记账一并清零。
|
|
3924
|
+
attempts.delete(key);
|
|
3925
|
+
continue;
|
|
3926
|
+
}
|
|
3927
|
+
const record = records.get(key);
|
|
3928
|
+
const scopedRecord = record !== undefined
|
|
3929
|
+
&& record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId
|
|
3930
|
+
? record
|
|
3931
|
+
: undefined;
|
|
3932
|
+
if (scopedRecord === undefined) {
|
|
3933
|
+
// 没有已确认的 tabId 时绝不猜一个 id 去关:这次打开仍在途就继续等它落地,
|
|
3934
|
+
// 否则只撤销来源标记(元素已经不在这个 surface 上了)。
|
|
3935
|
+
const opening = openings.get(key);
|
|
3936
|
+
if (opening !== undefined && opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId) continue;
|
|
3937
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
3938
|
+
origin.delete(key);
|
|
3939
|
+
attempts.delete(key);
|
|
3940
|
+
continue;
|
|
3941
|
+
}
|
|
3942
|
+
if (scopedRecord.origin !== SIDEBAR_TAB_ORIGIN_AUTO) {
|
|
3943
|
+
// 记录已经被手动接管(用户手动打开或外部 tab):手动优先,撤销自动来源,绝不关闭它。
|
|
3944
|
+
origin.delete(key);
|
|
3945
|
+
attempts.delete(key);
|
|
3946
|
+
continue;
|
|
3947
|
+
}
|
|
3948
|
+
if (closeSidebarResource === null) {
|
|
3949
|
+
// 宿主没有公开 close(旧宿主):撤销记账,绝不退化成别的关闭方式。
|
|
3950
|
+
origin.delete(key);
|
|
3951
|
+
attempts.delete(key);
|
|
3952
|
+
continue;
|
|
3953
|
+
}
|
|
3954
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
3955
|
+
// 本次级联里已经为它尝试过并失败:同一批微任务里绝不重试(见 sidebarAutoFailedKeysRef)。
|
|
3956
|
+
if (sidebarAutoFailedKeysRef.current.has(key) === true) continue;
|
|
3957
|
+
// 用户刚刚在这个 key 上表达过手动意图:交给手动链路结算,Auto 本轮让位(手动恒优先)。
|
|
3958
|
+
if (sidebarManualIntentOf(key) !== undefined) continue;
|
|
3959
|
+
const used = attempts.get(key) ?? 0;
|
|
3960
|
+
if (used >= SIDEBAR_AUTO_CLOSE_MAX_ATTEMPTS) {
|
|
3961
|
+
// 有界重试用尽:只撤销来源标记,tab 留给用户(绝不无限重试,也绝不改用批量入口)。
|
|
3962
|
+
origin.delete(key);
|
|
3963
|
+
attempts.delete(key);
|
|
3964
|
+
continue;
|
|
3965
|
+
}
|
|
3966
|
+
attempts.set(key, used + 1);
|
|
3967
|
+
// attemptsBefore 让「动作在真正调用 close 之前被撤销」的路径能精确回退这一次尝试计数,
|
|
3968
|
+
// 否则一次被取消的自动关闭就会白烧掉一次有界重试预算。
|
|
3969
|
+
return { key, address: entry.address, kind: "close", attemptsBefore: used };
|
|
3970
|
+
}
|
|
3971
|
+
return null;
|
|
3972
|
+
};
|
|
3973
|
+
const autoOpenCandidate = (running) => {
|
|
3974
|
+
// 能力降级 fail-closed:close()/落地证据/左箭头任一缺失时绝不自动打开(见 sidebarAutoOpenCapable)。
|
|
3975
|
+
if (sidebarAutoOpenCapable() !== true) return null;
|
|
3976
|
+
const origin = sidebarAutoOriginRef.current;
|
|
3977
|
+
const suppressed = sidebarAutoSuppressedRef.current;
|
|
3978
|
+
const reservations = sidebarAutoReservationRef.current;
|
|
3979
|
+
const records = sidebarTabRecordsRef.current;
|
|
3980
|
+
const openings = sidebarTabOpeningRef.current;
|
|
3981
|
+
for (const address of running) {
|
|
3982
|
+
const key = sidebarTabKey(sidebarSessionId, rootSessionId, address);
|
|
3983
|
+
// 本次级联里已经为它尝试过并失败:绝不在同一批微任务里重试(见 sidebarAutoFailedKeysRef)。
|
|
3984
|
+
if (sidebarAutoFailedKeysRef.current.has(key) === true) continue;
|
|
3985
|
+
// 用户手动关闭过:绝不立刻重开(抑制只由用户手动重新打开清除)。
|
|
3986
|
+
if (suppressed.has(key) === true) continue;
|
|
3987
|
+
// 本次运行已经为它成功发出过打开动作:绝不重复打开(与失败无关,失败会撤销预留)。
|
|
3988
|
+
if (origin.has(key) === true) continue;
|
|
3989
|
+
// 已经有一个候选为它预留:同一资源绝不并发两个动作。
|
|
3990
|
+
if (reservations.has(key) === true) continue;
|
|
3991
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
3992
|
+
// 用户在同一个 key 上有未结算的手动意图:让手动链路先结算,Auto 本轮跳过。
|
|
3993
|
+
if (sidebarManualIntentOf(key) !== undefined) continue;
|
|
3994
|
+
const record = records.get(key);
|
|
3995
|
+
if (record !== undefined && record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId) continue;
|
|
3996
|
+
const opening = openings.get(key);
|
|
3997
|
+
if (opening !== undefined && opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId) continue;
|
|
3998
|
+
/**
|
|
3999
|
+
* 存在性检查必须「先可读、再确认没有」:
|
|
4000
|
+
* - inventory 不可读时无法证明资源没打开,fail-closed 跳过这个候选(宁可不动,也不开出重复页);
|
|
4001
|
+
* - inventory 可读但没有该地址、而 active() 已经是它(提交延迟 / 别的 pane):同样视为已经存在
|
|
4002
|
+
* 的手动/未知来源,不重复打开,也不写 auto 来源。
|
|
4003
|
+
*/
|
|
4004
|
+
const state = sidebarResourceTabStateOf(address);
|
|
4005
|
+
if (state.readable !== true) continue;
|
|
4006
|
+
if (state.tabId !== null) continue;
|
|
4007
|
+
if (sidebarActiveResourceMatches(address) === true) continue;
|
|
4008
|
+
/**
|
|
4009
|
+
* 先写**预留**再发动作:任何重入(observer/轮询/尾随同步)都不会为同一个资源再选一次。
|
|
4010
|
+
* 预留只是候选去重,绝不等于「Auto 已经打开」——只有 openResource 真的成功发出后才会
|
|
4011
|
+
* 升级成来源标记;失败路径在 handleSidebarAction 的 finally 里撤销它,使后续同步可以重试。
|
|
4012
|
+
*/
|
|
4013
|
+
const epoch = sidebarAutoEpochRef.current;
|
|
4014
|
+
reservations.set(key, { sessionId: sidebarSessionId, rootSessionId, address, epoch });
|
|
4015
|
+
trimAutoKeyMap(reservations);
|
|
4016
|
+
return { key, address, kind: "open", epoch };
|
|
4017
|
+
}
|
|
4018
|
+
return null;
|
|
4019
|
+
};
|
|
4020
|
+
/** 上限淘汰:按插入顺序丢弃最旧的键(复合键跨 root 保留时的安全阀,见 SIDEBAR_AUTO_KEY_LIMIT)。 */
|
|
4021
|
+
const trimAutoKeyMap = (map) => {
|
|
4022
|
+
while (map.size > SIDEBAR_AUTO_KEY_LIMIT) {
|
|
4023
|
+
const oldest = map.keys().next();
|
|
4024
|
+
if (oldest.done === true) break;
|
|
4025
|
+
map.delete(oldest.value);
|
|
4026
|
+
}
|
|
4027
|
+
};
|
|
4028
|
+
/**
|
|
4029
|
+
* 清理已经离开当前快照的复合键(来源与抑制两个集合),避免长期泄漏。
|
|
4030
|
+
*
|
|
4031
|
+
* 只清理当前 surface 的键:别的 session/root 的资源不在这一份快照里,无法判定是否仍存在。
|
|
4032
|
+
* 本轮已经选中的候选键必须保留——它正要被关闭,删掉记账会让这次关闭失去来源身份。
|
|
4033
|
+
*/
|
|
4034
|
+
const cleanupAutoSidebarKeys = (known, actingKey) => {
|
|
4035
|
+
for (const map of [sidebarAutoOriginRef.current, sidebarAutoSuppressedRef.current]) {
|
|
4036
|
+
for (const [key, entry] of map) {
|
|
4037
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
|
|
4038
|
+
if (key === actingKey) continue;
|
|
4039
|
+
if (known.has(entry.address) === true) continue;
|
|
4040
|
+
map.delete(key);
|
|
4041
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
// 预留同样要清理,但**在途动作自己那一条绝不能被清理**:它的 finally 才是唯一的释放点,
|
|
4045
|
+
// 提前删掉会让同一条动作在收尾时把后来者的预留一起删掉。
|
|
4046
|
+
for (const [key, entry] of sidebarAutoReservationRef.current) {
|
|
4047
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
|
|
4048
|
+
if (key === actingKey) continue;
|
|
4049
|
+
if (known.has(entry.address) === true) continue;
|
|
4050
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
4051
|
+
sidebarAutoReservationRef.current.delete(key);
|
|
4052
|
+
}
|
|
4053
|
+
};
|
|
4054
|
+
/**
|
|
4055
|
+
* 放行一个自动动作。
|
|
4056
|
+
*
|
|
4057
|
+
* 复用用户点击的同一条入口(handleSidebarAction + origin=auto):同一把 pending 锁、同一段
|
|
4058
|
+
* split 事务、同一套「用真实 tabId 关闭」的记录语义,因此自动路径不存在第二套打开/关闭实现。
|
|
4059
|
+
* 动作收尾后才重新推进同步,下一个候选(或清理)由那条链路继续。
|
|
4060
|
+
*/
|
|
4061
|
+
const launchAutoSidebarAction = (candidate) => {
|
|
4062
|
+
sidebarAutoBusyRef.current = true;
|
|
4063
|
+
void Promise.resolve()
|
|
4064
|
+
// candidate 作为自动动作上下文(kind/epoch/预留)传入:动作必须在每个 await 之后
|
|
4065
|
+
// 复核它是否仍然有效,并在收尾时无条件释放预留(见 handleSidebarAction)。
|
|
4066
|
+
.then(() => handleSidebarAction(candidate.key, candidate.address, SIDEBAR_TAB_ORIGIN_AUTO, candidate))
|
|
4067
|
+
.catch(() => { /* 打开/关闭失败只影响这一次动作:状态由既有记录与重试记账负责。 */ })
|
|
4068
|
+
.then(() => {
|
|
4069
|
+
sidebarAutoBusyRef.current = false;
|
|
4070
|
+
/**
|
|
4071
|
+
* 尾随同步只用来推进「下一个候选」。它必须带着级联标记执行:这一轮里失败过的 key
|
|
4072
|
+
* 不会被重新选中,因此「失败 → 撤销预留 → 立刻重选」不在微任务里自转(见
|
|
4073
|
+
* sidebarAutoFailedKeysRef)。真正的重试留给下一个外部同步轮。
|
|
4074
|
+
*/
|
|
4075
|
+
sidebarAutoCascadeRef.current = true;
|
|
4076
|
+
try {
|
|
4077
|
+
// 用引用取最新一轮渲染的同步器(与 observer/轮询同一写法),绝不复用当前闭包。
|
|
4078
|
+
sidebarTabSyncRef.current?.();
|
|
4079
|
+
} finally {
|
|
4080
|
+
sidebarAutoCascadeRef.current = false;
|
|
4081
|
+
}
|
|
4082
|
+
});
|
|
4083
|
+
};
|
|
4084
|
+
/**
|
|
4085
|
+
* 第二阶段:Auto(自动打开运行中的子代理侧栏)同步。
|
|
4086
|
+
*
|
|
4087
|
+
* 顺序固定为「先关后开」,并且只在有属于当前 root 的真实快照时动作:
|
|
4088
|
+
* 1. 关闭:Auto 亲手打开过的资源一旦不在运行中集合里,就用**已确认记录里的真实 tabId** 走
|
|
4089
|
+
* 既有 handleSidebarAction 关闭路径(绝不猜 tabId、绝不调用批量关闭);
|
|
4090
|
+
* 2. 打开:运行中、尚未打开、且没有被用户手动关闭抑制的资源走既有打开路径;
|
|
4091
|
+
* 3. 清理:把已经离开当前快照的复合键从来源/抑制集合里移除。
|
|
4092
|
+
*
|
|
4093
|
+
* 开关关闭时不做任何自动 open/close(历史行为逐字节不变),已打开的 tab 交由用户手动处理;
|
|
4094
|
+
* 清理阶段与开关无关,它只维护插件自己的记账,不触碰宿主。
|
|
4095
|
+
*/
|
|
4096
|
+
const syncAutoSidebarTabs = () => {
|
|
4097
|
+
const source = snapshot;
|
|
4098
|
+
// 没有快照(首次加载 / 读取失败)时没有「谁在运行」的证据:既不打开也不关闭。
|
|
4099
|
+
if (source === null || source === undefined) return;
|
|
4100
|
+
// root 切换瞬间旧快照可能仍在 state 里:只有属于当前 root 的快照才能决定动作。
|
|
4101
|
+
if (text(source.rootSessionId, null) !== rootSessionId) return;
|
|
4102
|
+
const known = knownSidebarResourceAddresses();
|
|
4103
|
+
const running = runningSidebarResourceAddresses();
|
|
4104
|
+
const enabled = autoOpenEnabled();
|
|
4105
|
+
let candidate = null;
|
|
4106
|
+
if (enabled === true && sidebarAutoBusyRef.current !== true) {
|
|
4107
|
+
candidate = autoCloseCandidate(running) ?? autoOpenCandidate(running);
|
|
4108
|
+
}
|
|
4109
|
+
cleanupAutoSidebarKeys(known, candidate === null ? null : candidate.key);
|
|
4110
|
+
if (candidate === null) return;
|
|
4111
|
+
launchAutoSidebarAction(candidate);
|
|
4112
|
+
};
|
|
4113
|
+
|
|
4114
|
+
/**
|
|
4115
|
+
* 自动动作是否仍然有效:Auto 仍开启、动作代次未变、面板仍挂在同一个 root / sidebar session 上。
|
|
4116
|
+
*
|
|
4117
|
+
* 这是「关闭 Auto 取消在途打开」的判据:Auto getter 每次现读(权威读面),代次在开关变化时
|
|
4118
|
+
* 递增,surface 身份用与 handleSidebarAction 入口相同的三个 ref 复核。任一项不成立,那次自动
|
|
4119
|
+
* 动作一律放弃副作用。
|
|
4120
|
+
*/
|
|
4121
|
+
const autoActionStillValid = (context) => {
|
|
4122
|
+
if (context === null || context === undefined) return false;
|
|
4123
|
+
if (autoOpenEnabled() !== true) return false;
|
|
4124
|
+
if (sidebarAutoEpochRef.current !== context.epoch) return false;
|
|
4125
|
+
return mountedRef.current === true
|
|
4126
|
+
&& currentRootRef.current === rootSessionId
|
|
4127
|
+
&& currentSidebarSessionRef.current === sidebarSessionId;
|
|
4128
|
+
};
|
|
4129
|
+
/**
|
|
4130
|
+
* 撤销一次自动动作的在途记账(Auto 关闭 / 代次过期 / 手动接管 / 失败路径共用)。
|
|
4131
|
+
*
|
|
4132
|
+
* 三件事缺一不可:
|
|
4133
|
+
* 1. 收口这次动作开出的 split 事务:否则右栏会停在事务的临时 50%,直到 15 秒 deadline 才回落;
|
|
4134
|
+
* 2. 删掉这个 key 的在途打开记录:这次打开没有发出,不能让后续同步把它当成「还没确认的打开」;
|
|
4135
|
+
* 3. 释放预留:失败绝不等于「已经打开过」,下一次同步必须能重新选中这个资源。
|
|
4136
|
+
* 来源标记只在 openResource 成功发出时才存在,因此这里不删除它(关闭类动作被撤销后,
|
|
4137
|
+
* 那个 tab 仍然属于 Auto,开关重新打开后仍可被自动回收)。
|
|
4138
|
+
*/
|
|
4139
|
+
const abandonAutoSidebarAction = (key, context) => {
|
|
4140
|
+
if (sidebarSplitIntentRef.current?.key === key) cancelPendingSidebarSplit("auto-abandoned");
|
|
4141
|
+
sidebarTabOpeningRef.current.delete(key);
|
|
4142
|
+
sidebarAutoReservationRef.current.delete(key);
|
|
4143
|
+
releaseAutoCloseAttempt(key, context);
|
|
4144
|
+
};
|
|
4145
|
+
/** 回退候选选择时预支的那一次自动关闭重试(动作在真正调用 close 之前被撤销时调用)。 */
|
|
4146
|
+
const releaseAutoCloseAttempt = (key, context) => {
|
|
4147
|
+
if (context === null || context === undefined || context.kind !== "close") return;
|
|
4148
|
+
const attempts = sidebarAutoCloseAttemptsRef.current;
|
|
4149
|
+
if (attempts.has(key) !== true) return;
|
|
4150
|
+
if (context.attemptsBefore === 0) attempts.delete(key);
|
|
4151
|
+
else attempts.set(key, context.attemptsBefore);
|
|
4152
|
+
};
|
|
4153
|
+
/**
|
|
4154
|
+
* 自动动作在副作用之前必须让位的「手动接管」状态:
|
|
4155
|
+
* - "close":用户在同一个 key 上留下过关闭意图——Auto 绝不能在关闭意图之后再打开一个 tab;
|
|
4156
|
+
* - "open":用户手动要求打开这个资源——打开照常发出,但最终 tab 必须记成 manual 来源;
|
|
4157
|
+
* - null:没有手动接管。
|
|
4158
|
+
*/
|
|
4159
|
+
const autoTakeoverStateOf = (key) => {
|
|
4160
|
+
const intent = sidebarManualIntentOf(key);
|
|
4161
|
+
if (intent === undefined) return null;
|
|
4162
|
+
return intent.intent === "close" ? "close" : "open";
|
|
4163
|
+
};
|
|
4164
|
+
/**
|
|
4165
|
+
* 记录一次「在途动作期间」的用户手动意图。
|
|
4166
|
+
*
|
|
4167
|
+
* 触发点只有两个(用户点击落在一个已被 pending 锁或未落地打开占用的 key 上):意图方向按
|
|
4168
|
+
* 点击那一刻的按钮语义判定——已经有记录(<<)表示要关闭,没有记录(>>)表示要打开,
|
|
4169
|
+
* 绝不按动作在途时的中间态猜方向。方向判定与 handleSidebarAction 的分支条件同源。
|
|
4170
|
+
*/
|
|
4171
|
+
const recordSidebarManualIntent = (key, resourceAddress) => {
|
|
4172
|
+
const record = sidebarTabRecordsRef.current.get(key);
|
|
4173
|
+
const scoped = record !== undefined
|
|
4174
|
+
&& record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId;
|
|
4175
|
+
sidebarManualIntentRef.current.set(key, {
|
|
4176
|
+
sessionId: sidebarSessionId,
|
|
4177
|
+
rootSessionId,
|
|
4178
|
+
address: resourceAddress,
|
|
4179
|
+
intent: scoped ? "close" : "open",
|
|
4180
|
+
});
|
|
4181
|
+
};
|
|
4182
|
+
/**
|
|
4183
|
+
* 结算一个 key 上的手动意图(在途动作收尾后,以及同步的兜底扫描里调用)。
|
|
4184
|
+
*
|
|
4185
|
+
* 手动恒优先,但**只按当下的真实状态结算**,绝不盲目重放那次点击:
|
|
4186
|
+
* - 还有动作在途(pending)或打开尚未落地:这一轮什么都不做,等下一次收尾再结算;
|
|
4187
|
+
* - 「打开」:已经开着就把来源改回 manual(Auto 不再管它);还没开出才补发一次**手动**打开,
|
|
4188
|
+
* 走与点击完全相同的入口(唯一区别是 origin=manual,因此后续绝不会被自动关闭);
|
|
4189
|
+
* - 「关闭」:有精确 tabId 时走同一入口关闭并登记抑制;没有可关闭的 tab 时只登记抑制,
|
|
4190
|
+
* 绝不为了「执行这次关闭」而先打开一个 tab。
|
|
4191
|
+
*/
|
|
4192
|
+
const applyPendingManualIntent = (key) => {
|
|
4193
|
+
const entry = sidebarManualIntentOf(key);
|
|
4194
|
+
if (entry === undefined) return;
|
|
4195
|
+
// 同一 key 仍有动作在途:它收尾时会再结算一次,绝不在这里并发派发第二个动作。
|
|
4196
|
+
if (sidebarTabPendingRef.current.has(key) === true) return;
|
|
4197
|
+
const opening = sidebarTabOpeningRef.current.get(key);
|
|
4198
|
+
const openingScoped = opening !== undefined
|
|
4199
|
+
&& opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId;
|
|
4200
|
+
const record = sidebarTabRecordsRef.current.get(key);
|
|
4201
|
+
const recordScoped = record !== undefined
|
|
4202
|
+
&& record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId;
|
|
4203
|
+
if (entry.intent === "open") {
|
|
4204
|
+
if (openingScoped) {
|
|
4205
|
+
// 这次打开仍在途:把它的来源改成 manual,落地后就是手动 tab(绝不并发第二次 openResource)。
|
|
4206
|
+
opening.origin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
4207
|
+
sidebarManualIntentRef.current.delete(key);
|
|
4208
|
+
return;
|
|
4209
|
+
}
|
|
4210
|
+
if (recordScoped) {
|
|
4211
|
+
// 用户手动打开 = 他重新要这个会话:清除抑制,并把来源收回 manual(Auto 不得再自动关它)。
|
|
4212
|
+
record.origin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
4213
|
+
sidebarAutoSuppressedRef.current.delete(key);
|
|
4214
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
4215
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
4216
|
+
sidebarManualIntentRef.current.delete(key);
|
|
4217
|
+
return;
|
|
4218
|
+
}
|
|
4219
|
+
sidebarManualIntentRef.current.delete(key);
|
|
4220
|
+
void handleSidebarAction(key, entry.address, SIDEBAR_TAB_ORIGIN_MANUAL);
|
|
4221
|
+
return;
|
|
4222
|
+
}
|
|
4223
|
+
// intent === "close"
|
|
4224
|
+
if (recordScoped) {
|
|
4225
|
+
sidebarManualIntentRef.current.delete(key);
|
|
4226
|
+
void handleSidebarAction(key, entry.address, SIDEBAR_TAB_ORIGIN_MANUAL);
|
|
4227
|
+
return;
|
|
4228
|
+
}
|
|
4229
|
+
if (openingScoped) {
|
|
4230
|
+
/**
|
|
4231
|
+
* 打开仍在途、此刻没有可精确关闭的 tabId:抑制先登记(用户要的是关闭,Auto 不得重开),
|
|
4232
|
+
* 意图保留到它落地或超时——由同一条同步链路再结算一次,绝不在没有 tabId 时猜一个 id 去关。
|
|
4233
|
+
*/
|
|
4234
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
|
|
4235
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
// 没有可关闭的 tab:只登记抑制与撤销自动记账,绝不为了「执行关闭」先打开一个 tab。
|
|
4239
|
+
sidebarManualIntentRef.current.delete(key);
|
|
4240
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
|
|
4241
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
4242
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
4243
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
4244
|
+
};
|
|
4245
|
+
|
|
4246
|
+
/**
|
|
4247
|
+
* 右栏 tab 的完整同步入口(挂载 effect / 2.5 秒轮询 / sessions 通知 / observer / 可见性变化
|
|
4248
|
+
* 全部只经由它):先跑既有的记录/库存同步,再跑 Auto 自动同步。
|
|
4249
|
+
*
|
|
4250
|
+
* 顺序不可颠倒:自动打开必须建立在「库存与在途记录已经结算完」的基础上,否则同一个资源
|
|
4251
|
+
* 会被先记录再打开,重复消耗 pane。
|
|
4252
|
+
*/
|
|
4253
|
+
const syncSidebarTabs = () => {
|
|
4254
|
+
/**
|
|
4255
|
+
* 外部同步轮(挂载 / 2.5 秒轮询 / observer / 可见性变化 / 会话通知)都是新的一轮:自动动作的
|
|
4256
|
+
* 失败屏障在这里失效,因此同一个资源仍然可以被重试。自动动作收尾的那条尾随同步例外——它
|
|
4257
|
+
* 只是同一批微任务里的推进,不构成「宿主状态可能已经改变」的新证据。
|
|
4258
|
+
*/
|
|
4259
|
+
if (sidebarAutoCascadeRef.current !== true) sidebarAutoFailedKeysRef.current.clear();
|
|
4260
|
+
syncSidebarTabRecords();
|
|
4261
|
+
// Auto 开关是渲染输入:设置页保存或面板自身切换后必须重渲染一次(标题栏开关的勾选态)。
|
|
4262
|
+
// 与批量签名同一模式:只有真的变化才 bump,空闲轮询绝不产生重渲染。
|
|
4263
|
+
const autoNow = autoOpenEnabled();
|
|
4264
|
+
if (sidebarAutoSignatureRef.current !== autoNow) {
|
|
4265
|
+
sidebarAutoSignatureRef.current = autoNow;
|
|
4266
|
+
/**
|
|
4267
|
+
* 开关的权威值一变(含关闭)就推进自动动作代次:已经发出、仍在等待 split/layout 的在途
|
|
4268
|
+
* 自动动作会在下一次复核时放弃——「关闭 Auto」必须立刻取消还没落地的自动打开,而不是
|
|
4269
|
+
* 等它把 tab 打开之后再交给用户手动关。已存在的 tab 不受影响(关闭 Auto 绝不自动关 tab)。
|
|
4270
|
+
*/
|
|
4271
|
+
sidebarAutoEpochRef.current += 1;
|
|
4272
|
+
// 权威值变化后丢弃本地乐观值,开关始终收敛到读出的事实。
|
|
4273
|
+
setAutoOpenDraft(null);
|
|
4274
|
+
bump((value) => value + 1);
|
|
4275
|
+
}
|
|
4276
|
+
syncAutoSidebarTabs();
|
|
4277
|
+
};
|
|
3663
4278
|
// 挂载 effect 与轮询/observer 回调都只经由这个引用取最新闭包(见 sidebarTabSyncRef 的说明)。
|
|
3664
4279
|
sidebarTabSyncRef.current = syncSidebarTabs;
|
|
3665
4280
|
|
|
@@ -3671,12 +4286,19 @@
|
|
|
3671
4286
|
* - `kind === "subagentchat"`:active 可能仍是用户原本在看的别的 tab;
|
|
3672
4287
|
* - `contentId === 资源地址`:同一个 kind 下也可能是另一个子代理的地址。
|
|
3673
4288
|
* 不满足时保持 >> 的打开语义(按钮照旧可再次点击打开),绝不猜测 tabId。
|
|
4289
|
+
*
|
|
4290
|
+
* origin 的来源优先级:显式传入的动作来源 > 在途打开记录里记下的来源 > 已存在记录的来源 >
|
|
4291
|
+
* **manual**。最后那条兜底正是规格要求:「外部已存在但未由自动动作发起的 tab 视为手动来源」,
|
|
4292
|
+
* 因此自动同步永远关不掉一个它没有亲手打开过的 tab。
|
|
3674
4293
|
*/
|
|
3675
|
-
const recordSidebarTab = (key, resourceAddress) => {
|
|
4294
|
+
const recordSidebarTab = (key, resourceAddress, actionOrigin) => {
|
|
3676
4295
|
if (closeSidebarResource === null) return false;
|
|
3677
4296
|
if (readActiveSidebarResource === null && readSidebarOpenTabs() === null) return false;
|
|
3678
4297
|
// 左箭头图标缺失时不会渲染 <<,因此也不该建立只在 << 上才有意义的记录。
|
|
3679
4298
|
if (typeof primitives?.IconChevronLeftOutlineRegular !== "function") return false;
|
|
4299
|
+
const declaredOrigin = actionOrigin === SIDEBAR_TAB_ORIGIN_AUTO || actionOrigin === SIDEBAR_TAB_ORIGIN_MANUAL
|
|
4300
|
+
? actionOrigin
|
|
4301
|
+
: null;
|
|
3680
4302
|
let tab = null;
|
|
3681
4303
|
const inventory = readSidebarOpenTabs();
|
|
3682
4304
|
if (inventory !== null) {
|
|
@@ -3708,13 +4330,32 @@
|
|
|
3708
4330
|
if (text(source.kind, null) !== SUBAGENT_CHAT_KIND) return false;
|
|
3709
4331
|
if (text(source.contentId, null) !== resourceAddress) return false;
|
|
3710
4332
|
const previous = sidebarTabRecordsRef.current.get(key);
|
|
4333
|
+
const opening = sidebarTabOpeningRef.current.get(key);
|
|
4334
|
+
/**
|
|
4335
|
+
* 手动接管优先于调用方声明的来源:用户在那个 key 上留下过「要打开」的手动意图时,即使这次
|
|
4336
|
+
* 打开是 Auto 发出的,最终 tab 也必须记成 manual——手动打开的最终来源必须是 manual,
|
|
4337
|
+
* Auto 绝不能在资源结束后把它当成自己的 tab 关掉。
|
|
4338
|
+
*/
|
|
4339
|
+
const manualTakeover = sidebarManualIntentOf(key);
|
|
4340
|
+
const origin = manualTakeover !== undefined && manualTakeover.intent === "open"
|
|
4341
|
+
? SIDEBAR_TAB_ORIGIN_MANUAL
|
|
4342
|
+
: (declaredOrigin
|
|
4343
|
+
?? (previous !== undefined && previous.sessionId === sidebarSessionId && previous.rootSessionId === rootSessionId
|
|
4344
|
+
? previous.origin
|
|
4345
|
+
: null)
|
|
4346
|
+
?? (opening !== undefined && opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId
|
|
4347
|
+
? opening.origin
|
|
4348
|
+
: null)
|
|
4349
|
+
?? SIDEBAR_TAB_ORIGIN_MANUAL);
|
|
4350
|
+
const recordOrigin = origin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3711
4351
|
if (previous !== undefined && previous.sessionId === sidebarSessionId
|
|
3712
4352
|
&& previous.rootSessionId === rootSessionId && previous.tabId === tabId) {
|
|
3713
4353
|
previous.title = tabTitle ?? previous.title;
|
|
3714
4354
|
previous.misses = 0;
|
|
4355
|
+
previous.origin = recordOrigin;
|
|
3715
4356
|
return true;
|
|
3716
4357
|
}
|
|
3717
|
-
sidebarTabRecordsRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, tabId, title: tabTitle, misses: 0 });
|
|
4358
|
+
sidebarTabRecordsRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, tabId, title: tabTitle, misses: 0, origin: recordOrigin });
|
|
3718
4359
|
bump((value) => value + 1);
|
|
3719
4360
|
return true;
|
|
3720
4361
|
};
|
|
@@ -3726,18 +4367,68 @@
|
|
|
3726
4367
|
* 宿主的提交,重入点击若穿过检查就会发出第二次 openResource(第二个重复 tab)或提前走关闭。
|
|
3727
4368
|
* 抛错处理:openResource / close 抛错只影响这一次动作,绝不写状态——打开失败保持 >>,
|
|
3728
4369
|
* 关闭失败保持 <<(用户可以再点一次),两种情况都不会把按钮和真实世界对不上。
|
|
4370
|
+
*
|
|
4371
|
+
* origin 是**内部**参数,默认 manual:普通 UI 调用(箭头、键盘)一律手动来源;只有自动同步
|
|
4372
|
+
* 显式传 auto。它决定三件事:记录来源、手动动作是否清除抑制/自动来源、关闭时是否登记抑制。
|
|
4373
|
+
*
|
|
4374
|
+
* autoAction 也是**内部**参数,只在自动路径上出现:{ kind, epoch, attemptsBefore }。它让同一条
|
|
4375
|
+
* 实现能在每个 await 之后、真正调用宿主 open/close 之前复核「Auto 是否仍然开启、动作是否已过期、
|
|
4376
|
+
* 用户是否已经手动接管」,并在收尾时无条件释放在途预留(见 finally)。
|
|
3729
4377
|
*/
|
|
3730
|
-
const handleSidebarAction = async (key, resourceAddress) => {
|
|
4378
|
+
const handleSidebarAction = async (key, resourceAddress, actionOrigin, autoAction) => {
|
|
4379
|
+
const origin = actionOrigin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
4380
|
+
const autoContext = origin === SIDEBAR_TAB_ORIGIN_AUTO && autoAction !== null && typeof autoAction === "object"
|
|
4381
|
+
? {
|
|
4382
|
+
kind: autoAction.kind === "close" ? "close" : "open",
|
|
4383
|
+
epoch: Number.isFinite(Number(autoAction.epoch)) ? Number(autoAction.epoch) : sidebarAutoEpochRef.current,
|
|
4384
|
+
attemptsBefore: Number.isFinite(Number(autoAction.attemptsBefore)) ? Number(autoAction.attemptsBefore) : 0,
|
|
4385
|
+
}
|
|
4386
|
+
: null;
|
|
3731
4387
|
// React 重挂载或 root 切换后,旧 DOM 可能短暂保留旧 handler;它不能操作当前 session-scoped 服务。
|
|
3732
4388
|
if (mountedRef.current !== true
|
|
3733
4389
|
|| currentRootRef.current !== rootSessionId
|
|
3734
|
-
|| currentSidebarSessionRef.current !== sidebarSessionId)
|
|
4390
|
+
|| currentSidebarSessionRef.current !== sidebarSessionId) {
|
|
4391
|
+
/**
|
|
4392
|
+
* 连入口都没通过的自动动作:这里必须自己释放候选选择时写下的预留(以及自动关闭的重试记账)。
|
|
4393
|
+
* 这些键属于已经被换掉的 surface,当前闭包之后再也不会为它们做清理。
|
|
4394
|
+
*/
|
|
4395
|
+
if (autoContext !== null) {
|
|
4396
|
+
sidebarAutoReservationRef.current.delete(key);
|
|
4397
|
+
releaseAutoCloseAttempt(key, autoContext);
|
|
4398
|
+
}
|
|
4399
|
+
return;
|
|
4400
|
+
}
|
|
3735
4401
|
const pending = sidebarTabPendingRef.current;
|
|
3736
|
-
if (pending.has(key))
|
|
4402
|
+
if (pending.has(key)) {
|
|
4403
|
+
/**
|
|
4404
|
+
* 同一个 key 上已经有动作在途(典型:Auto 正在等 split 返回 pane / 等布局提交 / 等打开落地)。
|
|
4405
|
+
* 用户此刻的手动点击绝不能因为「锁被占用」而消失:它被记成手动意图,由在途动作在副作用之前
|
|
4406
|
+
* 让位、或由本函数收尾后立即结算(见 applyPendingManualIntent)。手动意图只在手动路径上记录。
|
|
4407
|
+
*/
|
|
4408
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) recordSidebarManualIntent(key, resourceAddress);
|
|
4409
|
+
return;
|
|
4410
|
+
}
|
|
3737
4411
|
pending.add(key);
|
|
4412
|
+
/** 本次动作是否真的把 openResource 发给了宿主:只有它才允许建立 auto 来源标记。 */
|
|
4413
|
+
let openIssued = false;
|
|
3738
4414
|
try {
|
|
4415
|
+
/**
|
|
4416
|
+
* 副作用之前的第一道复核(自动路径):Auto 开关的权威值与动作代次都可能在等待期间变化。
|
|
4417
|
+
* 关闭 Auto 时必须在这里放弃——包括「已经 split 出 pane」的前一阶段遗留在途记录的情况。
|
|
4418
|
+
*/
|
|
4419
|
+
if (autoContext !== null && autoActionStillValid(autoContext) !== true) {
|
|
4420
|
+
abandonAutoSidebarAction(key, autoContext);
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
3739
4423
|
const record = sidebarTabRecordsRef.current.get(key);
|
|
3740
|
-
|
|
4424
|
+
if (record === undefined && sidebarTabOpeningRef.current.has(key)) {
|
|
4425
|
+
/**
|
|
4426
|
+
* 打开已经发出、落地证据还没到:手动点击同样不能丢。按意图记账,等它落地(或超时)后
|
|
4427
|
+
* 由同步链路结算;在这之前绝不补发第二次 openResource,也绝不猜 tabId 去关闭。
|
|
4428
|
+
*/
|
|
4429
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) recordSidebarManualIntent(key, resourceAddress);
|
|
4430
|
+
return;
|
|
4431
|
+
}
|
|
3741
4432
|
// 关闭态:只调用记录里的精确 tabId。绝不根据 active() 当前是谁来决定关哪个 tab。
|
|
3742
4433
|
if (record !== undefined && closeSidebarResource !== null) {
|
|
3743
4434
|
let closed = false;
|
|
@@ -3756,6 +4447,19 @@
|
|
|
3756
4447
|
*/
|
|
3757
4448
|
if (sidebarSplitIntentRef.current?.key === key) cancelPendingSidebarSplit("close");
|
|
3758
4449
|
sidebarTabRecordsRef.current.delete(key);
|
|
4450
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
4451
|
+
/**
|
|
4452
|
+
* 关闭成功后收口来源与抑制记账(两种开放的语义差别就在这两行):
|
|
4453
|
+
*
|
|
4454
|
+
* - 自动关闭:只撤销来源标记——本次运行不会再对同一个资源重复动作;
|
|
4455
|
+
* - 手动关闭:除撤销来源外还要登记**抑制**,Auto 不得立刻把它重新打开。抑制只由
|
|
4456
|
+
* 用户手动重新打开同一资源清除(手动意图优先)。
|
|
4457
|
+
*/
|
|
4458
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
4459
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
|
|
4460
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
4461
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
4462
|
+
}
|
|
3759
4463
|
bump((value) => value + 1);
|
|
3760
4464
|
// 关闭改变了右栏组成,必须立刻重估一次:宽度与手柄锁都在同一条同步链路上。
|
|
3761
4465
|
syncSidebarTabs();
|
|
@@ -3763,6 +4467,25 @@
|
|
|
3763
4467
|
return;
|
|
3764
4468
|
}
|
|
3765
4469
|
if (openSidebarResource === null) return;
|
|
4470
|
+
/**
|
|
4471
|
+
* 自动动作的副作用复核点:真实的用户可见副作用(split / openResource)之前必须重新确认
|
|
4472
|
+
* 「Auto 仍开启」「动作代次没过期」「用户没有在同一个 key 上手动接管」。返回值:
|
|
4473
|
+
* - "proceed":照常执行;
|
|
4474
|
+
* - "abort":Auto 已关闭/动作已过期,或用户要求关闭 → 撤销在途记账,绝不发出副作用;
|
|
4475
|
+
* - "manual":用户手动要求打开 → 动作可以继续,但最终 tab 必须记成 manual 来源。
|
|
4476
|
+
* 手动路径(autoContext === null)永远是 "proceed",历史行为逐字不变。
|
|
4477
|
+
*/
|
|
4478
|
+
const reviewAutoSideEffect = () => {
|
|
4479
|
+
if (autoContext === null) return "proceed";
|
|
4480
|
+
if (autoContext === null) return "proceed";
|
|
4481
|
+
if (autoActionStillValid(autoContext) !== true) return "abort";
|
|
4482
|
+
const takeover = autoTakeoverStateOf(key);
|
|
4483
|
+
if (takeover === "close") return "abort";
|
|
4484
|
+
if (takeover === "open") return "manual";
|
|
4485
|
+
return "proceed";
|
|
4486
|
+
};
|
|
4487
|
+
/** 本次打开最终要记的来源:自动动作只在没有手动接管时才是 auto。 */
|
|
4488
|
+
let effectiveOrigin = origin;
|
|
3766
4489
|
const canTrackOpen = closeSidebarResource !== null
|
|
3767
4490
|
&& (readActiveSidebarResource !== null || sidebarOpenTabsAvailable === true)
|
|
3768
4491
|
&& typeof primitives?.IconChevronLeftOutlineRegular === "function";
|
|
@@ -3774,8 +4497,21 @@
|
|
|
3774
4497
|
rootSessionId,
|
|
3775
4498
|
resourceAddress,
|
|
3776
4499
|
expiresAt: Date.now() + SIDEBAR_TAB_OPEN_CONFIRMATION_TIMEOUT_MS,
|
|
4500
|
+
origin,
|
|
3777
4501
|
});
|
|
3778
4502
|
}
|
|
4503
|
+
/**
|
|
4504
|
+
* 手动打开的意图优先于自动记账:
|
|
4505
|
+
*
|
|
4506
|
+
* - 清除抑制:用户手动重新打开 = 他重新要这个会话,Auto 之后可以继续管理它;
|
|
4507
|
+
* - 撤销自动来源:这次打开是手动来源,Auto 绝不能在资源结束时把它当成自己的 tab 关掉。
|
|
4508
|
+
* 两条都在发动作之前写,因此这一段没有 await,不存在与自动同步交叉的窗口。
|
|
4509
|
+
*/
|
|
4510
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
|
|
4511
|
+
sidebarAutoSuppressedRef.current.delete(key);
|
|
4512
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
4513
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
4514
|
+
}
|
|
3779
4515
|
const targetPaneId = existingSubagentPaneId();
|
|
3780
4516
|
let openOptions = null;
|
|
3781
4517
|
let splitToken = null;
|
|
@@ -3833,6 +4569,21 @@
|
|
|
3833
4569
|
sidebarTabOpeningRef.current.delete(key);
|
|
3834
4570
|
return;
|
|
3835
4571
|
}
|
|
4572
|
+
/**
|
|
4573
|
+
* 布局等待是这条路径上唯一会跨越用户操作窗口的 await:用户可能在这里关闭了 Auto,
|
|
4574
|
+
* 也可能在同一个 key 上手动接管。此时连 split() 都还没调用,直接放弃是代价最小的时机。
|
|
4575
|
+
*/
|
|
4576
|
+
const afterLayoutReview = reviewAutoSideEffect();
|
|
4577
|
+
if (afterLayoutReview === "abort") {
|
|
4578
|
+
abandonAutoSidebarAction(key, autoContext);
|
|
4579
|
+
if (autoTakeoverStateOf(key) === "close") {
|
|
4580
|
+
// 用户要的是关闭:立刻登记抑制,Auto 不得在下一轮同步里把它重新选成候选。
|
|
4581
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
4582
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
4583
|
+
}
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
if (afterLayoutReview === "manual") effectiveOrigin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3836
4587
|
}
|
|
3837
4588
|
let paneId = null;
|
|
3838
4589
|
try { paneId = splitSidebarPane.call(sidebarRight); } catch { paneId = null; }
|
|
@@ -3864,7 +4615,33 @@
|
|
|
3864
4615
|
// 旧宿主没有公开 split():保留历史 preferNewPane 请求,兼容旧版本。
|
|
3865
4616
|
openOptions = { kind: SUBAGENT_CHAT_KIND, preferNewPane: true };
|
|
3866
4617
|
}
|
|
4618
|
+
/**
|
|
4619
|
+
* 真正发出副作用之前的最后一道复核:从「读记录」到这里可能已经跨过 split 前的布局等待,
|
|
4620
|
+
* Auto 开关与用户手动意图都可能变化。只有通过复核才允许调用宿主 openResource()——绝不把
|
|
4621
|
+
* 一个已经关掉的 Auto 变成一次迟到的自动打开。
|
|
4622
|
+
*/
|
|
4623
|
+
const beforeOpenReview = reviewAutoSideEffect();
|
|
4624
|
+
if (beforeOpenReview === "abort") {
|
|
4625
|
+
abandonAutoSidebarAction(key, autoContext);
|
|
4626
|
+
if (autoTakeoverStateOf(key) === "close") {
|
|
4627
|
+
// 用户要的是关闭:立刻登记抑制,绝不在同一轮或下一轮同步里把这个资源重新打开。
|
|
4628
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
4629
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
4630
|
+
}
|
|
4631
|
+
return;
|
|
4632
|
+
}
|
|
4633
|
+
if (beforeOpenReview === "manual") effectiveOrigin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3867
4634
|
openSidebarResource.call(sidebarRight, resourceAddress, openOptions);
|
|
4635
|
+
openIssued = true;
|
|
4636
|
+
/**
|
|
4637
|
+
* 预留 → 来源的升级点:只有 openResource 真的成功发出、且宿主具备可追踪能力(能读到 tabId、
|
|
4638
|
+
* 能精确关闭)时,才把这次打开记成「Auto 亲手打开」。openResource 抛错时直接走 catch,
|
|
4639
|
+
* 来源标记绝不写;能力不足时也不写——将来无法精确回收的 tab 不归 Auto 管。
|
|
4640
|
+
*/
|
|
4641
|
+
if (effectiveOrigin === SIDEBAR_TAB_ORIGIN_AUTO && canTrackOpen === true) {
|
|
4642
|
+
sidebarAutoOriginRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
4643
|
+
trimAutoKeyMap(sidebarAutoOriginRef.current);
|
|
4644
|
+
}
|
|
3868
4645
|
if (splitToken !== null) {
|
|
3869
4646
|
let acknowledged = true;
|
|
3870
4647
|
if (markSidebarSplitOpen !== null) {
|
|
@@ -3877,7 +4654,7 @@
|
|
|
3877
4654
|
}
|
|
3878
4655
|
}
|
|
3879
4656
|
// 记录必须发生在 openResource 之后:它读的是宿主提交后的 active tab,是打开的证据。
|
|
3880
|
-
const opened = recordSidebarTab(key, resourceAddress);
|
|
4657
|
+
const opened = recordSidebarTab(key, resourceAddress, effectiveOrigin);
|
|
3881
4658
|
// 只要这次打开的 tab 已经落定,就不能留下 opening 记录:它的语义是「这次打开还没有确定
|
|
3882
4659
|
// 证据」(split 事务仍在等待 pane 证据、或本来就没有事务时都一样)。残留的 opening 会让
|
|
3883
4660
|
// 用户收回这个 tab 之后的下一次同步把关闭记录重新建回来(<< 复活),并把之后的点击挡成
|
|
@@ -3891,11 +4668,33 @@
|
|
|
3891
4668
|
sidebarTabOpeningRef.current.delete(key);
|
|
3892
4669
|
/* 宿主暂时打不开/关不掉这个地址:只影响这一次侧栏动作,状态绝不被污染。 */
|
|
3893
4670
|
}
|
|
3894
|
-
finally {
|
|
4671
|
+
finally {
|
|
4672
|
+
pending.delete(key);
|
|
4673
|
+
/**
|
|
4674
|
+
* 自动动作收尾时无条件释放在途记账(split busy、准备失败、layout 失效、open 抛错、
|
|
4675
|
+
* surface 变化、被撤销都走这里):
|
|
4676
|
+
* - 预留必须释放,否则一次失败会被永久记成「已经打开过」,该资源在本次运行里再也打不开;
|
|
4677
|
+
* - 打开没有真正发出时,这个 key 不存在「Auto 亲手打开」的事实,残留来源标记一并撤销。
|
|
4678
|
+
*/
|
|
4679
|
+
if (autoContext !== null) {
|
|
4680
|
+
sidebarAutoReservationRef.current.delete(key);
|
|
4681
|
+
if (autoContext.kind === "open" && openIssued !== true) {
|
|
4682
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
4683
|
+
// 本次打开没有发出:把 key 记进失败屏障,同一批微任务里绝不再重选它(见其说明)。
|
|
4684
|
+
sidebarAutoFailedKeysRef.current.add(key);
|
|
4685
|
+
}
|
|
4686
|
+
if (openIssued === true) sidebarAutoFailedKeysRef.current.delete(key);
|
|
4687
|
+
}
|
|
4688
|
+
/**
|
|
4689
|
+
* 在途动作收尾后立刻结算用户在这个窗口里表达的手动意图:手动恒优先,绝不因为「那次点击撞上
|
|
4690
|
+
* 了 pending 锁」而丢失。没有意图时这一步是幂等空操作。
|
|
4691
|
+
*/
|
|
4692
|
+
applyPendingManualIntent(key);
|
|
4693
|
+
}
|
|
3895
4694
|
};
|
|
3896
4695
|
|
|
3897
4696
|
/**
|
|
3898
|
-
* 批量关闭的唯一入口(主代理行 = 当前 root
|
|
4697
|
+
* 批量关闭的唯一入口(主代理行 = 当前 root 的运行中专家/分片;空闲分组标题行 = ready 专家/已结算 fan-out 的空闲分片)。
|
|
3899
4698
|
*
|
|
3900
4699
|
* 与单会话箭头共用的三条并发与生命周期边界:
|
|
3901
4700
|
* 1) 聚合锁覆盖整段 await 序列:双击/连击只允许第一次穿过,pending 期间按钮 disabled + aria-busy;
|
|
@@ -4197,7 +4996,7 @@
|
|
|
4197
4996
|
const idleTitleChildren = [
|
|
4198
4997
|
react.createElement("div", { className: "deg_expert_group_line deg_expert_group_task" }, "空闲子代理(" + members.length + ")"),
|
|
4199
4998
|
];
|
|
4200
|
-
// 空闲分组的批量候选只来自当前 idleChildren
|
|
4999
|
+
// 空闲分组的批量候选只来自当前 idleChildren;运行中的专家或分片会被排除。
|
|
4201
5000
|
// slot 与计数标题同属第三列的 inline flex,标题按可用宽度省略,slot 不会被推到行尾。
|
|
4202
5001
|
if (trailing !== undefined && trailing !== null) idleTitleChildren.push(trailing);
|
|
4203
5002
|
return react.createElement("div", { className: "deg_expert_group_idle_group", "data-expanded": open },
|
|
@@ -4205,7 +5004,7 @@
|
|
|
4205
5004
|
statusIcon(react, primitives, "ready"),
|
|
4206
5005
|
renderChevron(group, open),
|
|
4207
5006
|
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);
|
|
5007
|
+
open ? react.createElement("div", { className: "deg_expert_group_children deg_expert_group_idle_children" }, members.map((node, index) => node.kind === "fanout" ? renderFanout(node, index) : renderExpert(node, index, rootSessionId))) : null);
|
|
4209
5008
|
};
|
|
4210
5009
|
|
|
4211
5010
|
const pendingSet = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
|
|
@@ -4330,9 +5129,8 @@
|
|
|
4330
5129
|
};
|
|
4331
5130
|
|
|
4332
5131
|
const children = Array.isArray(snapshot?.root?.children) ? snapshot.root.children : [];
|
|
4333
|
-
//
|
|
4334
|
-
//
|
|
4335
|
-
// fan-out 仍只在自己的取消/重试逻辑中读取 statusOf(task)。
|
|
5132
|
+
// 普通专家按实际运行状态分组,fan-out 父任务则按 manager 的聚合终态进入空闲组。
|
|
5133
|
+
// fan-out 展示与取消/重试仍使用各自的 manager 状态,不受分组位置影响。
|
|
4336
5134
|
const idleChildren = sidebarIdleChildren();
|
|
4337
5135
|
const visibleChildren = children.filter((node) => !idleChildren.includes(node));
|
|
4338
5136
|
// 两行标题后的批量 slot 内容:主代理行只覆盖运行中的普通专家/最新分片,空闲标题行只覆盖
|
|
@@ -4353,9 +5151,32 @@
|
|
|
4353
5151
|
...visibleChildren.map((node, index) => node.kind === "fanout" ? renderFanout(node, index) : renderExpert(node, index, rootSessionId)),
|
|
4354
5152
|
renderIdleGroup(idleChildren, idleBatchSlot),
|
|
4355
5153
|
]) : null));
|
|
5154
|
+
/**
|
|
5155
|
+
* 标题栏「Auto」开关(紧凑、与标题同一行,不遮挡标题与行内批量控件):
|
|
5156
|
+
*
|
|
5157
|
+
* - 勾选态:权威读面(install 注入的 getter,每次渲染现读)叠加本地乐观值;
|
|
5158
|
+
* - 可访问性:input 自带 aria-label="Auto",可见文字也是 Auto,说明性文案只放在 title 里,
|
|
5159
|
+
* 不新增任何长段落;
|
|
5160
|
+
* - 写面缺失(直接 createExpertGroupPanel 的旧宿主/单测)时 disabled:绝不渲染一个点了没反应的开关。
|
|
5161
|
+
*/
|
|
5162
|
+
const autoOpenChecked = autoOpenDraft === null ? autoOpenEnabled() : autoOpenDraft === true;
|
|
5163
|
+
const autoOpenToggle = react.createElement("label", {
|
|
5164
|
+
className: "deg_expert_group_header_auto",
|
|
5165
|
+
title: "自动打开运行中的子代理侧栏会话",
|
|
5166
|
+
},
|
|
5167
|
+
react.createElement("input", {
|
|
5168
|
+
type: "checkbox",
|
|
5169
|
+
className: "deg_expert_group_header_auto_input",
|
|
5170
|
+
"aria-label": "Auto",
|
|
5171
|
+
checked: autoOpenChecked,
|
|
5172
|
+
disabled: writeAutoOpenSetting === null,
|
|
5173
|
+
onChange: (event) => toggleAutoOpen(event?.target?.checked === true),
|
|
5174
|
+
}),
|
|
5175
|
+
react.createElement("span", { className: "deg_expert_group_header_auto_label" }, "Auto"));
|
|
4356
5176
|
return react.createElement("section", { className: "deg_expert_group", "data-version": version, "aria-label": "Expert Group" },
|
|
4357
5177
|
react.createElement("header", { className: "deg_expert_group_header" },
|
|
4358
5178
|
react.createElement("span", { className: "deg_expert_group_header_title" }, "Expert Group"),
|
|
5179
|
+
autoOpenToggle,
|
|
4359
5180
|
viewState.error !== null ? react.createElement("span", { className: "deg_expert_group_error", title: viewState.error }, "快照读取失败") : null,
|
|
4360
5181
|
viewState.error !== null ? react.createElement("button", { type: "button", className: "deg_expert_group_header_action", title: "重试", "aria-label": "重试", onClick: retry }, makeIcon(primitives, "IconRefreshOutlineRegular", { size: 14 })) : null), body);
|
|
4361
5182
|
}
|
|
@@ -9796,7 +10617,8 @@ window.__ModuleLoader__.load({
|
|
|
9796
10617
|
".deg_mc_effort input{width:14px;height:14px;accent-color:var(--dsw-alias-interactive-bg-primary,var(--dsw-alias-label-primary))}" +
|
|
9797
10618
|
".deg_mc_input_types{display:flex;flex-wrap:wrap;gap:10px}" +
|
|
9798
10619
|
".deg_mc_hint{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}" +
|
|
9799
|
-
|
|
10620
|
+
// 删除确认弹窗内的失败文本与卡片错误共用同一条规则(同一个错误视觉,不新增样式规则)。
|
|
10621
|
+
".deg_mc_error,.deg_remove_confirm_error{font-size:12px;line-height:18px;color:var(--dsw-alias-label-error,var(--dsw-alias-state-error-primary,#f87171));white-space:pre-wrap}" +
|
|
9800
10622
|
".deg_mc_card_footer{display:flex;align-items:center;gap:8px;padding-top:2px}" +
|
|
9801
10623
|
".deg_mc_card_footer .deg_mc_status{margin-right:auto}" +
|
|
9802
10624
|
".deg_mc_card_footer .deg_btn{flex:0 0 auto;min-width:76px}" +
|
|
@@ -12070,6 +12892,8 @@ window.__ModuleLoader__.load({
|
|
|
12070
12892
|
observerPreferred: source.observerPreferred === true,
|
|
12071
12893
|
// 任务管理缺字段时保持开启:默认必须维持 Expert Group 注册到侧边栏的历史行为。
|
|
12072
12894
|
taskManagementEnabled: source.taskManagementEnabled !== false,
|
|
12895
|
+
// 自动打开缺字段/非法值时一律按关闭归一:默认不做任何自动 open/close。
|
|
12896
|
+
autoOpenSubagentSidebar: source.autoOpenSubagentSidebar === true,
|
|
12073
12897
|
};
|
|
12074
12898
|
}
|
|
12075
12899
|
|
|
@@ -12079,18 +12903,35 @@ window.__ModuleLoader__.load({
|
|
|
12079
12903
|
|
|
12080
12904
|
function createSettingsModelCatalog(ctx) {
|
|
12081
12905
|
let state = { groups: [], status: "idle", error: null };
|
|
12906
|
+
// 目录刷新协调:同一时刻最多一个在途请求(单飞),在途期间到达的刷新事件只记账
|
|
12907
|
+
// 一个尾随刷新(queued),由在途请求收尾时启动。这样一串宿主事件(新增 provider 后
|
|
12908
|
+
// 立即删除会同时触发目录、文档与凭据事件)只会合并成"一个在途 + 一个最新"两次请求,
|
|
12909
|
+
// 而不是每个事件各发一次、互相把对方判成过期。
|
|
12082
12910
|
let inflight = null;
|
|
12911
|
+
let queued = false;
|
|
12912
|
+
// 销毁标记:cleanup 第一步就置位,之后任何入口都不得再发起远端目录调用。
|
|
12913
|
+
// 只依赖 freshness.dispose() 不够:在途请求的收尾路径(.finally)按 promise 身份
|
|
12914
|
+
// 判断"收尾责任",销毁时若仍持有 inflight / queued 账本,它会把排队中的尾随刷新
|
|
12915
|
+
// 真的发出去——插件已经销毁,却仍触发一次远端目录 RPC。
|
|
12916
|
+
let disposed = false;
|
|
12083
12917
|
const freshness = createClientFreshnessBoundary();
|
|
12084
12918
|
const listeners = new Set();
|
|
12085
12919
|
const publish = (next) => {
|
|
12086
12920
|
state = next;
|
|
12087
12921
|
for (const listener of listeners) listener();
|
|
12088
12922
|
};
|
|
12089
|
-
|
|
12090
|
-
|
|
12923
|
+
/**
|
|
12924
|
+
* 启动一次目录请求。只有"当前没有在途请求"时才会被调用(load / refresh / 尾随刷新),
|
|
12925
|
+
* 因此这里不需要再做并发判断。
|
|
12926
|
+
*/
|
|
12927
|
+
const startRequest = () => {
|
|
12928
|
+
// 销毁后绝不发起远端调用(也不改写状态):正常路径由 cleanup 清账本拦住尾随刷新,
|
|
12929
|
+
// 这里再兜一层,覆盖 connection/reset 处理器等销毁后才到达的迟到入口。
|
|
12930
|
+
if (disposed) return Promise.resolve();
|
|
12091
12931
|
const requestToken = freshness.beginRequest("catalog", "settings");
|
|
12932
|
+
// 保留上一次已知目录:加载中只换状态,不清空下拉已经能用的选项。
|
|
12092
12933
|
publish({ ...state, status: "loading", error: null });
|
|
12093
|
-
|
|
12934
|
+
const promise = Promise.resolve().then(() => ctx.remote.session.modelCatalog()).then((response) => {
|
|
12094
12935
|
const catalog = response?.ok === true ? response.value : response;
|
|
12095
12936
|
if (!catalog || !Array.isArray(catalog.groups)) throw new Error("模型目录不可用");
|
|
12096
12937
|
const normalizedGroups = normalizeSettingsModelGroups(catalog.groups);
|
|
@@ -12099,20 +12940,58 @@ window.__ModuleLoader__.load({
|
|
|
12099
12940
|
groups: normalizedGroups,
|
|
12100
12941
|
requestToken,
|
|
12101
12942
|
});
|
|
12943
|
+
// 过期响应(期间又来了刷新事件)由 freshness 拒绝:旧目录绝不覆盖新目录,
|
|
12944
|
+
// 也绝不把角色下拉提前解锁。
|
|
12102
12945
|
if (accepted.accepted) publish({ groups: normalizedGroups, status: "ready", error: null });
|
|
12103
12946
|
}).catch((cause) => {
|
|
12104
12947
|
if (freshness.isCurrent(requestToken)) {
|
|
12105
12948
|
publish({ ...state, status: "error", error: cause instanceof Error ? cause.message : String(cause) });
|
|
12106
12949
|
}
|
|
12107
12950
|
}).finally(() => {
|
|
12108
|
-
|
|
12951
|
+
// 用 promise 身份而不是 freshness 令牌判断收尾责任:在途期间到达的刷新事件会
|
|
12952
|
+
// 刻意作废本请求的令牌(见 refresh),那时令牌已不再"当前",但释放 inflight 与
|
|
12953
|
+
// 启动尾随刷新的责任仍然在本请求,否则 store 会永久停在 loading。
|
|
12954
|
+
if (inflight !== promise) return;
|
|
12955
|
+
inflight = null;
|
|
12956
|
+
if (!queued) return;
|
|
12957
|
+
queued = false;
|
|
12958
|
+
startRequest();
|
|
12109
12959
|
});
|
|
12110
|
-
|
|
12960
|
+
inflight = promise;
|
|
12961
|
+
return promise;
|
|
12962
|
+
};
|
|
12963
|
+
/**
|
|
12964
|
+
* 幂等读取:已有在途请求时复用它,不再并发第二次。
|
|
12965
|
+
* 销毁后返回一个已完成的 Promise:调用方(设置页挂载 effect)不会被悬挂的等待卡住,
|
|
12966
|
+
* 但 store 不会因此向远端发请求。
|
|
12967
|
+
*/
|
|
12968
|
+
const load = () => {
|
|
12969
|
+
if (disposed) return Promise.resolve();
|
|
12970
|
+
if (inflight !== null) return inflight;
|
|
12971
|
+
return startRequest();
|
|
12111
12972
|
};
|
|
12112
12973
|
const refresh = (clear) => {
|
|
12113
|
-
|
|
12114
|
-
|
|
12115
|
-
|
|
12974
|
+
// 销毁后刷新事件没有任何意义(监听已摘除);迟到的 connection/reset 也必须被丢弃,
|
|
12975
|
+
// 否则会在插件销毁后启动一次远端目录调用。
|
|
12976
|
+
if (disposed) return;
|
|
12977
|
+
// connection/reset 的 clear 语义:在途请求属于旧连接,它的结算与响应都不可再用,
|
|
12978
|
+
// 因此放弃它(收尾里的身份检查会让它的 finally 变成空操作,startRequest 的新令牌
|
|
12979
|
+
// 也会让它的迟到响应被拒绝)并立刻重新读取——绝不等待一个可能永远不会结算的旧请求。
|
|
12980
|
+
if (clear) {
|
|
12981
|
+
inflight = null;
|
|
12982
|
+
queued = false;
|
|
12983
|
+
publish({ groups: [], status: "idle", error: null });
|
|
12984
|
+
startRequest();
|
|
12985
|
+
return;
|
|
12986
|
+
}
|
|
12987
|
+
if (inflight !== null) {
|
|
12988
|
+
// 单飞:在途期间的刷新事件合并为一个尾随刷新,并立刻作废在途请求的令牌:
|
|
12989
|
+
// 它的响应属于旧目录,必须由 client-freshness 拒绝,不能覆盖即将到来的最新目录。
|
|
12990
|
+
if (!queued) freshness.beginRequest("catalog", "settings");
|
|
12991
|
+
queued = true;
|
|
12992
|
+
return;
|
|
12993
|
+
}
|
|
12994
|
+
startRequest();
|
|
12116
12995
|
};
|
|
12117
12996
|
const subscribe = (listener) => {
|
|
12118
12997
|
listeners.add(listener);
|
|
@@ -12125,6 +13004,15 @@ window.__ModuleLoader__.load({
|
|
|
12125
13004
|
}
|
|
12126
13005
|
ctx.on("connection/reset", () => refresh(true));
|
|
12127
13006
|
ctx.effect(() => () => {
|
|
13007
|
+
// 顺序是语义的一部分:
|
|
13008
|
+
// ① 先置销毁标记,封锁所有入口(load / refresh / startRequest / 尾随刷新);
|
|
13009
|
+
disposed = true;
|
|
13010
|
+
// ② 清掉逻辑账本:在途请求的 .finally 用 promise 身份判断收尾责任,账本清空后
|
|
13011
|
+
// 它只会直接返回,不会再把 queued 的尾随刷新发出去;
|
|
13012
|
+
inflight = null;
|
|
13013
|
+
queued = false;
|
|
13014
|
+
// ③ 最后才摘监听并 dispose freshness:在途请求的迟到响应仍会被 freshness
|
|
13015
|
+
// 拒绝(dispose 后 isCurrent 恒为 false),绝不覆盖目录或把角色下拉解锁。
|
|
12128
13016
|
for (const stop of eventStops) if (typeof stop === "function") stop();
|
|
12129
13017
|
freshness.dispose();
|
|
12130
13018
|
}, "dsh-expert-group: settings catalog events");
|
|
@@ -13041,7 +13929,10 @@ window.__ModuleLoader__.load({
|
|
|
13041
13929
|
className: "deg_btn deg_mc_delete_btn",
|
|
13042
13930
|
disabled: saving || card.readOnly,
|
|
13043
13931
|
"aria-label": card.displayName + " 删除",
|
|
13044
|
-
|
|
13932
|
+
// 事件的 `currentTarget`(真实按钮)只在**同步派发期间**有效,因此原样交给父区域:
|
|
13933
|
+
// 删除成功后要按"点击那一刻的焦点归属 + 点击时捕获的区域作用域"决定是否把焦点还回
|
|
13934
|
+
// DEG 区域内的稳定控件(详见 REMOVE_FOCUS_TARGET_SELECTORS 上方的边界说明)。
|
|
13935
|
+
onClick: (event) => { void onRemove(event); },
|
|
13045
13936
|
}, "删除")
|
|
13046
13937
|
: null;
|
|
13047
13938
|
|
|
@@ -13590,6 +14481,15 @@ window.__ModuleLoader__.load({
|
|
|
13590
14481
|
*/
|
|
13591
14482
|
let modelDiscoveryTitleSeq = 0;
|
|
13592
14483
|
|
|
14484
|
+
/**
|
|
14485
|
+
* 删除确认弹窗的身份序号。
|
|
14486
|
+
*
|
|
14487
|
+
* 一次确认从"待确认"到"进行中"、失败回退、成功关闭会经历多次状态更新;每次都按这个
|
|
14488
|
+
* 序号配对当前弹窗,**绝不**用一个会被自身更新换掉的旧对象引用来做身份比较——那样
|
|
14489
|
+
* 失败回退与成功关闭都会静默失效(弹窗永远停在"删除中…"),并发的后来者也会被误伤。
|
|
14490
|
+
*/
|
|
14491
|
+
let removeConfirmSeq = 0;
|
|
14492
|
+
|
|
13593
14493
|
/**
|
|
13594
14494
|
* 弹窗焦点环的候选控件:只保留真正可聚焦(启用且可见)的对话框控件。
|
|
13595
14495
|
*
|
|
@@ -14260,6 +15160,271 @@ window.__ModuleLoader__.load({
|
|
|
14260
15160
|
: null));
|
|
14261
15161
|
}
|
|
14262
15162
|
|
|
15163
|
+
// ── 删除 provider 之后的「区域内」焦点归还 ─────────────────────────────────
|
|
15164
|
+
//
|
|
15165
|
+
// 用户报告:在设置页删除一张 provider 卡片后,键盘焦点落回页面根,紧随其后的 DSH
|
|
15166
|
+
// 主输入框光标也随之消失。修复的边界是**只把焦点还给 DEG 区域内的稳定控件**。
|
|
15167
|
+
//
|
|
15168
|
+
// 这些边界是修复的一部分,不是实现细节:
|
|
15169
|
+
// - DEG **绝不**访问、也绝不 focus 宿主的任何控件(尤其是 DSH 主输入框):那是宿主
|
|
15170
|
+
// 自己的焦点管理,插件越界只会制造新的抢焦点问题;
|
|
15171
|
+
// - 只有四个条件同时成立才尝试一次自己的恢复:
|
|
15172
|
+
// ① 点击删除按钮那一刻焦点就在这个按钮上(这次交互确实来自 DEG 卡片),且这次读取
|
|
15173
|
+
// **成功**——读面抛错只是"归属未知",未知不得 arm;
|
|
15174
|
+
// ② 用户接受了原生 confirm;
|
|
15175
|
+
// ③ settings 里的 provider 配置删除成功(凭据阶段与写入阶段的失败都不算);
|
|
15176
|
+
// ④ 删除成功后的目录/视图刷新**整个加载事务**已经结算:`await load()` 等到的不只是
|
|
15177
|
+
// 自己那一轮,还包括在同一轮里被合并进来的所有尾随重跑。早一步恢复,随后的尾随
|
|
15178
|
+
// React 更新就会把焦点又打回根节点(这正是本波修掉的时序缺陷)。
|
|
15179
|
+
// 于是"确认取消""角色联动守卫拦截""凭据删除失败""settings 写入失败"四条路径都不
|
|
15180
|
+
// 恢复焦点,与既有语义完全一致;
|
|
15181
|
+
// - 异步窗口(凭据 / settings / 刷新都可能长时间 pending)里在 document 捕获阶段观察
|
|
15182
|
+
// pointerdown / keydown / focusin:任何用户或宿主动作把意图移走(例如用户点回 DSH
|
|
15183
|
+
// 输入框继续打字)都会把本次恢复标记为 abandoned,收尾时不再抢焦点;
|
|
15184
|
+
// - 恢复目标只在**点击时捕获的作用域**内查询(`trigger.closest(".deg_mc_section")`),
|
|
15185
|
+
// 绝不 document 全局查询:宿主输入框不可能被这里选中;
|
|
15186
|
+
// - 作用域或目标缺失、DOM 读面失败、目标已断开、hidden/disabled/aria-disabled、没有
|
|
15187
|
+
// 渲染矩形时静默跳过(并继续尝试下一个候选):焦点问题绝不影响删除结果(删除已经
|
|
15188
|
+
// 写盘,不能因为焦点读面异常而变成失败)。
|
|
15189
|
+
|
|
15190
|
+
/** 恢复目标优先级:「添加提供方」入口优先,回退到稳定的折叠区标题。 */
|
|
15191
|
+
const REMOVE_FOCUS_TARGET_SELECTORS = Object.freeze([
|
|
15192
|
+
'[data-deg-add-provider="true"]',
|
|
15193
|
+
'[data-deg-model-config-toggle="true"]',
|
|
15194
|
+
]);
|
|
15195
|
+
|
|
15196
|
+
/** 运行期 document;没有 DOM(Node、裁剪过的宿主替身)时返回 null。 */
|
|
15197
|
+
function removeFocusDocument() {
|
|
15198
|
+
return typeof document === "undefined" ? null : (document ?? null);
|
|
15199
|
+
}
|
|
15200
|
+
|
|
15201
|
+
/**
|
|
15202
|
+
* 读取当前持有焦点的元素。
|
|
15203
|
+
*
|
|
15204
|
+
* 读面的**三个状态必须分开表达**,只有中间那一种是"成功":
|
|
15205
|
+
* - `activeElement` 明确返回 **null**:这是**已知的空焦点**(浏览器在没有焦点、或焦点
|
|
15206
|
+
* 落到 body / 页面根时给出的就是 null)。`ok === true`,收尾按"焦点仍停在原处"继续;
|
|
15207
|
+
* - 属性**不存在**,或 getter 返回 **undefined**:读面没有给出答案,是**未知**,
|
|
15208
|
+
* `ok === false`。这**不是**空焦点:宿主替身 / 被裁剪的宿主压根没有这个读面时,
|
|
15209
|
+
* `undefined` 只说明"读不到";
|
|
15210
|
+
* - getter **抛错**:同样是未知,`ok === false`。
|
|
15211
|
+
*
|
|
15212
|
+
* 后两种曾被 `documentStub.activeElement ?? null` 折叠成"成功读到 null":同一次删除流程
|
|
15213
|
+
* 里点击那一刻读到真实删除按钮、收尾时读面退化成 undefined 的非标准宿主,因此会在收尾
|
|
15214
|
+
* 把**未知**当成**已知的空焦点**,把焦点抢回 DEG 区域——正是"插件越过自己边界动焦点"
|
|
15215
|
+
* 那类事故。所以这里不能再做 nullish 合并,只有显式 null 才允许收尾继续。
|
|
15216
|
+
* 同一次删除里点击那一刻的读取失败也必须阻止 arm,否则一条无法证明归属的流程会在收尾
|
|
15217
|
+
* 时把焦点搬走。因此这里返回读结果对象,失败用 `ok === false` 显式表达,调用方各自决定跳过。
|
|
15218
|
+
*/
|
|
15219
|
+
function readFocusedElement(documentStub) {
|
|
15220
|
+
if (documentStub === null || documentStub === undefined) return { ok: false, element: null };
|
|
15221
|
+
try {
|
|
15222
|
+
// 用 `in` 走**原型链**探测"这个宿主到底有没有焦点读面":真实 document 的
|
|
15223
|
+
// activeElement 是 Document.prototype 上的继承访问器,任何 own-property 限定
|
|
15224
|
+
// (hasOwnProperty / 只认数据属性)都会把真实 DOM 判成"没有读面",让整条恢复路径
|
|
15225
|
+
// 静默失效。Proxy / 被裁剪的宿主连 `in` 都可能抛错:那属于"读面归属未知",
|
|
15226
|
+
// 与"读面存在但没有答案"同样按失败处理(宁可不恢复,也不猜)。
|
|
15227
|
+
if (!("activeElement" in documentStub)) return { ok: false, element: null };
|
|
15228
|
+
const element = documentStub.activeElement;
|
|
15229
|
+
// 读面给出 undefined = 没有答案(未知),绝不能当作空焦点。
|
|
15230
|
+
if (element === undefined) return { ok: false, element: null };
|
|
15231
|
+
return { ok: true, element };
|
|
15232
|
+
} catch {
|
|
15233
|
+
return { ok: false, element: null };
|
|
15234
|
+
}
|
|
15235
|
+
}
|
|
15236
|
+
|
|
15237
|
+
/**
|
|
15238
|
+
* 捕获作用域:删除按钮所在的 DEG 区域。
|
|
15239
|
+
*
|
|
15240
|
+
* 必须在**点击时**捕获而不是收尾时再算:删除成功的刷新会给所有卡片换 key,原按钮
|
|
15241
|
+
* 届时已从页面摘除,它的祖先链随之断开,事后再 closest 只会得到 null。
|
|
15242
|
+
*/
|
|
15243
|
+
function closestRemoveFocusScope(trigger) {
|
|
15244
|
+
if (trigger === null || typeof trigger.closest !== "function") return null;
|
|
15245
|
+
try {
|
|
15246
|
+
return trigger.closest(".deg_mc_section") ?? null;
|
|
15247
|
+
} catch {
|
|
15248
|
+
return null;
|
|
15249
|
+
}
|
|
15250
|
+
}
|
|
15251
|
+
|
|
15252
|
+
/**
|
|
15253
|
+
* 布局可见性的保守判定:读得到就读,读不到就不猜。
|
|
15254
|
+
*
|
|
15255
|
+
* 这里**不能**再用 `hasOwnProperty("offsetParent")`:真实浏览器里 offsetParent 定义在
|
|
15256
|
+
* `HTMLElement.prototype` 上,own property 探测恒为 false——这条判定在真实 DOM 上从未
|
|
15257
|
+
* 生效过;而换成 `offsetParent !== null` 又会把 fixed 定位元素(offsetParent 本来就是
|
|
15258
|
+
* null)误杀。两个真实可用的读面都按"有读面才判定"处理:
|
|
15259
|
+
* - `checkVisibility({ visibilityProperty: true })`:一次覆盖 display:none、visibility 与
|
|
15260
|
+
* content-visibility。options 必须显式给出:`checkVisibility()` 的默认语义**只**看元素
|
|
15261
|
+
* 是否渲染,被 CSS `visibility: hidden` 藏起来的控件仍然参与排版、仍然会被判为可见,
|
|
15262
|
+
* 恢复焦点就会交给一个用户看不见的控件。宿主实现若不认识 options 而抛错,异常会被
|
|
15263
|
+
* 下面的 catch 吞掉并退到 getClientRects,不会连累删除结果;
|
|
15264
|
+
* - `getClientRects()`:零渲染矩形(未排版 / display:none)说明元素没有盒子,
|
|
15265
|
+
* 而 fixed 定位元素同样有渲染矩形,因此合法目标不会被误杀。
|
|
15266
|
+
* 无布局引擎的替身/宿主两个读面都读不到时保持"不做无根据的判定",避免整体误杀恢复能力。
|
|
15267
|
+
*/
|
|
15268
|
+
function isLaidOutRemoveFocusTarget(element) {
|
|
15269
|
+
if (typeof element.checkVisibility === "function") {
|
|
15270
|
+
try {
|
|
15271
|
+
// 必须带 options:默认调用不看 CSS visibility 属性(见上方边界说明)。
|
|
15272
|
+
if (element.checkVisibility({ visibilityProperty: true }) === false) return false;
|
|
15273
|
+
} catch { /* 读面抛错:交给下一个读面,不做无根据的判定。 */ }
|
|
15274
|
+
}
|
|
15275
|
+
if (typeof element.getClientRects === "function") {
|
|
15276
|
+
try {
|
|
15277
|
+
const rects = element.getClientRects();
|
|
15278
|
+
// DOMRectList 与数组都有 length;读面给出非列表值时同样不做无根据的判定。
|
|
15279
|
+
if (rects !== null && rects !== undefined && typeof rects.length === "number" && rects.length === 0) return false;
|
|
15280
|
+
} catch { /* 同上。 */ }
|
|
15281
|
+
}
|
|
15282
|
+
return true;
|
|
15283
|
+
}
|
|
15284
|
+
|
|
15285
|
+
/**
|
|
15286
|
+
* 目标可用性的保守判定:断开、隐藏、disabled、aria-disabled 或没有渲染矩形一律不可用。
|
|
15287
|
+
*/
|
|
15288
|
+
function isUsableRemoveFocusTarget(element) {
|
|
15289
|
+
if (element === null || typeof element !== "object") return false;
|
|
15290
|
+
// focus() 缺失(裁剪过的元素替身)时不尝试:恢复焦点绝不允许抛错。
|
|
15291
|
+
if (typeof element.focus !== "function") return false;
|
|
15292
|
+
// isConnected 在真实 DOM 上恒为布尔;读面缺失时按"不可用"处理,宁可不恢复也不猜。
|
|
15293
|
+
if (element.isConnected !== true) return false;
|
|
15294
|
+
if (element.hidden === true) return false;
|
|
15295
|
+
if (element.disabled === true) return false;
|
|
15296
|
+
const ariaDisabled = typeof element.getAttribute === "function"
|
|
15297
|
+
? element.getAttribute("aria-disabled")
|
|
15298
|
+
: element["aria-disabled"];
|
|
15299
|
+
if (ariaDisabled === "true" || ariaDisabled === true) return false;
|
|
15300
|
+
if (!isLaidOutRemoveFocusTarget(element)) return false;
|
|
15301
|
+
return true;
|
|
15302
|
+
}
|
|
15303
|
+
|
|
15304
|
+
/** 在作用域内按优先级挑一个恢复目标;一个都不可用时返回 null。 */
|
|
15305
|
+
function pickRemoveFocusTarget(scope) {
|
|
15306
|
+
if (scope === null || typeof scope.querySelector !== "function") return null;
|
|
15307
|
+
for (const selector of REMOVE_FOCUS_TARGET_SELECTORS) {
|
|
15308
|
+
let candidate = null;
|
|
15309
|
+
try {
|
|
15310
|
+
candidate = scope.querySelector(selector);
|
|
15311
|
+
} catch {
|
|
15312
|
+
candidate = null;
|
|
15313
|
+
}
|
|
15314
|
+
if (isUsableRemoveFocusTarget(candidate)) return candidate;
|
|
15315
|
+
}
|
|
15316
|
+
return null;
|
|
15317
|
+
}
|
|
15318
|
+
|
|
15319
|
+
/**
|
|
15320
|
+
* 一次删除期间的"焦点意图"观察器。
|
|
15321
|
+
*
|
|
15322
|
+
* document 捕获阶段的 pointerdown / keydown / focusin 任一命中即把本次恢复标记为
|
|
15323
|
+
* abandoned(用户或宿主已经把意图移到别处)。`dispose()` 幂等:调用方必须在提前返回、
|
|
15324
|
+
* 异常与成功路径上都调用它,ModelConfigSection 卸载时还会再兜一次——删除流程里有多个
|
|
15325
|
+
* await,远端 Promise 可能长时间不结算,全局监听绝不能留在页面上。
|
|
15326
|
+
*/
|
|
15327
|
+
function observeRemoveFocusIntent(documentStub) {
|
|
15328
|
+
const events = ["pointerdown", "keydown", "focusin"];
|
|
15329
|
+
const registered = [];
|
|
15330
|
+
let abandoned = false;
|
|
15331
|
+
let disposed = false;
|
|
15332
|
+
const markAbandoned = () => { abandoned = true; };
|
|
15333
|
+
if (documentStub !== null && typeof documentStub.addEventListener === "function") {
|
|
15334
|
+
for (const type of events) {
|
|
15335
|
+
try {
|
|
15336
|
+
// capture=true:即使宿主在冒泡阶段 stopPropagation,也必须看到这次操作。
|
|
15337
|
+
documentStub.addEventListener(type, markAbandoned, true);
|
|
15338
|
+
registered.push(type);
|
|
15339
|
+
} catch { /* 登记失败:少观察一类事件,不影响删除语义。 */ }
|
|
15340
|
+
}
|
|
15341
|
+
}
|
|
15342
|
+
return {
|
|
15343
|
+
isAbandoned: () => abandoned,
|
|
15344
|
+
dispose() {
|
|
15345
|
+
if (disposed) return;
|
|
15346
|
+
disposed = true;
|
|
15347
|
+
if (documentStub === null || typeof documentStub.removeEventListener !== "function") return;
|
|
15348
|
+
for (const type of registered) {
|
|
15349
|
+
try {
|
|
15350
|
+
documentStub.removeEventListener(type, markAbandoned, true);
|
|
15351
|
+
} catch { /* 摘监听失败:监听随页面销毁一起消失,不影响删除结果。 */ }
|
|
15352
|
+
}
|
|
15353
|
+
registered.length = 0;
|
|
15354
|
+
},
|
|
15355
|
+
};
|
|
15356
|
+
}
|
|
15357
|
+
|
|
15358
|
+
/**
|
|
15359
|
+
* 在途焦点意图观察器的账本(每个删除流程各拥有自己那一条)。
|
|
15360
|
+
*
|
|
15361
|
+
* 为什么必须由组件持有而不是各流程自己记:删除流程里有多个 await,远端 Promise 可能
|
|
15362
|
+
* 长时间不结算,捕获监听绝不能留在页面上,所以卸载时必须有一个统一的兜底口。
|
|
15363
|
+
*
|
|
15364
|
+
* 三条不变量:
|
|
15365
|
+
* ① 集合在**首个渲染期**(useRef 初始化)就建立,绝不等到 passive effect 才赋值——
|
|
15366
|
+
* 真实 React 的 passive effect 在提交之后异步执行,删除流程完全可能在它之前就登记,
|
|
15367
|
+
* 那样的登记会落进 null 而永远无人清理(等价于把捕获监听永久留在 document 上);
|
|
15368
|
+
* ② 重叠的删除各自只移除自己那一条(`delete` 按对象身份,与顺序无关),单槽位账本
|
|
15369
|
+
* 会在双击删除时漏掉先发起的那条;
|
|
15370
|
+
* ③ 卸载即 `close()`:关闭后到达的登记**当场自我清理**,绝不允许再出现无人认领的监听。
|
|
15371
|
+
* 因此 `delete`/`close` 与 `dispose` 一样必须幂等。
|
|
15372
|
+
*/
|
|
15373
|
+
function createRemoveFocusObservationLedger() {
|
|
15374
|
+
const observations = new Set();
|
|
15375
|
+
let closed = false;
|
|
15376
|
+
return {
|
|
15377
|
+
add(observation) {
|
|
15378
|
+
if (observation === null || observation === undefined) return;
|
|
15379
|
+
if (closed) {
|
|
15380
|
+
// 账本已随卸载关闭:这条登记没有任何人去摘它,只能当场清理。
|
|
15381
|
+
observation.dispose();
|
|
15382
|
+
return;
|
|
15383
|
+
}
|
|
15384
|
+
observations.add(observation);
|
|
15385
|
+
},
|
|
15386
|
+
/** 某次流程自己认领的观察器收尾:与卸载兜底摘的是同一条,dispose 幂等。 */
|
|
15387
|
+
delete(observation) {
|
|
15388
|
+
observations.delete(observation);
|
|
15389
|
+
},
|
|
15390
|
+
close() {
|
|
15391
|
+
if (closed) return;
|
|
15392
|
+
closed = true;
|
|
15393
|
+
for (const observation of observations) observation.dispose();
|
|
15394
|
+
observations.clear();
|
|
15395
|
+
},
|
|
15396
|
+
};
|
|
15397
|
+
}
|
|
15398
|
+
|
|
15399
|
+
/**
|
|
15400
|
+
* 成功路径的收尾:先摘观察器,再按优先级还一次焦点。
|
|
15401
|
+
*
|
|
15402
|
+
* 前置条件缺一不可——作用域仍在页面上、意图没被移走、焦点仍停在原处(原按钮 / body /
|
|
15403
|
+
* 空)。任何读面缺失或 focus() 抛错都只让这次恢复静默跳过:删除已经写盘,焦点问题
|
|
15404
|
+
* 绝不允许改变删除结果。
|
|
15405
|
+
*/
|
|
15406
|
+
function restoreRemoveFocus({ documentStub, trigger, scope, observation }) {
|
|
15407
|
+
// 整段包在 try 里:焦点读面/写面(activeElement、getAttribute、focus)在任何宿主
|
|
15408
|
+
// 实现上都可能抛错,而删除已经写盘——焦点问题绝不允许改变删除结果。
|
|
15409
|
+
try {
|
|
15410
|
+
if (documentStub === null || trigger === null || scope === null) return;
|
|
15411
|
+
if (observation?.isAbandoned?.() === true) return;
|
|
15412
|
+
// 区域必须仍挂在页面上:整块设置页被卸载或替换时不再恢复任何焦点。
|
|
15413
|
+
if (scope.isConnected !== true) return;
|
|
15414
|
+
// 收尾这一次读取失败 = 当前焦点归属**未知**:安全跳过,绝不猜测。
|
|
15415
|
+
const focusRead = readFocusedElement(documentStub);
|
|
15416
|
+
if (focusRead.ok !== true) return;
|
|
15417
|
+
const active = focusRead.element;
|
|
15418
|
+
const body = documentStub.body ?? null;
|
|
15419
|
+
// 只有焦点仍"停在原处"(原按钮 / body / 已知的空焦点)才恢复:用户已经点/切到
|
|
15420
|
+
// 别处(例如 DSH 输入框)时绝不抢回来。
|
|
15421
|
+
if (active !== null && active !== trigger && active !== body) return;
|
|
15422
|
+
const target = pickRemoveFocusTarget(scope);
|
|
15423
|
+
if (target === null) return;
|
|
15424
|
+
target.focus();
|
|
15425
|
+
} catch { /* 焦点不可用时保持删除结果不变。 */ }
|
|
15426
|
+
}
|
|
15427
|
+
|
|
14263
15428
|
/**
|
|
14264
15429
|
* 折叠的「模型配置」区域。
|
|
14265
15430
|
*
|
|
@@ -14274,6 +15439,15 @@ window.__ModuleLoader__.load({
|
|
|
14274
15439
|
const [adding, setAdding] = react.useState(false);
|
|
14275
15440
|
const [addChoice, setAddChoice] = react.useState("");
|
|
14276
15441
|
const [settingsError, setSettingsError] = react.useState(null);
|
|
15442
|
+
/**
|
|
15443
|
+
* 删除确认状态(DEG 自渲染的非阻塞弹窗,见 openRemoveConfirm / removeConfirmDialog)。
|
|
15444
|
+
*
|
|
15445
|
+
* 为什么不是原生 confirm:同步 `globalThis.confirm()` 会阻塞浏览器事件循环,弹窗存续
|
|
15446
|
+
* 期间页面脚本、遮罩与宿主(DSH 会话输入框)的输入处理全部停摆,用户看到的就是
|
|
15447
|
+
* "设置页与 DSH 会话输入框随后都不可用,重启才恢复"。DEG 因此自己渲染确认层,
|
|
15448
|
+
* 任何阶段都不再调用同步原生 confirm。
|
|
15449
|
+
*/
|
|
15450
|
+
const [removeIntent, setRemoveIntent] = react.useState(null);
|
|
14277
15451
|
// 宿主替身/host 对象自身不触发渲染,因此每次重新读取宿主视图时手动推进一个版本号,
|
|
14278
15452
|
// 让 provider 卡片拿到最新的 user 层与 revision(而不是首次渲染时的那一份)。
|
|
14279
15453
|
const [viewRevision, setViewRevision] = react.useState(0);
|
|
@@ -14389,7 +15563,13 @@ window.__ModuleLoader__.load({
|
|
|
14389
15563
|
//
|
|
14390
15564
|
// 并发用一个"尾随重跑"标记收口:正在加载时到来的请求不会并发跑两次,但一定会在
|
|
14391
15565
|
// 当前这轮结束后再跑一轮,因此调用方(例如取消半完成创建的卡片)拿到的始终是新视图。
|
|
14392
|
-
|
|
15566
|
+
//
|
|
15567
|
+
// 事务语义(删除后的焦点归还依赖它):`running` 期间到达的每一次调用都返回**同一个**
|
|
15568
|
+
// 覆盖「当前轮 + 所有 again 尾随重跑」的 Promise。删除流程 `await load()` 因此会一直
|
|
15569
|
+
// 等到最后一轮结算,焦点恢复不会落在尾随轮次的 React 更新之前(那会把焦点又打回根节点)。
|
|
15570
|
+
// 少返回一次这个 Promise(例如直接 `return Promise.resolve()`)就等于把"刷新完成"提前
|
|
15571
|
+
// 宣布,这正是本波要修掉的时序缺陷。
|
|
15572
|
+
const loadStateRef = react.useRef({ running: false, again: false, promise: null });
|
|
14393
15573
|
const runLoad = react.useCallback(async () => {
|
|
14394
15574
|
setDirectory((previous) => ({ ...previous, status: "loading", error: null }));
|
|
14395
15575
|
setSettingsError(null);
|
|
@@ -14409,21 +15589,36 @@ window.__ModuleLoader__.load({
|
|
|
14409
15589
|
}
|
|
14410
15590
|
}, [config, host]);
|
|
14411
15591
|
|
|
14412
|
-
const load = react.useCallback(
|
|
15592
|
+
const load = react.useCallback(() => {
|
|
14413
15593
|
const state = loadStateRef.current;
|
|
14414
15594
|
if (state.running) {
|
|
15595
|
+
// 加入同一个事务:只置尾随标记(多个并发调用合并成**一个**尾随轮次),并把当前
|
|
15596
|
+
// 事务的 Promise 原样交回调用方——它覆盖到最后一次尾随重跑为止。
|
|
14415
15597
|
state.again = true;
|
|
14416
|
-
return;
|
|
15598
|
+
return state.promise;
|
|
14417
15599
|
}
|
|
14418
15600
|
state.running = true;
|
|
14419
|
-
|
|
14420
|
-
|
|
15601
|
+
const transaction = (async () => {
|
|
15602
|
+
try {
|
|
15603
|
+
do {
|
|
15604
|
+
state.again = false;
|
|
15605
|
+
await runLoad();
|
|
15606
|
+
} while (state.again);
|
|
15607
|
+
} finally {
|
|
15608
|
+
// 任何出口(正常结束、runLoad 抛错)都必须同时清掉两个账本字段:
|
|
15609
|
+
// running 卡住会让后续调用永远只置一遍 again,promise 不清空则会让新调用拿到
|
|
15610
|
+
// 一个已经结算(甚至已经拒绝)的旧 Promise——两者都会变成静默的永久挂起。
|
|
15611
|
+
// 从"循环条件不成立"到这里的整段是同步的,因此不存在"刚好在收尾期间置 again
|
|
15612
|
+
// 却被丢弃"的窗口:那个窗口之外的新调用一定会开启一个新事务。
|
|
15613
|
+
state.running = false;
|
|
14421
15614
|
state.again = false;
|
|
14422
|
-
|
|
14423
|
-
}
|
|
14424
|
-
}
|
|
14425
|
-
|
|
14426
|
-
|
|
15615
|
+
state.promise = null;
|
|
15616
|
+
}
|
|
15617
|
+
})();
|
|
15618
|
+
// 赋值发生在事务体的第一个 await 之前(事务体只能在 await 之后进入 finally),
|
|
15619
|
+
// 因此 running 为真时 promise 一定已就绪。
|
|
15620
|
+
state.promise = transaction;
|
|
15621
|
+
return transaction;
|
|
14427
15622
|
}, [runLoad]);
|
|
14428
15623
|
|
|
14429
15624
|
react.useEffect(() => {
|
|
@@ -14431,36 +15626,173 @@ window.__ModuleLoader__.load({
|
|
|
14431
15626
|
void load();
|
|
14432
15627
|
}, [open, load, hostInvalidation]);
|
|
14433
15628
|
|
|
15629
|
+
// 删除后的焦点归还:在途观察器由账本统一记账(见 createRemoveFocusObservationLedger)。
|
|
15630
|
+
//
|
|
15631
|
+
// 账本在**渲染期**建立(useRef 初始化 + 惰性赋值),不放在 passive effect 里:真实
|
|
15632
|
+
// React 的 passive effect 在提交之后才异步执行,删除流程可能在那之前就登记,那条登记
|
|
15633
|
+
// 会落进 null 而永远无人清理。卸载时统一 close()——删除流程里有多个 await,远端
|
|
15634
|
+
// Promise 可能长时间不结算,捕获监听绝不能留在页面上(常规路径仍由
|
|
15635
|
+
// confirmRemoveProvider 的 finally 自己收尾,这里是额外的一层兜底)。
|
|
15636
|
+
const removeFocusObservations = react.useRef(null);
|
|
15637
|
+
if (removeFocusObservations.current === null) {
|
|
15638
|
+
removeFocusObservations.current = createRemoveFocusObservationLedger();
|
|
15639
|
+
}
|
|
15640
|
+
// 确认弹窗的根节点:Tab 焦点环只在这个子树里取控件(绝不用 document 全局查询)。
|
|
15641
|
+
const removeDialogRef = react.useRef(null);
|
|
15642
|
+
react.useEffect(() => () => {
|
|
15643
|
+
removeFocusObservations.current?.close();
|
|
15644
|
+
}, []);
|
|
15645
|
+
|
|
15646
|
+
react.useEffect(() => {
|
|
15647
|
+
// 区域折叠(body 不再渲染)时清掉**待确认**状态:快照里挂着的是此刻已经不在页面上的
|
|
15648
|
+
// 删除按钮,重新展开时不得把它当成仍然有效的待确认操作弹出来。
|
|
15649
|
+
// 两个边界:
|
|
15650
|
+
// - 只在**折叠**方向清理,展开方向什么都不做(否则"折叠期间结算成失败"的状态会在
|
|
15651
|
+
// 重新展开的那一刻被自己抹掉);
|
|
15652
|
+
// - **running 绝不清**:异步删除(凭据 / 写入 / 刷新)仍在继续,清掉它只会让进行中、
|
|
15653
|
+
// 失败与重试的可见入口永久消失——用户可以折叠,但这次删除的结果必须找得回来。
|
|
15654
|
+
if (open) return;
|
|
15655
|
+
setRemoveIntent((previous) => (previous !== null && previous.phase === "confirming" ? null : previous));
|
|
15656
|
+
}, [open]);
|
|
15657
|
+
|
|
14434
15658
|
const editableRows = directory.rows.filter((row) => config.providerEditable(row, host.namespaceView(row.settingsNs)));
|
|
14435
15659
|
const configured = providerSections.configured.map((entry) => entry.row);
|
|
14436
15660
|
const addable = providerSections.addable.map((entry) => entry.row);
|
|
14437
|
-
|
|
14438
|
-
|
|
15661
|
+
/**
|
|
15662
|
+
* 点击卡片「删除」:**只**捕获这次交互的上下文并打开 DEG 自渲染的确认弹窗。
|
|
15663
|
+
*
|
|
15664
|
+
* 这一阶段绝不能有任何远端副作用:确认前的凭据删除、settings 写入与目录刷新都必须为 0。
|
|
15665
|
+
* 也绝不能再调用同步原生 `confirm()`:它阻塞浏览器事件循环,弹窗存续期间页面脚本、
|
|
15666
|
+
* 遮罩与宿主(DSH 会话输入框)的输入处理全部停摆——这正是"删除后设置页与会话输入框
|
|
15667
|
+
* 都不可用、重启才恢复"的事故面。
|
|
15668
|
+
*
|
|
15669
|
+
* trigger / scope / ownsFocus 必须在**点击那一刻**捕获:删除成功的刷新会给卡片换 key,
|
|
15670
|
+
* 原按钮届时已从页面摘除,事后再算只能得到 null。
|
|
15671
|
+
*/
|
|
15672
|
+
const openRemoveConfirm = (row, event) => {
|
|
14439
15673
|
const label = typeof row.displayName === "string" && row.displayName.length > 0 ? row.displayName : row.provider;
|
|
14440
|
-
|
|
14441
|
-
const
|
|
14442
|
-
|
|
14443
|
-
|
|
14444
|
-
|
|
14445
|
-
|
|
14446
|
-
|
|
14447
|
-
|
|
14448
|
-
|
|
14449
|
-
|
|
14450
|
-
|
|
15674
|
+
// `currentTarget` 只在同步派发期间有效:在这里读一次并存进确认状态。
|
|
15675
|
+
const trigger = event?.currentTarget ?? null;
|
|
15676
|
+
const documentStub = removeFocusDocument();
|
|
15677
|
+
// ① 只有"点击删除按钮那一刻焦点确实在这个按钮上"才登记恢复意图:否则这次交互的
|
|
15678
|
+
// 焦点不属于 DEG 卡片(宿主替身没有真实 DOM 焦点),绝不猜测、绝不抢焦点。
|
|
15679
|
+
// 读面失败(ok=false)时焦点归属**未知**,同样不得 arm:一条无法证明归属的流程
|
|
15680
|
+
// 在收尾时把焦点搬走,恰恰是"插件越过自己边界动焦点"的那类事故。
|
|
15681
|
+
const triggerFocusRead = readFocusedElement(documentStub);
|
|
15682
|
+
const ownsFocus = trigger !== null
|
|
15683
|
+
&& triggerFocusRead.ok === true
|
|
15684
|
+
&& triggerFocusRead.element === trigger;
|
|
15685
|
+
// 作用域必须在点击时捕获(见 closestRemoveFocusScope 的说明)。
|
|
15686
|
+
const scope = ownsFocus ? closestRemoveFocusScope(trigger) : null;
|
|
15687
|
+
removeConfirmSeq += 1;
|
|
15688
|
+
setRemoveIntent({ token: removeConfirmSeq, row, label, trigger, scope, ownsFocus, phase: "confirming", error: null });
|
|
15689
|
+
};
|
|
15690
|
+
|
|
15691
|
+
/**
|
|
15692
|
+
* 取消确认:只关弹窗,并把焦点还给**仍可用**的已捕获删除按钮,或同一 DEG 区域内的
|
|
15693
|
+
* 安全回退目标。
|
|
15694
|
+
*
|
|
15695
|
+
* 取消路径不得读取当前 `document.activeElement` 去另找一个恢复目标:那是"把全局焦点
|
|
15696
|
+
* 当恢复目标"的错误做法,正好会把焦点交给宿主输入框。按钮与作用域都来自打开弹窗那一刻
|
|
15697
|
+
* 的快照,因此这条路径在结构上不可能触及 DEG 之外的控件。
|
|
15698
|
+
*/
|
|
15699
|
+
const cancelRemoveConfirm = () => {
|
|
15700
|
+
const intent = removeIntent;
|
|
15701
|
+
// 进行中一律拒绝取消:此刻凭据与 settings 可能已经写盘,"取消"在语义上已经不存在,
|
|
15702
|
+
// 关掉弹窗只会把进行中 / 失败 / 重试的可见入口一起卸载,而异步删除仍在继续。
|
|
15703
|
+
// 遮罩、Escape 与「取消」按钮三条入口共用这一个守卫,任何一条都不得绕过它。
|
|
15704
|
+
if (intent !== null && intent.phase === "running") return;
|
|
15705
|
+
setRemoveIntent(null);
|
|
15706
|
+
if (intent === null || intent.ownsFocus !== true) return;
|
|
15707
|
+
try {
|
|
15708
|
+
// 删除按钮仍在页面上且可用 → 优先还给它(按 Escape / 取消后的直觉位置)。
|
|
15709
|
+
if (isUsableRemoveFocusTarget(intent.trigger)) {
|
|
15710
|
+
intent.trigger.focus();
|
|
14451
15711
|
return;
|
|
14452
15712
|
}
|
|
15713
|
+
// 按钮已不可用(断开 / 隐藏 / 禁用)→ 只在点击时捕获的 DEG 区域内找安全回退目标。
|
|
15714
|
+
if (intent.scope !== null && intent.scope.isConnected === true) {
|
|
15715
|
+
const target = pickRemoveFocusTarget(intent.scope);
|
|
15716
|
+
if (target !== null) target.focus();
|
|
15717
|
+
}
|
|
15718
|
+
} catch { /* 焦点不可用不影响"取消"语义:弹窗已经关掉。 */ }
|
|
15719
|
+
};
|
|
15720
|
+
|
|
15721
|
+
/**
|
|
15722
|
+
* 用户点击弹窗「删除」:**从这里**才开始真正的删除链路。
|
|
15723
|
+
*
|
|
15724
|
+
* 顺序与错误语义与既有实现完全一致:角色联动守卫 → 凭据删除 → settings unset →
|
|
15725
|
+
* 完整 load 事务(含并发合并进来的尾随轮次)→ 区域内焦点恢复策略。唯一变化是它由 DEG
|
|
15726
|
+
* 自己的弹窗按钮触发,因此不再有同步原生 confirm 阻塞事件循环。
|
|
15727
|
+
*
|
|
15728
|
+
* 失败(守卫拦截 / 凭据失败 / 写入失败 / 异常)保留弹窗:错误同时留在弹窗内(role=alert)
|
|
15729
|
+
* 与区域级错误里,用户可以直接取消或重试;失败路径不做任何焦点恢复,也绝不把删除
|
|
15730
|
+
* 结果伪装成成功。删除进行中(phase === "running")按钮禁用,防止重复提交。
|
|
15731
|
+
*/
|
|
15732
|
+
const confirmRemoveProvider = async () => {
|
|
15733
|
+
const intent = removeIntent;
|
|
15734
|
+
if (intent === null || intent.phase !== "confirming") return;
|
|
15735
|
+
const { row, trigger, scope, ownsFocus } = intent;
|
|
15736
|
+
// 立刻转 running:此刻凭据与 settings 可能已经写盘,关掉弹窗只会掩盖真实结果,
|
|
15737
|
+
// 因此按钮禁用、取消/Escape 不再受理(见 removeConfirmDialog 的键位处理)。
|
|
15738
|
+
// 所有后续更新都按 token 配对:同一次确认换了对象也认得自己,后来者则不受影响。
|
|
15739
|
+
setRemoveIntent((previous) => (previous !== null && previous.token === intent.token
|
|
15740
|
+
? { ...previous, phase: "running", error: null }
|
|
15741
|
+
: previous));
|
|
15742
|
+
const documentStub = removeFocusDocument();
|
|
15743
|
+
// ② 确认之后、任何 await 之前登记观察器:异步窗口里用户/宿主的任何操作都可能把
|
|
15744
|
+
// 意图移走,收尾时据 abandoned 决定是否还焦点。
|
|
15745
|
+
const observation = ownsFocus && scope !== null ? observeRemoveFocusIntent(documentStub) : null;
|
|
15746
|
+
if (observation !== null) removeFocusObservations.current?.add(observation);
|
|
15747
|
+
// 失败出口:区域级错误保持既有语义,同时在弹窗内可见(身份比较,绝不动后来者)。
|
|
15748
|
+
const fail = (message) => {
|
|
15749
|
+
setSettingsError(message);
|
|
15750
|
+
setRemoveIntent((previous) => (previous !== null && previous.token === intent.token
|
|
15751
|
+
? { ...previous, phase: "confirming", error: message }
|
|
15752
|
+
: previous));
|
|
15753
|
+
};
|
|
15754
|
+
try {
|
|
15755
|
+
const affected = config.rolesAffectedByChange(roleDefaults, { provider: row.provider, removeProvider: true });
|
|
15756
|
+
if (affected.length > 0) {
|
|
15757
|
+
fail(config.reasoningGuardMessage(["(整个 provider)"], affected, roleLabelOf));
|
|
15758
|
+
return;
|
|
15759
|
+
}
|
|
15760
|
+
const namespace = host.namespaceView(row.settingsNs);
|
|
15761
|
+
const credentialRef = config.credentialRefOf(row, namespace);
|
|
15762
|
+
if (host.operations.credentialState(credentialRef)?.configured === true) {
|
|
15763
|
+
const removed = await host.operations.removeKey(credentialRef);
|
|
15764
|
+
if (removed !== null) {
|
|
15765
|
+
fail(removed);
|
|
15766
|
+
return;
|
|
15767
|
+
}
|
|
15768
|
+
}
|
|
15769
|
+
const written = await host.writeNamespace({
|
|
15770
|
+
ns: row.settingsNs,
|
|
15771
|
+
ops: [{ op: "unset", path: [...row.settingsPath] }],
|
|
15772
|
+
expectedRevision: namespace?.revision,
|
|
15773
|
+
});
|
|
15774
|
+
if (!written.ok) {
|
|
15775
|
+
fail(written.message);
|
|
15776
|
+
return;
|
|
15777
|
+
}
|
|
15778
|
+
// ③ + ④ 写入成功,且**整个加载事务**(当前轮 + 所有尾随重跑)已经结算:先摘观察器
|
|
15779
|
+
// (自己的 focus 不该被自己记成"意图移走"),再按优先级把焦点还回区域内
|
|
15780
|
+
// (目标不可用时静默跳过)。`await load()` 拿到的是共享事务的 Promise,
|
|
15781
|
+
// 不是"立刻结算的空壳"——见 loadStateRef 上方的时序说明。
|
|
15782
|
+
await load();
|
|
15783
|
+
observation?.dispose();
|
|
15784
|
+
// 成功:先关掉自己这一轮弹窗(按 token 配对:并发的后来者不受影响),再恢复焦点。
|
|
15785
|
+
setRemoveIntent((previous) => (previous !== null && previous.token === intent.token ? null : previous));
|
|
15786
|
+
restoreRemoveFocus({ documentStub, trigger, scope, observation });
|
|
15787
|
+
} catch (cause) {
|
|
15788
|
+
// 任何读面/写面抛错都必须可见,而不是静默变成一次"看起来成功"的删除。
|
|
15789
|
+
fail(cause instanceof Error ? cause.message : String(cause));
|
|
15790
|
+
} finally {
|
|
15791
|
+
// 所有出口(守卫拦截、凭据失败、写入失败、异常、成功)都在这里摘掉观察器;
|
|
15792
|
+
// 摘监听本身幂等,卸载兜底再摘一次也不会重复移除。
|
|
15793
|
+
observation?.dispose();
|
|
15794
|
+
removeFocusObservations.current?.delete(observation);
|
|
14453
15795
|
}
|
|
14454
|
-
const written = await host.writeNamespace({
|
|
14455
|
-
ns: row.settingsNs,
|
|
14456
|
-
ops: [{ op: "unset", path: [...row.settingsPath] }],
|
|
14457
|
-
expectedRevision: namespace?.revision,
|
|
14458
|
-
});
|
|
14459
|
-
if (!written.ok) {
|
|
14460
|
-
setSettingsError(written.message);
|
|
14461
|
-
return;
|
|
14462
|
-
}
|
|
14463
|
-
await load();
|
|
14464
15796
|
};
|
|
14465
15797
|
|
|
14466
15798
|
const body = section.open ? react.createElement("div", { className: "deg_mc_body", id: section.disclosure.bodyId },
|
|
@@ -14480,7 +15812,9 @@ window.__ModuleLoader__.load({
|
|
|
14480
15812
|
host,
|
|
14481
15813
|
roleLabels: { defaults: roleDefaults, labelOf: roleLabelOf },
|
|
14482
15814
|
onSaved: () => { void load(); },
|
|
14483
|
-
|
|
15815
|
+
// 事件原样透传:openRemoveConfirm 只读取同步有效的 `currentTarget`(删除按钮本身),
|
|
15816
|
+
// 打开 DEG 自己的确认弹窗——确认之前不删凭据、不写 settings、不刷目录。
|
|
15817
|
+
onRemove: (event) => openRemoveConfirm(row, event),
|
|
14484
15818
|
}))),
|
|
14485
15819
|
react.createElement("div", { className: "deg_mc_add" },
|
|
14486
15820
|
adding
|
|
@@ -14570,9 +15904,90 @@ window.__ModuleLoader__.load({
|
|
|
14570
15904
|
}, directory.status === "loading" ? "读取中…" : "重新读取 provider 目录"))
|
|
14571
15905
|
: null;
|
|
14572
15906
|
|
|
15907
|
+
/**
|
|
15908
|
+
* 删除确认弹窗:复用提示词弹窗的 `deg_prompt_dialog_backdrop` / `deg_prompt_dialog`
|
|
15909
|
+
* 样式与 `data-deg-dialog-action` / focusableDialogControls 的键位模式,但**非阻塞**:
|
|
15910
|
+
* 渲染在 React 树里,事件循环照常运转。
|
|
15911
|
+
*
|
|
15912
|
+
* 边界(每条都是修复的一部分):
|
|
15913
|
+
* - 只显示 provider 名称与「确认 / 取消」两个操作;
|
|
15914
|
+
* - 打开后焦点落在「取消」(破坏性动作绝不能是默认焦点);
|
|
15915
|
+
* - 取消、Escape、点击遮罩**本身**三条路径都只关弹窗:不删凭据、不写 settings、不刷目录;
|
|
15916
|
+
* - Tab / Shift+Tab 只在弹窗内**真正可聚焦**的控件之间循环,不漏到背景页面;
|
|
15917
|
+
* - 删除进行中(phase === "running")按钮禁用、Escape 与 Tab 都不再受理:此刻写盘可能
|
|
15918
|
+
* 已经发生,关掉弹窗只会掩盖真实结果;
|
|
15919
|
+
* - 失败时弹窗保留,错误在弹窗内以 role=alert 可见,用户可以取消或重试。
|
|
15920
|
+
*/
|
|
15921
|
+
const removeConfirmDialog = removeIntent === null ? null : react.createElement("div", {
|
|
15922
|
+
className: "deg_prompt_dialog_backdrop",
|
|
15923
|
+
// 点遮罩本身只取消(弹窗内部的点击在下面 stopPropagation,不会误触发);
|
|
15924
|
+
// 删除进行中由 cancelRemoveConfirm 自己的守卫拒绝——这里绝不另写一套 phase 判断,
|
|
15925
|
+
// 三条关闭入口(遮罩 / Escape / 取消按钮)必须共用同一个守卫。
|
|
15926
|
+
onClick: () => cancelRemoveConfirm(),
|
|
15927
|
+
},
|
|
15928
|
+
react.createElement("div", {
|
|
15929
|
+
className: "deg_prompt_dialog",
|
|
15930
|
+
role: "dialog",
|
|
15931
|
+
"aria-modal": "true",
|
|
15932
|
+
"aria-label": "删除 provider 确认",
|
|
15933
|
+
ref: removeDialogRef,
|
|
15934
|
+
onClick: (event) => event?.stopPropagation?.(),
|
|
15935
|
+
onKeyDown: (event) => {
|
|
15936
|
+
// 删除进行中:不再受理取消与焦点环,避免用户以为"取消掉了"而实际已经写盘。
|
|
15937
|
+
if (removeIntent.phase === "running") return;
|
|
15938
|
+
if (event?.key === "Escape") {
|
|
15939
|
+
// 独占模态语义:阻止默认行为并停止冒泡,避免外层再处理同一个 Escape。
|
|
15940
|
+
event.preventDefault?.();
|
|
15941
|
+
event.stopPropagation?.();
|
|
15942
|
+
cancelRemoveConfirm();
|
|
15943
|
+
return;
|
|
15944
|
+
}
|
|
15945
|
+
if (event?.key !== "Tab") return;
|
|
15946
|
+
// 与候选弹窗同一套焦点环:只取真正可聚焦(启用且可见)的控件,disabled 的
|
|
15947
|
+
// 「删除 / 取消」不能被 preventDefault 之后 focus 一个被忽略的元素而卡死焦点。
|
|
15948
|
+
const controls = focusableDialogControls(removeDialogRef.current ?? event?.currentTarget ?? null);
|
|
15949
|
+
// 没有任何可聚焦控件时不拦截 Tab:宁可让浏览器默认行为走,也不把焦点锁死。
|
|
15950
|
+
if (controls.length === 0) return;
|
|
15951
|
+
const current = controls.indexOf(typeof document === "undefined" ? null : document.activeElement);
|
|
15952
|
+
const nextIndex = event.shiftKey
|
|
15953
|
+
? (current <= 0 ? controls.length - 1 : current - 1)
|
|
15954
|
+
: (current >= controls.length - 1 ? 0 : current + 1);
|
|
15955
|
+
event.preventDefault?.();
|
|
15956
|
+
try { controls[nextIndex]?.focus?.(); } catch { /* 无 DOM 焦点时保持可点击按钮。 */ }
|
|
15957
|
+
},
|
|
15958
|
+
},
|
|
15959
|
+
react.createElement("h3", null, "删除 provider"),
|
|
15960
|
+
react.createElement("p", null, "确定删除 provider「" + removeIntent.label + "」及其配置吗?"),
|
|
15961
|
+
removeIntent.error === null ? null : react.createElement("p", {
|
|
15962
|
+
className: "deg_remove_confirm_error",
|
|
15963
|
+
role: "alert",
|
|
15964
|
+
"aria-live": "assertive",
|
|
15965
|
+
}, removeIntent.error),
|
|
15966
|
+
react.createElement("div", { className: "deg_prompt_dialog_actions" },
|
|
15967
|
+
react.createElement("button", {
|
|
15968
|
+
type: "button",
|
|
15969
|
+
className: "deg_btn",
|
|
15970
|
+
"data-deg-dialog-action": "cancel",
|
|
15971
|
+
"data-deg-remove-cancel": "true",
|
|
15972
|
+
// 打开弹窗后焦点落在「取消」:破坏性动作不抢默认焦点。
|
|
15973
|
+
autoFocus: true,
|
|
15974
|
+
disabled: removeIntent.phase === "running",
|
|
15975
|
+
onClick: () => cancelRemoveConfirm(),
|
|
15976
|
+
}, "取消"),
|
|
15977
|
+
react.createElement("button", {
|
|
15978
|
+
type: "button",
|
|
15979
|
+
className: "deg_btn deg_btn_primary",
|
|
15980
|
+
"data-deg-dialog-action": "confirm",
|
|
15981
|
+
"data-deg-remove-confirm": "true",
|
|
15982
|
+
disabled: removeIntent.phase === "running",
|
|
15983
|
+
onClick: () => { void confirmRemoveProvider(); },
|
|
15984
|
+
}, removeIntent.phase === "running" ? "删除中…" : "删除"))));
|
|
15985
|
+
|
|
14573
15986
|
return react.createElement("div", { className: "deg_mc_section" },
|
|
14574
15987
|
react.createElement(ModelConfigDisclosure, { section, onToggle: () => setOpen((value) => !value) }),
|
|
14575
|
-
body
|
|
15988
|
+
body,
|
|
15989
|
+
// 折叠时删除按钮已不在页面上,弹窗一并不渲染(确认状态由 open 的 effect 清掉)。
|
|
15990
|
+
open ? removeConfirmDialog : null);
|
|
14576
15991
|
}
|
|
14577
15992
|
|
|
14578
15993
|
/**
|
|
@@ -14805,13 +16220,27 @@ window.__ModuleLoader__.load({
|
|
|
14805
16220
|
|
|
14806
16221
|
const groups = Array.isArray(directoryState.groups) ? directoryState.groups : [];
|
|
14807
16222
|
const providerGroups = groups.filter((group) => group && typeof group.id === "string");
|
|
16223
|
+
/**
|
|
16224
|
+
* 目录可用性:`status === "ready"`,或**已有成功目录缓存**(groups 非空)。
|
|
16225
|
+
*
|
|
16226
|
+
* 角色的选择与保存只依赖目录**内容**,不依赖"此刻有没有在途刷新":
|
|
16227
|
+
* - `startRequest` 每次只把 status 推到 loading、**刻意保留**上一次已知目录(见
|
|
16228
|
+
* createSettingsModelCatalog 的注释),最新一次请求失败也只把 status 推到 error;
|
|
16229
|
+
* 这两种状态下下拉里的选项仍然完整可用,若按 status 硬门控就会出现"用户看得见选项
|
|
16230
|
+
* 却选不了、更保存不了"的死锁——"新增 provider 后立即删除"的刷新突发正是这个时序;
|
|
16231
|
+
* - 首次挂载还没有过任何成功目录(groups 为空)时仍然禁用:没有目录就没有合法选项;
|
|
16232
|
+
* - `connection/reset` 的 clear 语义会**立刻清空** groups(refresh(true)),清空期间
|
|
16233
|
+
* 同样必须禁用,直到新连接上的目录返回。缓存可用的判定因此只看"有没有目录",
|
|
16234
|
+
* 绝不能用"曾经 ready 过"这类会跨 reset 残留的记忆。
|
|
16235
|
+
*/
|
|
16236
|
+
const catalogAvailable = directoryState.status === "ready" || groups.length > 0;
|
|
14808
16237
|
// 统一选择器只反映当前展示角色的共同 provider;混合状态使用空值映射到“无”。
|
|
14809
16238
|
const unifiedProvider = unifiedProviderOf(draft.defaults, roles, draft.provider);
|
|
14810
16239
|
|
|
14811
16240
|
const save = async ({ retry = false } = {}) => {
|
|
14812
16241
|
const promptOnlySave = selectedPromptRole !== null && promptTouchedRoles.length > 0;
|
|
14813
16242
|
if (!hasUnsavedChanges || saving || settingsSnapshot.writable !== true
|
|
14814
|
-
|| (!promptOnlySave &&
|
|
16243
|
+
|| (!promptOnlySave && !catalogAvailable)) return false;
|
|
14815
16244
|
const source = normalizeSettingsDraft(settingsSnapshot.value, roles);
|
|
14816
16245
|
// 编辑器只修改提示词时必须以当前完整 snapshot 为候选基底:首次 CAS 会因旧 revision
|
|
14817
16246
|
// 冲突而失败;明确重试时才以最新 snapshot + 用户实际提示词字段提交,绝不重放旧模型/通知。
|
|
@@ -14820,10 +16249,12 @@ window.__ModuleLoader__.load({
|
|
|
14820
16249
|
const draftProviderIntent = promptOnlySave ? sourceProvider : (typeof draft.provider === "string" ? draft.provider : "");
|
|
14821
16250
|
const draftProvider = unifiedProviderOf(normalizedDraft.defaults, roles, draftProviderIntent);
|
|
14822
16251
|
const defaultsChanged = !settingsDraftEqual(normalizedDraft.defaults, source.defaults);
|
|
14823
|
-
const catalogReady = directoryState.status === "ready";
|
|
14824
16252
|
// provider 的模型变化由 defaults 差异或用户明确改变统一选择器决定;通知字段单独保存时
|
|
14825
16253
|
// draftProviderIntent 与 sourceProvider 相同,因此不会把旧顶层 provider 强行写回行级配置。
|
|
14826
|
-
|
|
16254
|
+
// 目录门控与下拉同源(catalogAvailable):缓存在手时用户从缓存里选出的 provider+model
|
|
16255
|
+
// 是合法候选,必须能随这次保存一起提交,否则就成了"能选不能存";反之没有任何目录时
|
|
16256
|
+
// 这里仍然不写,绝不凭空写入一个无法校验的 provider。
|
|
16257
|
+
const modelChanged = !promptOnlySave && catalogAvailable && (defaultsChanged || draftProviderIntent !== sourceProvider);
|
|
14827
16258
|
|
|
14828
16259
|
// 提示词是完整 settings 候选中的一个全局映射。只有被编辑过的角色才会从草稿
|
|
14829
16260
|
// 覆盖 source;恢复默认通过删除对应键表达,避免把默认文本固化成冗余覆盖。
|
|
@@ -14889,6 +16320,11 @@ window.__ModuleLoader__.load({
|
|
|
14889
16320
|
if (candidate.taskManagementEnabled !== source.taskManagementEnabled) {
|
|
14890
16321
|
ops.push({ op: "set", path: ["taskManagementEnabled"], value: candidate.taskManagementEnabled });
|
|
14891
16322
|
}
|
|
16323
|
+
// 自动打开与任务管理同一形状:新客户端的完整候选始终显式携带该字段,
|
|
16324
|
+
// Host 按「显式字段才写 patch」处理,旧客户端缺字段因此不可能覆盖它。
|
|
16325
|
+
if (candidate.autoOpenSubagentSidebar !== source.autoOpenSubagentSidebar) {
|
|
16326
|
+
ops.push({ op: "set", path: ["autoOpenSubagentSidebar"], value: candidate.autoOpenSubagentSidebar });
|
|
16327
|
+
}
|
|
14892
16328
|
if (promptChanged) {
|
|
14893
16329
|
ops.push({ op: "set", path: ["promptOverrides"], value: candidatePromptOverrides });
|
|
14894
16330
|
}
|
|
@@ -15247,6 +16683,12 @@ window.__ModuleLoader__.load({
|
|
|
15247
16683
|
react.createElement("span", { className: "deg_settings_table_head" }, "供应商"),
|
|
15248
16684
|
react.createElement("span", { className: "deg_settings_table_head" }, "模型选择"),
|
|
15249
16685
|
react.createElement("span", { className: "deg_settings_table_head" }, "思考深度"));
|
|
16686
|
+
// 行级三个下拉的**目录门控**统一走 catalogAvailable(ready 或已有缓存目录):
|
|
16687
|
+
// 刷新中的 loading、以及最新一次刷新失败后的 error,都只代表"内容可能不是最新",
|
|
16688
|
+
// 只要缓存目录还在,里面的 provider/model/思考深度就仍然是用户可选的合法值;
|
|
16689
|
+
// 而首次挂载(还没有任何成功目录)与 connection/reset 清空目录之后 groups 为空,
|
|
16690
|
+
// catalogAvailable 为 false,三个下拉和保存按钮一并禁用。
|
|
16691
|
+
// 业务禁用条件(saving / roleEnabled / 没有 provider、model、reasoning)保持不变。
|
|
15250
16692
|
const rows = PROMPT_ROLE_IDS.map((role) => {
|
|
15251
16693
|
const roleEnabled = roles.includes(role);
|
|
15252
16694
|
const selection = draft.defaults[role] ?? null;
|
|
@@ -15350,7 +16792,7 @@ window.__ModuleLoader__.load({
|
|
|
15350
16792
|
className: "deg_settings_provider_select",
|
|
15351
16793
|
"aria-label": (SETTINGS_ROLE_LABELS[role] ?? role) + " 供应商",
|
|
15352
16794
|
value: rowProvider,
|
|
15353
|
-
disabled: saving ||
|
|
16795
|
+
disabled: saving || !catalogAvailable || !roleEnabled,
|
|
15354
16796
|
onChange: updateProvider,
|
|
15355
16797
|
}, providerOptions)),
|
|
15356
16798
|
react.createElement("div", { className: "deg_settings_table_cell" },
|
|
@@ -15358,7 +16800,7 @@ window.__ModuleLoader__.load({
|
|
|
15358
16800
|
className: "deg_settings_model_select",
|
|
15359
16801
|
"aria-label": (SETTINGS_ROLE_LABELS[role] ?? role) + " 模型选择",
|
|
15360
16802
|
value: selection?.model ?? "",
|
|
15361
|
-
disabled: saving ||
|
|
16803
|
+
disabled: saving || !catalogAvailable || !roleEnabled || !rowProvider || rowProviderGroup === undefined,
|
|
15362
16804
|
onChange: updateModel,
|
|
15363
16805
|
}, modelOptions)),
|
|
15364
16806
|
react.createElement("div", { className: "deg_settings_table_cell" },
|
|
@@ -15366,7 +16808,7 @@ window.__ModuleLoader__.load({
|
|
|
15366
16808
|
className: "deg_settings_effort_select",
|
|
15367
16809
|
"aria-label": (SETTINGS_ROLE_LABELS[role] ?? role) + " 思考深度",
|
|
15368
16810
|
value: selection?.reasoningEffort ?? "",
|
|
15369
|
-
disabled: saving ||
|
|
16811
|
+
disabled: saving || !catalogAvailable || !roleEnabled || reasoning === undefined || !selection?.model,
|
|
15370
16812
|
onChange: updateReasoning,
|
|
15371
16813
|
}, effortOptions)));
|
|
15372
16814
|
});
|
|
@@ -15420,7 +16862,7 @@ window.__ModuleLoader__.load({
|
|
|
15420
16862
|
react.createElement("select", {
|
|
15421
16863
|
id: "deg-settings-provider",
|
|
15422
16864
|
value: unifiedProvider,
|
|
15423
|
-
disabled: saving ||
|
|
16865
|
+
disabled: saving || !catalogAvailable,
|
|
15424
16866
|
onChange: (event) => editDraft((previous) => {
|
|
15425
16867
|
const provider = event.target.value;
|
|
15426
16868
|
if (!provider) {
|
|
@@ -15456,6 +16898,17 @@ window.__ModuleLoader__.load({
|
|
|
15456
16898
|
onChange: (event) => editDraft((previous) => ({ ...previous, taskManagementEnabled: event.target.checked })),
|
|
15457
16899
|
}),
|
|
15458
16900
|
react.createElement("span", { className: "deg_settings_label" }, "任务管理")),
|
|
16901
|
+
// 「Auto」= 自动打开运行中的子代理右侧栏会话:与任务管理同一行的紧凑开关。
|
|
16902
|
+
// 说明性文案只放在 title(悬浮提示)里,可见文字保持一个单词,不挤压既有控件。
|
|
16903
|
+
react.createElement("label", { className: "deg_settings_checkbox", title: "自动打开运行中的子代理侧栏会话" },
|
|
16904
|
+
react.createElement("input", {
|
|
16905
|
+
type: "checkbox",
|
|
16906
|
+
"aria-label": "Auto",
|
|
16907
|
+
checked: draft.autoOpenSubagentSidebar,
|
|
16908
|
+
disabled: saving,
|
|
16909
|
+
onChange: (event) => editDraft((previous) => ({ ...previous, autoOpenSubagentSidebar: event.target.checked === true })),
|
|
16910
|
+
}),
|
|
16911
|
+
react.createElement("span", { className: "deg_settings_label" }, "Auto")),
|
|
15459
16912
|
react.createElement("label", { className: "deg_settings_control", htmlFor: "deg-settings-role-concurrency-limit" },
|
|
15460
16913
|
react.createElement("span", { className: "deg_settings_label" }, "单个角色并发上限"),
|
|
15461
16914
|
react.createElement("input", {
|
|
@@ -15484,7 +16937,7 @@ window.__ModuleLoader__.load({
|
|
|
15484
16937
|
react.createElement("div", { className: "deg_settings_footer" },
|
|
15485
16938
|
react.createElement("span", { className: "deg_settings_status", role: "status" }, saved ? "已保存" : hasUnsavedChanges ? "有未保存的修改" : ""),
|
|
15486
16939
|
react.createElement("button", { type: "button", className: "deg_btn", disabled: !hasUnsavedChanges || saving, onClick: discard }, "放弃修改"),
|
|
15487
|
-
react.createElement("button", { type: "button", className: "deg_btn", disabled: !hasUnsavedChanges || saving ||
|
|
16940
|
+
react.createElement("button", { type: "button", className: "deg_btn", disabled: !hasUnsavedChanges || saving || !catalogAvailable || settingsSnapshot.writable !== true, onClick: save }, saving ? "保存中…" : "保存")));
|
|
15488
16941
|
}
|
|
15489
16942
|
/**
|
|
15490
16943
|
* 浏览器 FileReader 的等价字节编码:File.arrayBuffer() 取得原始字节,btoa()
|
|
@@ -16197,6 +17650,57 @@ window.__ModuleLoader__.load({
|
|
|
16197
17650
|
slots: ctx.slots,
|
|
16198
17651
|
// 浏览器 confirm 是同步 API,Panel 以 Promise/boolean 双兼容方式消费。
|
|
16199
17652
|
confirm: (message) => typeof globalThis.confirm === "function" && globalThis.confirm(message) === true,
|
|
17653
|
+
/**
|
|
17654
|
+
* 「自动打开」的实时读面:面板每次同步都现读配置表单快照。
|
|
17655
|
+
*
|
|
17656
|
+
* 不在这里读取一次再当常量传下去:设置页保存后必须立刻生效,而重装整个侧边栏
|
|
17657
|
+
* (disposer 不可复用)只为跟随一个偏好是过度且会打断侧栏状态的做法。
|
|
17658
|
+
* 读取失败/字段缺失一律按关闭(历史行为)。
|
|
17659
|
+
*/
|
|
17660
|
+
getAutoOpenSubagentSidebar: () => readAutoOpenSubagentSidebar(),
|
|
17661
|
+
/** 面板标题栏 Auto 开关的写面:必须显式提交布尔值,否则设置页会一直显示旧值。 */
|
|
17662
|
+
setAutoOpenSubagentSidebar: (next) => commitAutoOpenSubagentSidebar(next),
|
|
17663
|
+
};
|
|
17664
|
+
/**
|
|
17665
|
+
* 「自动打开运行中的子代理侧栏」读面:唯一来源是配置表单当前快照。
|
|
17666
|
+
*
|
|
17667
|
+
* 只有显式 true 才算开启——旧文档缺字段、非布尔值与读取异常全部按关闭处理,
|
|
17668
|
+
* 保证默认行为与历史逐字节一致(不做任何自动 open/close)。
|
|
17669
|
+
*/
|
|
17670
|
+
const readAutoOpenSubagentSidebar = () => {
|
|
17671
|
+
try {
|
|
17672
|
+
const snapshot = typeof settingsForm?.getSnapshot === "function" ? settingsForm.getSnapshot() : void 0;
|
|
17673
|
+
return snapshot?.value?.autoOpenSubagentSidebar === true;
|
|
17674
|
+
} catch {
|
|
17675
|
+
return false;
|
|
17676
|
+
}
|
|
17677
|
+
};
|
|
17678
|
+
/**
|
|
17679
|
+
* 写面:把表单快照原样作为整体候选,只翻转 autoOpenSubagentSidebar 一个字段。
|
|
17680
|
+
*
|
|
17681
|
+
* 走的是设置页同一条 settings-commit(完整候选 + expectedRevision CAS),因此
|
|
17682
|
+
* 宿主的语义校验与并发栅栏照常生效;这里**不**重放设置页草稿,避免把未保存的
|
|
17683
|
+
* 编辑顺带提交。只读文档、快照不可用、校验失败或传输失败都返回 false,面板据此
|
|
17684
|
+
* 回退到权威读面(开关不会停在错误状态)。
|
|
17685
|
+
*/
|
|
17686
|
+
const commitAutoOpenSubagentSidebar = async (next) => {
|
|
17687
|
+
try {
|
|
17688
|
+
const snapshot = typeof settingsForm?.getSnapshot === "function" ? settingsForm.getSnapshot() : void 0;
|
|
17689
|
+
if (snapshot?.writable !== true) return false;
|
|
17690
|
+
const value = snapshot.value;
|
|
17691
|
+
if (value === null || typeof value !== "object") return false;
|
|
17692
|
+
await commitSettingsCandidate(
|
|
17693
|
+
{ ...value, autoOpenSubagentSidebar: next === true },
|
|
17694
|
+
snapshot.revision,
|
|
17695
|
+
globalThis.fetch,
|
|
17696
|
+
ctx?.connection,
|
|
17697
|
+
PROMPT_ROLE_IDS,
|
|
17698
|
+
);
|
|
17699
|
+
return true;
|
|
17700
|
+
} catch {
|
|
17701
|
+
// 失败原因(只读/目录校验/修订冲突)由设置页承担呈现;面板只回退开关状态。
|
|
17702
|
+
return false;
|
|
17703
|
+
}
|
|
16200
17704
|
};
|
|
16201
17705
|
const readTaskManagementEnabled = () => {
|
|
16202
17706
|
try {
|