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
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// ACTIVE_STATUSES / RETRY_STATUSES 只服务于 fan-out 管理语义(取消/重试判定),不是展示状态词表。
|
|
17
17
|
const ACTIVE_STATUSES = new Set(["queued", "waiting", "running"]);
|
|
18
18
|
const RETRY_STATUSES = new Set(["failed", "timed_out", "interrupted"]);
|
|
19
|
+
// 父任务的规范终态表示全部分片已结算,且不会把未知/展示态误判为空闲。
|
|
20
|
+
const FANOUT_SETTLED_STATUSES = new Set(["succeeded", "failed", "cancelled"]);
|
|
19
21
|
const STYLE_ID = "dsh-expert-group/expert-group-sidebar";
|
|
20
22
|
/**
|
|
21
23
|
* Expert Group 页的右侧栏宽度:**恒为视口宽度的 20%**(1600px 视口 ⇒ 320px)。
|
|
@@ -175,6 +177,31 @@
|
|
|
175
177
|
const SIDEBAR_SPLIT_PENDING_MAX_ATTEMPTS = 4;
|
|
176
178
|
/** 侧栏打开状态的复合键分隔符:会话 id、rootSessionId 与资源地址都是单行文本,NUL 不会出现在其中。 */
|
|
177
179
|
const SIDEBAR_TAB_KEY_SEPARATOR = "\u0000";
|
|
180
|
+
/**
|
|
181
|
+
* 右栏 tab 的两个来源。
|
|
182
|
+
*
|
|
183
|
+
* - `auto`:「自动打开」(Auto)本次运行亲手打开的 tab,只有它允许被自动关闭;
|
|
184
|
+
* - `manual`:用户点击箭头打开、或外部已存在但不由本插件自动动作发起的 tab。
|
|
185
|
+
* 双来源是「手动优先」判定唯一依据:自动同步绝不关闭 manual 来源的 tab。
|
|
186
|
+
*/
|
|
187
|
+
const SIDEBAR_TAB_ORIGIN_AUTO = "auto";
|
|
188
|
+
const SIDEBAR_TAB_ORIGIN_MANUAL = "manual";
|
|
189
|
+
/**
|
|
190
|
+
* 自动关闭的有界重试上限。
|
|
191
|
+
*
|
|
192
|
+
* 宿主 close(tabId) 可能同步抛错或长期失败;自动同步每 2.5 秒一轮,没有上限就会变成
|
|
193
|
+
* 后台重试风暴。达到上限后只撤销「这是 Auto 打开的」记账(tab 原样留给用户手动关闭),
|
|
194
|
+
* 绝不猜测 tabId、也绝不改用批量关闭入口。
|
|
195
|
+
*/
|
|
196
|
+
const SIDEBAR_AUTO_CLOSE_MAX_ATTEMPTS = 3;
|
|
197
|
+
/**
|
|
198
|
+
* 自动来源/抑制记集合的条目上限(按插入顺序淘汰最旧一项)。
|
|
199
|
+
*
|
|
200
|
+
* 复合键含 rootSessionId:面板实例跨多个 root 复用时,别的 root 的键在当前快照里无法判定
|
|
201
|
+
* 是否仍然存在(快照只描述当前 root),因此除了「快照里已消失就清理」之外还需要这条上限,
|
|
202
|
+
* 保证长期运行的面板不会无界增长。
|
|
203
|
+
*/
|
|
204
|
+
const SIDEBAR_AUTO_KEY_LIMIT = 128;
|
|
178
205
|
|
|
179
206
|
function object(value) {
|
|
180
207
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -945,6 +972,11 @@
|
|
|
945
972
|
".deg_expert_group_header_title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
946
973
|
".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}",
|
|
947
974
|
".deg_expert_group_header_action{width:24px;height:24px;margin-left:auto}",
|
|
975
|
+
// 标题栏 Auto 开关:紧凑到与标题同一行,右对齐且不换行,绝不遮挡标题或右侧操作按钮。
|
|
976
|
+
".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}",
|
|
977
|
+
".deg_expert_group_header_auto_input{margin:0;cursor:pointer}",
|
|
978
|
+
".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}",
|
|
979
|
+
".deg_expert_group_header_auto_label{font-size:12px;line-height:16px}",
|
|
948
980
|
".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)}",
|
|
949
981
|
".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}",
|
|
950
982
|
".deg_expert_group_tree{min-width:0;padding-top:5px}",
|
|
@@ -983,6 +1015,7 @@
|
|
|
983
1015
|
".deg_expert_group_task:hover,.deg_expert_group_content:hover{text-decoration:underline}",
|
|
984
1016
|
".deg_expert_group_content{grid-area:3 / 1;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));cursor:pointer}",
|
|
985
1017
|
".deg_expert_group_chevron{width:20px;height:20px;padding:0;align-self:start}",
|
|
1018
|
+
".deg_expert_group_fanout_header>.deg_expert_group_chevron{align-self:center}",
|
|
986
1019
|
".deg_expert_group_chevron_placeholder{display:inline-block;width:20px;height:20px}",
|
|
987
1020
|
".deg_expert_group_status_icon{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;color:var(--dsw-alias-label-secondary)}",
|
|
988
1021
|
// 兜底圆点:官方 primitives.StateDot 缺失时才使用,静态、无动画。
|
|
@@ -2158,6 +2191,16 @@
|
|
|
2158
2191
|
const versionPair = typeof react.useState === "function" ? react.useState(0) : [0, () => {}];
|
|
2159
2192
|
const version = versionPair[0];
|
|
2160
2193
|
const bump = versionPair[1];
|
|
2194
|
+
/**
|
|
2195
|
+
* 面板标题栏 Auto 开关的本地乐观值(null = 跟随权威读面)。
|
|
2196
|
+
*
|
|
2197
|
+
* 开关的持久化要走一次 settings-commit 往返;没有本地覆盖时,用户点击后要等下一次
|
|
2198
|
+
* 轮询(最长 2.5 秒)才会看到勾选状态变化。这里先乐观翻转,写失败或权威值变化时
|
|
2199
|
+
* 立刻回退到读面值(绝不让开关停在错误状态)。
|
|
2200
|
+
*/
|
|
2201
|
+
const autoOpenDraftPair = typeof react.useState === "function" ? react.useState(null) : [null, () => {}];
|
|
2202
|
+
const autoOpenDraft = autoOpenDraftPair[0];
|
|
2203
|
+
const setAutoOpenDraft = autoOpenDraftPair[1];
|
|
2161
2204
|
const model = modelApi();
|
|
2162
2205
|
// 展开状态只依赖 storage 与 root;缓存 store 可避免每次 RPC/通知重渲染都重复读取
|
|
2163
2206
|
// sessionStorage 和解析同一份展开键。
|
|
@@ -2305,21 +2348,91 @@
|
|
|
2305
2348
|
const refreshRef = typeof react.useRef === "function" ? react.useRef(refresh) : { current: refresh };
|
|
2306
2349
|
refreshRef.current = refresh;
|
|
2307
2350
|
/**
|
|
2308
|
-
* 子代理侧栏打开状态:复合键(sessionId + rootSessionId + 资源地址)→
|
|
2351
|
+
* 子代理侧栏打开状态:复合键(sessionId + rootSessionId + 资源地址)→
|
|
2352
|
+
* { sessionId, rootSessionId, tabId, title, misses, origin }。
|
|
2309
2353
|
*
|
|
2310
2354
|
* 用 ref 而不是 state 的理由与 pendingSet 相同:一次渲染里可能同时存在多个已打开的子代理
|
|
2311
2355
|
* (每个 root、每个资源地址各一条记录),状态必须按「哪个 root 的哪个地址」隔离,绝不使用
|
|
2312
2356
|
* 单一全局 boolean——那会让两个子代理互相顶掉,也无法区分不同会话。重渲染统一走 bump。
|
|
2313
2357
|
* misses 是该 tab 连续被观测到缺失的次数(见 SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS)。
|
|
2358
|
+
* origin 是该 tab 的来源(auto | manual,见 SIDEBAR_TAB_ORIGIN_*):只有 auto 来源才允许
|
|
2359
|
+
* 被自动同步关闭,manual 来源(用户手动打开、外部已存在的 tab)一律不碰。
|
|
2314
2360
|
*/
|
|
2315
2361
|
const sidebarTabRecordsRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2316
2362
|
/**
|
|
2317
|
-
* 已发起但尚未从 active() 得到落地证据的打开动作:
|
|
2363
|
+
* 已发起但尚未从 active() 得到落地证据的打开动作:
|
|
2364
|
+
* key → { sessionId, rootSessionId, resourceAddress, expiresAt, origin }。
|
|
2318
2365
|
*
|
|
2319
2366
|
* DSH 的 openResource 可能先提交导航意图,下一次渲染才出现 active tab;这段窗口不能让第二次
|
|
2320
2367
|
* 点击再次 openResource,也不能提前伪造 <<。observer/轮询读到匹配 active() 后才转成正式记录。
|
|
2321
2368
|
*/
|
|
2322
2369
|
const sidebarTabOpeningRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2370
|
+
/**
|
|
2371
|
+
* Auto(自动打开运行中的子代理侧栏)的来源与抑制账本,键与打开记录完全同构
|
|
2372
|
+
* (sidebar session + rootSessionId + 资源地址),因此不同会话/root 的状态互不影响。
|
|
2373
|
+
*
|
|
2374
|
+
* - 来源:Auto 本次运行亲手发出过打开动作的资源。它同时是「不要重复打开」的记账:
|
|
2375
|
+
* 命中即跳过(哪怕这次打开始终没有落地证据,也绝不再发第二次 openResource);
|
|
2376
|
+
* 只有这个集合里的 key 才允许被自动关闭。
|
|
2377
|
+
* - 抑制:用户手动关闭过的资源。Auto 不得立刻重开它;用户手动重新打开同一资源时清除
|
|
2378
|
+
* (手动意图始终优先)。
|
|
2379
|
+
* 两个集合都按当前快照与上限清理(见 syncAutoSidebarTabs 的清理阶段)。
|
|
2380
|
+
*/
|
|
2381
|
+
const sidebarAutoOriginRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2382
|
+
const sidebarAutoSuppressedRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2383
|
+
/** 自动关闭的重试记账:key → 已尝试次数(成功或资源重新变成运行中时清空)。 */
|
|
2384
|
+
const sidebarAutoCloseAttemptsRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2385
|
+
/**
|
|
2386
|
+
* 自动打开的**预留**(reservation):key → { sessionId, rootSessionId, address, epoch }。
|
|
2387
|
+
*
|
|
2388
|
+
* 与来源标记严格分离:预留只在候选选择的那一刻写入,用来挡住同一资源的重复动作;只有
|
|
2389
|
+
* openResource 真的成功发出、且宿主有可追踪能力时才升级成「Auto 亲手打开」的来源。
|
|
2390
|
+
* split busy、准备失败、layout 失效、open 抛错、root/sidebar session 变化等失败路径一律
|
|
2391
|
+
* 撤销预留(见 handleSidebarAction 的 finally):否则一次失败会被永久记成「已经开过」,
|
|
2392
|
+
* 该资源在本次运行里再也打不开。
|
|
2393
|
+
*/
|
|
2394
|
+
const sidebarAutoReservationRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2395
|
+
/**
|
|
2396
|
+
* 自动动作的级联标记与失败屏障。
|
|
2397
|
+
*
|
|
2398
|
+
* 撤销预留之后,「下一次同步」会立刻重新选中同一个资源,而自动动作收尾时又会立刻跑一次
|
|
2399
|
+
* 尾随同步。若宿主的失败是**持续**的(本会话的 split 事务仍在途 ⇒ busy;宿主持续拒绝分栏;
|
|
2400
|
+
* openResource 持续抛错),「失败 → 撤销预留 → 立刻重选」这条链路会在微任务里自转:它永远
|
|
2401
|
+
* 不让出宏任务,宿主的提交证据也就永远不会被观察到,页面与预算一起被拖住。
|
|
2402
|
+
*
|
|
2403
|
+
* 因此:同一次级联(自动动作收尾引发的那条同步链)里失败过的 key 不再入选;失败仍然可重试,
|
|
2404
|
+
* 但下一次尝试一定发生在**外部同步轮**(2.5 秒轮询 / observer / 可见性变化 / 会话通知)里,
|
|
2405
|
+
* 那里才能观察到形成失败条件的宿主状态是否已经改变。
|
|
2406
|
+
*/
|
|
2407
|
+
const sidebarAutoCascadeRef = typeof react.useRef === "function" ? react.useRef(false) : { current: false };
|
|
2408
|
+
const sidebarAutoFailedKeysRef = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
|
|
2409
|
+
/**
|
|
2410
|
+
* Auto 动作代次:Auto 开关的权威值每次变化(含关闭)时递增。
|
|
2411
|
+
*
|
|
2412
|
+
* 在途自动动作在自己那一轮渲染的闭包里捕获取代次,并在每个 await 之后、真正调用宿主
|
|
2413
|
+
* openResource()/close() 之前复核:代次不符说明用户已经关掉(或重新打开)Auto,那次自动
|
|
2414
|
+
* 动作一律放弃——绝不把「已经关掉的 Auto」变成一次迟到的用户可见打开。
|
|
2415
|
+
*/
|
|
2416
|
+
const sidebarAutoEpochRef = typeof react.useRef === "function" ? react.useRef(0) : { current: 0 };
|
|
2417
|
+
/**
|
|
2418
|
+
* 用户手动意图记录:key → { sessionId, rootSessionId, address, intent }。
|
|
2419
|
+
*
|
|
2420
|
+
* 唯一目的:Auto 的动作正在同一个 key 上等待(split/layout/open)时,手动点击不能被 pending
|
|
2421
|
+
* 锁直接丢弃。意图只记录「用户要打开还是关闭」,由在途动作在副作用之前让位,或由同一条同步
|
|
2422
|
+
* 链路在它收尾后结算(见 applyPendingManualIntent):手动关闭绝不被 Auto 随后重开,手动打开
|
|
2423
|
+
* 的最终 tab 一律记为 manual 来源。
|
|
2424
|
+
*/
|
|
2425
|
+
const sidebarManualIntentRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
|
|
2426
|
+
/**
|
|
2427
|
+
* Auto 动作串行闸门。
|
|
2428
|
+
*
|
|
2429
|
+
* 自动同步可能同时看到多个候选(多个运行中的子代理),但同一 tick 里连发多个
|
|
2430
|
+
* split()/openResource() 会互相抢 pane(第二个动作看到的 pane 证据仍是第一帧之前的)。
|
|
2431
|
+
* 因此一次只放行一个动作,动作收尾后由同一条同步链路推进下一个候选。
|
|
2432
|
+
*/
|
|
2433
|
+
const sidebarAutoBusyRef = typeof react.useRef === "function" ? react.useRef(false) : { current: false };
|
|
2434
|
+
/** 最近一次同步观察到的 Auto 开关值;变化时 bump 一次(空闲轮询绝不重渲染)。 */
|
|
2435
|
+
const sidebarAutoSignatureRef = typeof react.useRef === "function" ? react.useRef(null) : { current: null };
|
|
2323
2436
|
/**
|
|
2324
2437
|
* 当前首次 split 的用户意图;它跨越 split → openResource → openTabs/DOM 提交窗口,
|
|
2325
2438
|
* 只能在目标 tab 已经落到返回的 pane 后才交给 install 侧结算。
|
|
@@ -2488,6 +2601,41 @@
|
|
|
2488
2601
|
const commitSidebarSplit = typeof config.commitSidebarSplit === "function" ? config.commitSidebarSplit : null;
|
|
2489
2602
|
const cancelSidebarSplit = typeof config.cancelSidebarSplit === "function" ? config.cancelSidebarSplit : null;
|
|
2490
2603
|
const openSidebarResource = methodOf(sidebarRight, "openResource");
|
|
2604
|
+
/**
|
|
2605
|
+
* Auto(自动打开运行中的子代理侧栏)的实时读面:install 注入的 getter,每次同步现读。
|
|
2606
|
+
*
|
|
2607
|
+
* 直接 createExpertGroupPanel(旧宿主/单测)没有该依赖时按**关闭**处理:默认行为必须
|
|
2608
|
+
* 与历史逐字节一致(不做任何自动 open/close)。读取抛错同样按关闭处理——自动行为只是
|
|
2609
|
+
* 体验增强,绝不能影响面板渲染或既有的手动箭头。
|
|
2610
|
+
*/
|
|
2611
|
+
const readAutoOpenSetting = typeof config.getAutoOpenSubagentSidebar === "function"
|
|
2612
|
+
? config.getAutoOpenSubagentSidebar
|
|
2613
|
+
: null;
|
|
2614
|
+
/** 面板标题栏 Auto 开关的写面;缺失时开关只读(渲染 disabled),绝不假装能改。 */
|
|
2615
|
+
const writeAutoOpenSetting = typeof config.setAutoOpenSubagentSidebar === "function"
|
|
2616
|
+
? config.setAutoOpenSubagentSidebar
|
|
2617
|
+
: null;
|
|
2618
|
+
const autoOpenEnabled = () => {
|
|
2619
|
+
if (readAutoOpenSetting === null) return false;
|
|
2620
|
+
try { return readAutoOpenSetting() === true; } catch { return false; }
|
|
2621
|
+
};
|
|
2622
|
+
/**
|
|
2623
|
+
* 标题栏 Auto 开关的点击处理:先本地乐观翻转,再把显式布尔交给写面持久化。
|
|
2624
|
+
*
|
|
2625
|
+
* 写面缺失或返回非 true(只读文档、宿主校验/并发失败)时立刻清掉本地值,开关回退到权威读面;
|
|
2626
|
+
* 成功时本地值与读面一致,下一次同步观察到权威值变化会再清一次(幂等,绝不闪回)。
|
|
2627
|
+
*/
|
|
2628
|
+
const toggleAutoOpen = (next) => {
|
|
2629
|
+
const value = next === true;
|
|
2630
|
+
if (writeAutoOpenSetting === null) return;
|
|
2631
|
+
setAutoOpenDraft(value);
|
|
2632
|
+
void Promise.resolve()
|
|
2633
|
+
.then(() => writeAutoOpenSetting(value))
|
|
2634
|
+
.then(
|
|
2635
|
+
(accepted) => { if (accepted !== true) setAutoOpenDraft(null); },
|
|
2636
|
+
() => { setAutoOpenDraft(null); },
|
|
2637
|
+
);
|
|
2638
|
+
};
|
|
2491
2639
|
// 新版宿主提供公开 split(),返回新 pane 的 PaneId;只有拿到这个回执后才打开资源,
|
|
2492
2640
|
// 避免 preferNewPane 失败时静默回落到 Expert Group 所在 pane。旧宿主没有 split() 时
|
|
2493
2641
|
// 保留 preferNewPane 兼容路径,但绝不调用 float(),不制造用户可见浮窗。
|
|
@@ -2964,11 +3112,15 @@
|
|
|
2964
3112
|
const sidebarRootChildren = () => (Array.isArray(snapshot?.root?.children) ? snapshot.root.children : []);
|
|
2965
3113
|
|
|
2966
3114
|
/**
|
|
2967
|
-
*
|
|
2968
|
-
*
|
|
3115
|
+
* 空闲分组收纳 ready 直接专家和已结算的 fan-out 父任务。
|
|
3116
|
+
* 父任务必须命中 manager 终态,不能仅凭子会话暂时未运行或未知状态提前移动。
|
|
2969
3117
|
*/
|
|
2970
3118
|
const sidebarIdleChildren = () => sidebarRootChildren()
|
|
2971
|
-
.filter((node) =>
|
|
3119
|
+
.filter((node) => {
|
|
3120
|
+
if (node.kind === "fanout") return FANOUT_SETTLED_STATUSES.has(statusOf(node));
|
|
3121
|
+
return node.kind === "expert"
|
|
3122
|
+
&& runtimeStatusOfNode(node, rootSessionId, text(node.sessionId, null)) === "ready";
|
|
3123
|
+
});
|
|
2972
3124
|
|
|
2973
3125
|
/**
|
|
2974
3126
|
* 当前 root 专家树里「运行中」的资源地址集合(主代理行批量关闭的唯一候选来源)。
|
|
@@ -2981,12 +3133,26 @@
|
|
|
2981
3133
|
};
|
|
2982
3134
|
|
|
2983
3135
|
/**
|
|
2984
|
-
*
|
|
3136
|
+
* 空闲分组的资源地址集合;成员判定严格复用当前 idleChildren。
|
|
3137
|
+
* fan-out 父任务代表其最新 attempt 的分片会话,只收录当前未运行的会话地址。
|
|
2985
3138
|
*/
|
|
2986
3139
|
const idleSidebarResourceAddresses = (nodes) => {
|
|
2987
3140
|
const addresses = new Set();
|
|
2988
3141
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
|
2989
3142
|
const source = object(node);
|
|
3143
|
+
if (source.kind === "fanout") {
|
|
3144
|
+
for (const shard of Array.isArray(source.shards) ? source.shards : []) {
|
|
3145
|
+
const shardSource = object(shard);
|
|
3146
|
+
const shardChildId = text(shardSource.childId, null);
|
|
3147
|
+
if (shardChildId === null || runtimeStatusOfNode(shardSource, rootSessionId, shardChildId) !== "ready") continue;
|
|
3148
|
+
const shardAddress = subagentChatAddress(subagentAddressOfChild(shardChildId, rootSessionId, {
|
|
3149
|
+
...shardSource,
|
|
3150
|
+
kind: "expert",
|
|
3151
|
+
}));
|
|
3152
|
+
if (shardAddress !== null) addresses.add(shardAddress);
|
|
3153
|
+
}
|
|
3154
|
+
continue;
|
|
3155
|
+
}
|
|
2990
3156
|
const childId = text(source.sessionId, text(source.childId, null));
|
|
2991
3157
|
if (childId === null) continue;
|
|
2992
3158
|
const address = subagentChatAddress(subagentAddressOfChild(childId, rootSessionId, source));
|
|
@@ -3155,7 +3321,13 @@
|
|
|
3155
3321
|
}
|
|
3156
3322
|
};
|
|
3157
3323
|
|
|
3158
|
-
|
|
3324
|
+
/**
|
|
3325
|
+
* 第一阶段:既有「右栏 tab 记录/库存同步」(外部关闭观察、在途打开结算、批量签名更新)。
|
|
3326
|
+
*
|
|
3327
|
+
* 它保留了原有的提前 return(records 为空时不做任何 DOM 查询);自动同步因此被拆到
|
|
3328
|
+
* 独立的第二阶段(syncSidebarTabs),那些短路不会再吞掉自动打开/关闭。
|
|
3329
|
+
*/
|
|
3330
|
+
const syncSidebarTabRecords = () => {
|
|
3159
3331
|
// 右栏拖拽手柄锁复用这条既有生命周期(挂载 / body 子树变更 / 2.5 秒轮询 / 可见性变化):
|
|
3160
3332
|
// 活跃 tab 一变就重估,绝不为它单独再挂一个长期 observer。请求失败只影响这一次重估。
|
|
3161
3333
|
try { requestRightbarDragSync?.(); } catch { /* 手柄锁只是体验优化,绝不影响面板。 */ }
|
|
@@ -3198,6 +3370,22 @@
|
|
|
3198
3370
|
}
|
|
3199
3371
|
// 先完成精确 split 提交,再执行关闭同步;guide-only 或错误 pane 仍继续等待。
|
|
3200
3372
|
tryCommitSidebarSplit();
|
|
3373
|
+
/**
|
|
3374
|
+
* 手动意图的兜底结算扫描(在任何提前 return 之前):
|
|
3375
|
+
* 在途动作收尾、打开记录落地或超时都可能让一个意图变成可结算状态。数量级等于用户点击次数,
|
|
3376
|
+
* 因此每轮扫描的成本可以忽略;仍然在途的意图由 applyPendingManualIntent 自己跳过。
|
|
3377
|
+
* 别的 session/root 的条目在这里永远结算不了(它属于那个 surface 自己的闭包),直接删除,
|
|
3378
|
+
* 保证面板跨 root 复用时不泄漏手动意图。
|
|
3379
|
+
*/
|
|
3380
|
+
for (const key of [...sidebarManualIntentRef.current.keys()]) {
|
|
3381
|
+
const entry = sidebarManualIntentRef.current.get(key);
|
|
3382
|
+
if (entry === undefined) continue;
|
|
3383
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) {
|
|
3384
|
+
sidebarManualIntentRef.current.delete(key);
|
|
3385
|
+
continue;
|
|
3386
|
+
}
|
|
3387
|
+
applyPendingManualIntent(key);
|
|
3388
|
+
}
|
|
3201
3389
|
// 先完成打开记录,再执行关闭同步;同 pane 回落不再被转换成独立浮窗。
|
|
3202
3390
|
// 这里保留「没有打开记录就直接返回」的既有短路(它保证空闲时不会发生任何 DOM 查询);
|
|
3203
3391
|
// 批量控件的聚合状态更新已经在**这个短路之前**完成,因此 records 为空时 inventory 的变化
|
|
@@ -3227,6 +3415,433 @@
|
|
|
3227
3415
|
}
|
|
3228
3416
|
bump((value) => value + 1);
|
|
3229
3417
|
};
|
|
3418
|
+
|
|
3419
|
+
/**
|
|
3420
|
+
* 当前 sidebar session 的公开 inventory 里,某个资源地址对应的已提交 tabId。
|
|
3421
|
+
*
|
|
3422
|
+
* 返回值必须区分两种「没有」——这是能力降级 fail-closed 的唯一判据:
|
|
3423
|
+
* - readable === false:inventory 不可读(旧宿主 / 读取抛错)。此时既证明不了资源已打开,
|
|
3424
|
+
* 也证明不了它没打开,调用方一律不得据此自动打开(否则可能开出重复页);
|
|
3425
|
+
* - readable === true && tabId === null:读到了 inventory,确认这个地址没有已提交 tab。
|
|
3426
|
+
* 判定字段与 recordSidebarTab 逐条同源(同 session、kind、contentId),因此「自动打开前的
|
|
3427
|
+
* 存在性检查」与「关闭用的 tabId」永远来自同一个来源,绝不按「当前 active 是谁」猜 tab。
|
|
3428
|
+
*/
|
|
3429
|
+
const sidebarResourceTabStateOf = (resourceAddress) => {
|
|
3430
|
+
const inventory = readSidebarOpenTabs();
|
|
3431
|
+
if (inventory === null) return { readable: false, tabId: null };
|
|
3432
|
+
for (const entry of inventory) {
|
|
3433
|
+
const candidate = object(entry);
|
|
3434
|
+
if (text(candidate.sessionId, null) !== sidebarSessionId) continue;
|
|
3435
|
+
if (text(candidate.kind, null) !== SUBAGENT_CHAT_KIND) continue;
|
|
3436
|
+
if (text(candidate.contentId, null) !== resourceAddress) continue;
|
|
3437
|
+
return { readable: true, tabId: text(candidate.tabId, null) };
|
|
3438
|
+
}
|
|
3439
|
+
return { readable: true, tabId: null };
|
|
3440
|
+
};
|
|
3441
|
+
/**
|
|
3442
|
+
* active() 是否已经是这个资源(kind 与 contentId 同时匹配)。
|
|
3443
|
+
*
|
|
3444
|
+
* inventory 不可读或提交延迟时,它仍能证明「这个资源已经开着」:这样的 tab 只算手动/未知
|
|
3445
|
+
* 来源,Auto 既不接管(不写 auto 来源)也不重复打开;它只在「可追踪」判定里作为证据使用。
|
|
3446
|
+
*/
|
|
3447
|
+
const sidebarActiveResourceMatches = (resourceAddress) => {
|
|
3448
|
+
if (readActiveSidebarResource === null) return false;
|
|
3449
|
+
let tab = null;
|
|
3450
|
+
try { tab = object(readActiveSidebarResource.call(sidebarRight)); } catch { return false; }
|
|
3451
|
+
if (text(tab.kind, null) !== SUBAGENT_CHAT_KIND) return false;
|
|
3452
|
+
return text(tab.contentId, null) === resourceAddress;
|
|
3453
|
+
};
|
|
3454
|
+
/**
|
|
3455
|
+
* 自动打开必须同时具备的能力(缺一项即 fail-closed,绝不自动打开):
|
|
3456
|
+
* 1. 公开 close():没有它,Auto 打开的 tab 将来无法被精确回收(只能用 tabId 关闭,绝不改用
|
|
3457
|
+
* 批量入口或按 active 猜);
|
|
3458
|
+
* 2. 落地证据读面(active() 或 openTabs inventory)与左箭头图标:没有证据就无法确认这次打开
|
|
3459
|
+
* 有没有落地,也就没有可关闭的 << 状态;
|
|
3460
|
+
* 3. 公开 openResource():没有它连打开动作都发不出去。
|
|
3461
|
+
* 只约束自动路径:这些能力缺失时手动箭头与历史行为逐条不变。
|
|
3462
|
+
*/
|
|
3463
|
+
const sidebarAutoOpenCapable = () => openSidebarResource !== null
|
|
3464
|
+
&& closeSidebarResource !== null
|
|
3465
|
+
&& (readActiveSidebarResource !== null || sidebarOpenTabsAvailable === true)
|
|
3466
|
+
&& typeof primitives?.IconChevronLeftOutlineRegular === "function";
|
|
3467
|
+
|
|
3468
|
+
/** 当前 surface 自己的手动意图记录;别的 session/root 的键在当前闭包里一律不可见。 */
|
|
3469
|
+
const sidebarManualIntentOf = (key) => {
|
|
3470
|
+
const entry = sidebarManualIntentRef.current.get(key);
|
|
3471
|
+
if (entry === undefined) return undefined;
|
|
3472
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) return undefined;
|
|
3473
|
+
return entry;
|
|
3474
|
+
};
|
|
3475
|
+
/**
|
|
3476
|
+
* Auto 同步第二阶段的候选选择:先给一个「该关的」候选,再给一个「该开的」候选。
|
|
3477
|
+
*
|
|
3478
|
+
* 一个同步轮次最多产出一个候选(见 sidebarAutoBusyRef):多个候选由动作收尾后的同一条
|
|
3479
|
+
* 同步链路逐个推进,绝不并发发多个 split/openResource。
|
|
3480
|
+
*/
|
|
3481
|
+
const autoCloseCandidate = (running) => {
|
|
3482
|
+
const origin = sidebarAutoOriginRef.current;
|
|
3483
|
+
const records = sidebarTabRecordsRef.current;
|
|
3484
|
+
const openings = sidebarTabOpeningRef.current;
|
|
3485
|
+
const attempts = sidebarAutoCloseAttemptsRef.current;
|
|
3486
|
+
for (const [key, entry] of origin) {
|
|
3487
|
+
// 只处理当前 surface 的记账:别的 session/root 的键在当前快照里无法判定,绝不越权操作。
|
|
3488
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
|
|
3489
|
+
if (running.has(entry.address) === true) {
|
|
3490
|
+
// 资源重新变成运行中:它已经开着就保持开着,重试记账一并清零。
|
|
3491
|
+
attempts.delete(key);
|
|
3492
|
+
continue;
|
|
3493
|
+
}
|
|
3494
|
+
const record = records.get(key);
|
|
3495
|
+
const scopedRecord = record !== undefined
|
|
3496
|
+
&& record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId
|
|
3497
|
+
? record
|
|
3498
|
+
: undefined;
|
|
3499
|
+
if (scopedRecord === undefined) {
|
|
3500
|
+
// 没有已确认的 tabId 时绝不猜一个 id 去关:这次打开仍在途就继续等它落地,
|
|
3501
|
+
// 否则只撤销来源标记(元素已经不在这个 surface 上了)。
|
|
3502
|
+
const opening = openings.get(key);
|
|
3503
|
+
if (opening !== undefined && opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId) continue;
|
|
3504
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
3505
|
+
origin.delete(key);
|
|
3506
|
+
attempts.delete(key);
|
|
3507
|
+
continue;
|
|
3508
|
+
}
|
|
3509
|
+
if (scopedRecord.origin !== SIDEBAR_TAB_ORIGIN_AUTO) {
|
|
3510
|
+
// 记录已经被手动接管(用户手动打开或外部 tab):手动优先,撤销自动来源,绝不关闭它。
|
|
3511
|
+
origin.delete(key);
|
|
3512
|
+
attempts.delete(key);
|
|
3513
|
+
continue;
|
|
3514
|
+
}
|
|
3515
|
+
if (closeSidebarResource === null) {
|
|
3516
|
+
// 宿主没有公开 close(旧宿主):撤销记账,绝不退化成别的关闭方式。
|
|
3517
|
+
origin.delete(key);
|
|
3518
|
+
attempts.delete(key);
|
|
3519
|
+
continue;
|
|
3520
|
+
}
|
|
3521
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
3522
|
+
// 本次级联里已经为它尝试过并失败:同一批微任务里绝不重试(见 sidebarAutoFailedKeysRef)。
|
|
3523
|
+
if (sidebarAutoFailedKeysRef.current.has(key) === true) continue;
|
|
3524
|
+
// 用户刚刚在这个 key 上表达过手动意图:交给手动链路结算,Auto 本轮让位(手动恒优先)。
|
|
3525
|
+
if (sidebarManualIntentOf(key) !== undefined) continue;
|
|
3526
|
+
const used = attempts.get(key) ?? 0;
|
|
3527
|
+
if (used >= SIDEBAR_AUTO_CLOSE_MAX_ATTEMPTS) {
|
|
3528
|
+
// 有界重试用尽:只撤销来源标记,tab 留给用户(绝不无限重试,也绝不改用批量入口)。
|
|
3529
|
+
origin.delete(key);
|
|
3530
|
+
attempts.delete(key);
|
|
3531
|
+
continue;
|
|
3532
|
+
}
|
|
3533
|
+
attempts.set(key, used + 1);
|
|
3534
|
+
// attemptsBefore 让「动作在真正调用 close 之前被撤销」的路径能精确回退这一次尝试计数,
|
|
3535
|
+
// 否则一次被取消的自动关闭就会白烧掉一次有界重试预算。
|
|
3536
|
+
return { key, address: entry.address, kind: "close", attemptsBefore: used };
|
|
3537
|
+
}
|
|
3538
|
+
return null;
|
|
3539
|
+
};
|
|
3540
|
+
const autoOpenCandidate = (running) => {
|
|
3541
|
+
// 能力降级 fail-closed:close()/落地证据/左箭头任一缺失时绝不自动打开(见 sidebarAutoOpenCapable)。
|
|
3542
|
+
if (sidebarAutoOpenCapable() !== true) return null;
|
|
3543
|
+
const origin = sidebarAutoOriginRef.current;
|
|
3544
|
+
const suppressed = sidebarAutoSuppressedRef.current;
|
|
3545
|
+
const reservations = sidebarAutoReservationRef.current;
|
|
3546
|
+
const records = sidebarTabRecordsRef.current;
|
|
3547
|
+
const openings = sidebarTabOpeningRef.current;
|
|
3548
|
+
for (const address of running) {
|
|
3549
|
+
const key = sidebarTabKey(sidebarSessionId, rootSessionId, address);
|
|
3550
|
+
// 本次级联里已经为它尝试过并失败:绝不在同一批微任务里重试(见 sidebarAutoFailedKeysRef)。
|
|
3551
|
+
if (sidebarAutoFailedKeysRef.current.has(key) === true) continue;
|
|
3552
|
+
// 用户手动关闭过:绝不立刻重开(抑制只由用户手动重新打开清除)。
|
|
3553
|
+
if (suppressed.has(key) === true) continue;
|
|
3554
|
+
// 本次运行已经为它成功发出过打开动作:绝不重复打开(与失败无关,失败会撤销预留)。
|
|
3555
|
+
if (origin.has(key) === true) continue;
|
|
3556
|
+
// 已经有一个候选为它预留:同一资源绝不并发两个动作。
|
|
3557
|
+
if (reservations.has(key) === true) continue;
|
|
3558
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
3559
|
+
// 用户在同一个 key 上有未结算的手动意图:让手动链路先结算,Auto 本轮跳过。
|
|
3560
|
+
if (sidebarManualIntentOf(key) !== undefined) continue;
|
|
3561
|
+
const record = records.get(key);
|
|
3562
|
+
if (record !== undefined && record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId) continue;
|
|
3563
|
+
const opening = openings.get(key);
|
|
3564
|
+
if (opening !== undefined && opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId) continue;
|
|
3565
|
+
/**
|
|
3566
|
+
* 存在性检查必须「先可读、再确认没有」:
|
|
3567
|
+
* - inventory 不可读时无法证明资源没打开,fail-closed 跳过这个候选(宁可不动,也不开出重复页);
|
|
3568
|
+
* - inventory 可读但没有该地址、而 active() 已经是它(提交延迟 / 别的 pane):同样视为已经存在
|
|
3569
|
+
* 的手动/未知来源,不重复打开,也不写 auto 来源。
|
|
3570
|
+
*/
|
|
3571
|
+
const state = sidebarResourceTabStateOf(address);
|
|
3572
|
+
if (state.readable !== true) continue;
|
|
3573
|
+
if (state.tabId !== null) continue;
|
|
3574
|
+
if (sidebarActiveResourceMatches(address) === true) continue;
|
|
3575
|
+
/**
|
|
3576
|
+
* 先写**预留**再发动作:任何重入(observer/轮询/尾随同步)都不会为同一个资源再选一次。
|
|
3577
|
+
* 预留只是候选去重,绝不等于「Auto 已经打开」——只有 openResource 真的成功发出后才会
|
|
3578
|
+
* 升级成来源标记;失败路径在 handleSidebarAction 的 finally 里撤销它,使后续同步可以重试。
|
|
3579
|
+
*/
|
|
3580
|
+
const epoch = sidebarAutoEpochRef.current;
|
|
3581
|
+
reservations.set(key, { sessionId: sidebarSessionId, rootSessionId, address, epoch });
|
|
3582
|
+
trimAutoKeyMap(reservations);
|
|
3583
|
+
return { key, address, kind: "open", epoch };
|
|
3584
|
+
}
|
|
3585
|
+
return null;
|
|
3586
|
+
};
|
|
3587
|
+
/** 上限淘汰:按插入顺序丢弃最旧的键(复合键跨 root 保留时的安全阀,见 SIDEBAR_AUTO_KEY_LIMIT)。 */
|
|
3588
|
+
const trimAutoKeyMap = (map) => {
|
|
3589
|
+
while (map.size > SIDEBAR_AUTO_KEY_LIMIT) {
|
|
3590
|
+
const oldest = map.keys().next();
|
|
3591
|
+
if (oldest.done === true) break;
|
|
3592
|
+
map.delete(oldest.value);
|
|
3593
|
+
}
|
|
3594
|
+
};
|
|
3595
|
+
/**
|
|
3596
|
+
* 清理已经离开当前快照的复合键(来源与抑制两个集合),避免长期泄漏。
|
|
3597
|
+
*
|
|
3598
|
+
* 只清理当前 surface 的键:别的 session/root 的资源不在这一份快照里,无法判定是否仍存在。
|
|
3599
|
+
* 本轮已经选中的候选键必须保留——它正要被关闭,删掉记账会让这次关闭失去来源身份。
|
|
3600
|
+
*/
|
|
3601
|
+
const cleanupAutoSidebarKeys = (known, actingKey) => {
|
|
3602
|
+
for (const map of [sidebarAutoOriginRef.current, sidebarAutoSuppressedRef.current]) {
|
|
3603
|
+
for (const [key, entry] of map) {
|
|
3604
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
|
|
3605
|
+
if (key === actingKey) continue;
|
|
3606
|
+
if (known.has(entry.address) === true) continue;
|
|
3607
|
+
map.delete(key);
|
|
3608
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
// 预留同样要清理,但**在途动作自己那一条绝不能被清理**:它的 finally 才是唯一的释放点,
|
|
3612
|
+
// 提前删掉会让同一条动作在收尾时把后来者的预留一起删掉。
|
|
3613
|
+
for (const [key, entry] of sidebarAutoReservationRef.current) {
|
|
3614
|
+
if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
|
|
3615
|
+
if (key === actingKey) continue;
|
|
3616
|
+
if (known.has(entry.address) === true) continue;
|
|
3617
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
3618
|
+
sidebarAutoReservationRef.current.delete(key);
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
/**
|
|
3622
|
+
* 放行一个自动动作。
|
|
3623
|
+
*
|
|
3624
|
+
* 复用用户点击的同一条入口(handleSidebarAction + origin=auto):同一把 pending 锁、同一段
|
|
3625
|
+
* split 事务、同一套「用真实 tabId 关闭」的记录语义,因此自动路径不存在第二套打开/关闭实现。
|
|
3626
|
+
* 动作收尾后才重新推进同步,下一个候选(或清理)由那条链路继续。
|
|
3627
|
+
*/
|
|
3628
|
+
const launchAutoSidebarAction = (candidate) => {
|
|
3629
|
+
sidebarAutoBusyRef.current = true;
|
|
3630
|
+
void Promise.resolve()
|
|
3631
|
+
// candidate 作为自动动作上下文(kind/epoch/预留)传入:动作必须在每个 await 之后
|
|
3632
|
+
// 复核它是否仍然有效,并在收尾时无条件释放预留(见 handleSidebarAction)。
|
|
3633
|
+
.then(() => handleSidebarAction(candidate.key, candidate.address, SIDEBAR_TAB_ORIGIN_AUTO, candidate))
|
|
3634
|
+
.catch(() => { /* 打开/关闭失败只影响这一次动作:状态由既有记录与重试记账负责。 */ })
|
|
3635
|
+
.then(() => {
|
|
3636
|
+
sidebarAutoBusyRef.current = false;
|
|
3637
|
+
/**
|
|
3638
|
+
* 尾随同步只用来推进「下一个候选」。它必须带着级联标记执行:这一轮里失败过的 key
|
|
3639
|
+
* 不会被重新选中,因此「失败 → 撤销预留 → 立刻重选」不在微任务里自转(见
|
|
3640
|
+
* sidebarAutoFailedKeysRef)。真正的重试留给下一个外部同步轮。
|
|
3641
|
+
*/
|
|
3642
|
+
sidebarAutoCascadeRef.current = true;
|
|
3643
|
+
try {
|
|
3644
|
+
// 用引用取最新一轮渲染的同步器(与 observer/轮询同一写法),绝不复用当前闭包。
|
|
3645
|
+
sidebarTabSyncRef.current?.();
|
|
3646
|
+
} finally {
|
|
3647
|
+
sidebarAutoCascadeRef.current = false;
|
|
3648
|
+
}
|
|
3649
|
+
});
|
|
3650
|
+
};
|
|
3651
|
+
/**
|
|
3652
|
+
* 第二阶段:Auto(自动打开运行中的子代理侧栏)同步。
|
|
3653
|
+
*
|
|
3654
|
+
* 顺序固定为「先关后开」,并且只在有属于当前 root 的真实快照时动作:
|
|
3655
|
+
* 1. 关闭:Auto 亲手打开过的资源一旦不在运行中集合里,就用**已确认记录里的真实 tabId** 走
|
|
3656
|
+
* 既有 handleSidebarAction 关闭路径(绝不猜 tabId、绝不调用批量关闭);
|
|
3657
|
+
* 2. 打开:运行中、尚未打开、且没有被用户手动关闭抑制的资源走既有打开路径;
|
|
3658
|
+
* 3. 清理:把已经离开当前快照的复合键从来源/抑制集合里移除。
|
|
3659
|
+
*
|
|
3660
|
+
* 开关关闭时不做任何自动 open/close(历史行为逐字节不变),已打开的 tab 交由用户手动处理;
|
|
3661
|
+
* 清理阶段与开关无关,它只维护插件自己的记账,不触碰宿主。
|
|
3662
|
+
*/
|
|
3663
|
+
const syncAutoSidebarTabs = () => {
|
|
3664
|
+
const source = snapshot;
|
|
3665
|
+
// 没有快照(首次加载 / 读取失败)时没有「谁在运行」的证据:既不打开也不关闭。
|
|
3666
|
+
if (source === null || source === undefined) return;
|
|
3667
|
+
// root 切换瞬间旧快照可能仍在 state 里:只有属于当前 root 的快照才能决定动作。
|
|
3668
|
+
if (text(source.rootSessionId, null) !== rootSessionId) return;
|
|
3669
|
+
const known = knownSidebarResourceAddresses();
|
|
3670
|
+
const running = runningSidebarResourceAddresses();
|
|
3671
|
+
const enabled = autoOpenEnabled();
|
|
3672
|
+
let candidate = null;
|
|
3673
|
+
if (enabled === true && sidebarAutoBusyRef.current !== true) {
|
|
3674
|
+
candidate = autoCloseCandidate(running) ?? autoOpenCandidate(running);
|
|
3675
|
+
}
|
|
3676
|
+
cleanupAutoSidebarKeys(known, candidate === null ? null : candidate.key);
|
|
3677
|
+
if (candidate === null) return;
|
|
3678
|
+
launchAutoSidebarAction(candidate);
|
|
3679
|
+
};
|
|
3680
|
+
|
|
3681
|
+
/**
|
|
3682
|
+
* 自动动作是否仍然有效:Auto 仍开启、动作代次未变、面板仍挂在同一个 root / sidebar session 上。
|
|
3683
|
+
*
|
|
3684
|
+
* 这是「关闭 Auto 取消在途打开」的判据:Auto getter 每次现读(权威读面),代次在开关变化时
|
|
3685
|
+
* 递增,surface 身份用与 handleSidebarAction 入口相同的三个 ref 复核。任一项不成立,那次自动
|
|
3686
|
+
* 动作一律放弃副作用。
|
|
3687
|
+
*/
|
|
3688
|
+
const autoActionStillValid = (context) => {
|
|
3689
|
+
if (context === null || context === undefined) return false;
|
|
3690
|
+
if (autoOpenEnabled() !== true) return false;
|
|
3691
|
+
if (sidebarAutoEpochRef.current !== context.epoch) return false;
|
|
3692
|
+
return mountedRef.current === true
|
|
3693
|
+
&& currentRootRef.current === rootSessionId
|
|
3694
|
+
&& currentSidebarSessionRef.current === sidebarSessionId;
|
|
3695
|
+
};
|
|
3696
|
+
/**
|
|
3697
|
+
* 撤销一次自动动作的在途记账(Auto 关闭 / 代次过期 / 手动接管 / 失败路径共用)。
|
|
3698
|
+
*
|
|
3699
|
+
* 三件事缺一不可:
|
|
3700
|
+
* 1. 收口这次动作开出的 split 事务:否则右栏会停在事务的临时 50%,直到 15 秒 deadline 才回落;
|
|
3701
|
+
* 2. 删掉这个 key 的在途打开记录:这次打开没有发出,不能让后续同步把它当成「还没确认的打开」;
|
|
3702
|
+
* 3. 释放预留:失败绝不等于「已经打开过」,下一次同步必须能重新选中这个资源。
|
|
3703
|
+
* 来源标记只在 openResource 成功发出时才存在,因此这里不删除它(关闭类动作被撤销后,
|
|
3704
|
+
* 那个 tab 仍然属于 Auto,开关重新打开后仍可被自动回收)。
|
|
3705
|
+
*/
|
|
3706
|
+
const abandonAutoSidebarAction = (key, context) => {
|
|
3707
|
+
if (sidebarSplitIntentRef.current?.key === key) cancelPendingSidebarSplit("auto-abandoned");
|
|
3708
|
+
sidebarTabOpeningRef.current.delete(key);
|
|
3709
|
+
sidebarAutoReservationRef.current.delete(key);
|
|
3710
|
+
releaseAutoCloseAttempt(key, context);
|
|
3711
|
+
};
|
|
3712
|
+
/** 回退候选选择时预支的那一次自动关闭重试(动作在真正调用 close 之前被撤销时调用)。 */
|
|
3713
|
+
const releaseAutoCloseAttempt = (key, context) => {
|
|
3714
|
+
if (context === null || context === undefined || context.kind !== "close") return;
|
|
3715
|
+
const attempts = sidebarAutoCloseAttemptsRef.current;
|
|
3716
|
+
if (attempts.has(key) !== true) return;
|
|
3717
|
+
if (context.attemptsBefore === 0) attempts.delete(key);
|
|
3718
|
+
else attempts.set(key, context.attemptsBefore);
|
|
3719
|
+
};
|
|
3720
|
+
/**
|
|
3721
|
+
* 自动动作在副作用之前必须让位的「手动接管」状态:
|
|
3722
|
+
* - "close":用户在同一个 key 上留下过关闭意图——Auto 绝不能在关闭意图之后再打开一个 tab;
|
|
3723
|
+
* - "open":用户手动要求打开这个资源——打开照常发出,但最终 tab 必须记成 manual 来源;
|
|
3724
|
+
* - null:没有手动接管。
|
|
3725
|
+
*/
|
|
3726
|
+
const autoTakeoverStateOf = (key) => {
|
|
3727
|
+
const intent = sidebarManualIntentOf(key);
|
|
3728
|
+
if (intent === undefined) return null;
|
|
3729
|
+
return intent.intent === "close" ? "close" : "open";
|
|
3730
|
+
};
|
|
3731
|
+
/**
|
|
3732
|
+
* 记录一次「在途动作期间」的用户手动意图。
|
|
3733
|
+
*
|
|
3734
|
+
* 触发点只有两个(用户点击落在一个已被 pending 锁或未落地打开占用的 key 上):意图方向按
|
|
3735
|
+
* 点击那一刻的按钮语义判定——已经有记录(<<)表示要关闭,没有记录(>>)表示要打开,
|
|
3736
|
+
* 绝不按动作在途时的中间态猜方向。方向判定与 handleSidebarAction 的分支条件同源。
|
|
3737
|
+
*/
|
|
3738
|
+
const recordSidebarManualIntent = (key, resourceAddress) => {
|
|
3739
|
+
const record = sidebarTabRecordsRef.current.get(key);
|
|
3740
|
+
const scoped = record !== undefined
|
|
3741
|
+
&& record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId;
|
|
3742
|
+
sidebarManualIntentRef.current.set(key, {
|
|
3743
|
+
sessionId: sidebarSessionId,
|
|
3744
|
+
rootSessionId,
|
|
3745
|
+
address: resourceAddress,
|
|
3746
|
+
intent: scoped ? "close" : "open",
|
|
3747
|
+
});
|
|
3748
|
+
};
|
|
3749
|
+
/**
|
|
3750
|
+
* 结算一个 key 上的手动意图(在途动作收尾后,以及同步的兜底扫描里调用)。
|
|
3751
|
+
*
|
|
3752
|
+
* 手动恒优先,但**只按当下的真实状态结算**,绝不盲目重放那次点击:
|
|
3753
|
+
* - 还有动作在途(pending)或打开尚未落地:这一轮什么都不做,等下一次收尾再结算;
|
|
3754
|
+
* - 「打开」:已经开着就把来源改回 manual(Auto 不再管它);还没开出才补发一次**手动**打开,
|
|
3755
|
+
* 走与点击完全相同的入口(唯一区别是 origin=manual,因此后续绝不会被自动关闭);
|
|
3756
|
+
* - 「关闭」:有精确 tabId 时走同一入口关闭并登记抑制;没有可关闭的 tab 时只登记抑制,
|
|
3757
|
+
* 绝不为了「执行这次关闭」而先打开一个 tab。
|
|
3758
|
+
*/
|
|
3759
|
+
const applyPendingManualIntent = (key) => {
|
|
3760
|
+
const entry = sidebarManualIntentOf(key);
|
|
3761
|
+
if (entry === undefined) return;
|
|
3762
|
+
// 同一 key 仍有动作在途:它收尾时会再结算一次,绝不在这里并发派发第二个动作。
|
|
3763
|
+
if (sidebarTabPendingRef.current.has(key) === true) return;
|
|
3764
|
+
const opening = sidebarTabOpeningRef.current.get(key);
|
|
3765
|
+
const openingScoped = opening !== undefined
|
|
3766
|
+
&& opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId;
|
|
3767
|
+
const record = sidebarTabRecordsRef.current.get(key);
|
|
3768
|
+
const recordScoped = record !== undefined
|
|
3769
|
+
&& record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId;
|
|
3770
|
+
if (entry.intent === "open") {
|
|
3771
|
+
if (openingScoped) {
|
|
3772
|
+
// 这次打开仍在途:把它的来源改成 manual,落地后就是手动 tab(绝不并发第二次 openResource)。
|
|
3773
|
+
opening.origin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3774
|
+
sidebarManualIntentRef.current.delete(key);
|
|
3775
|
+
return;
|
|
3776
|
+
}
|
|
3777
|
+
if (recordScoped) {
|
|
3778
|
+
// 用户手动打开 = 他重新要这个会话:清除抑制,并把来源收回 manual(Auto 不得再自动关它)。
|
|
3779
|
+
record.origin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3780
|
+
sidebarAutoSuppressedRef.current.delete(key);
|
|
3781
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
3782
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
3783
|
+
sidebarManualIntentRef.current.delete(key);
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
sidebarManualIntentRef.current.delete(key);
|
|
3787
|
+
void handleSidebarAction(key, entry.address, SIDEBAR_TAB_ORIGIN_MANUAL);
|
|
3788
|
+
return;
|
|
3789
|
+
}
|
|
3790
|
+
// intent === "close"
|
|
3791
|
+
if (recordScoped) {
|
|
3792
|
+
sidebarManualIntentRef.current.delete(key);
|
|
3793
|
+
void handleSidebarAction(key, entry.address, SIDEBAR_TAB_ORIGIN_MANUAL);
|
|
3794
|
+
return;
|
|
3795
|
+
}
|
|
3796
|
+
if (openingScoped) {
|
|
3797
|
+
/**
|
|
3798
|
+
* 打开仍在途、此刻没有可精确关闭的 tabId:抑制先登记(用户要的是关闭,Auto 不得重开),
|
|
3799
|
+
* 意图保留到它落地或超时——由同一条同步链路再结算一次,绝不在没有 tabId 时猜一个 id 去关。
|
|
3800
|
+
*/
|
|
3801
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
|
|
3802
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
3803
|
+
return;
|
|
3804
|
+
}
|
|
3805
|
+
// 没有可关闭的 tab:只登记抑制与撤销自动记账,绝不为了「执行关闭」先打开一个 tab。
|
|
3806
|
+
sidebarManualIntentRef.current.delete(key);
|
|
3807
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
|
|
3808
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
3809
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
3810
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
3811
|
+
};
|
|
3812
|
+
|
|
3813
|
+
/**
|
|
3814
|
+
* 右栏 tab 的完整同步入口(挂载 effect / 2.5 秒轮询 / sessions 通知 / observer / 可见性变化
|
|
3815
|
+
* 全部只经由它):先跑既有的记录/库存同步,再跑 Auto 自动同步。
|
|
3816
|
+
*
|
|
3817
|
+
* 顺序不可颠倒:自动打开必须建立在「库存与在途记录已经结算完」的基础上,否则同一个资源
|
|
3818
|
+
* 会被先记录再打开,重复消耗 pane。
|
|
3819
|
+
*/
|
|
3820
|
+
const syncSidebarTabs = () => {
|
|
3821
|
+
/**
|
|
3822
|
+
* 外部同步轮(挂载 / 2.5 秒轮询 / observer / 可见性变化 / 会话通知)都是新的一轮:自动动作的
|
|
3823
|
+
* 失败屏障在这里失效,因此同一个资源仍然可以被重试。自动动作收尾的那条尾随同步例外——它
|
|
3824
|
+
* 只是同一批微任务里的推进,不构成「宿主状态可能已经改变」的新证据。
|
|
3825
|
+
*/
|
|
3826
|
+
if (sidebarAutoCascadeRef.current !== true) sidebarAutoFailedKeysRef.current.clear();
|
|
3827
|
+
syncSidebarTabRecords();
|
|
3828
|
+
// Auto 开关是渲染输入:设置页保存或面板自身切换后必须重渲染一次(标题栏开关的勾选态)。
|
|
3829
|
+
// 与批量签名同一模式:只有真的变化才 bump,空闲轮询绝不产生重渲染。
|
|
3830
|
+
const autoNow = autoOpenEnabled();
|
|
3831
|
+
if (sidebarAutoSignatureRef.current !== autoNow) {
|
|
3832
|
+
sidebarAutoSignatureRef.current = autoNow;
|
|
3833
|
+
/**
|
|
3834
|
+
* 开关的权威值一变(含关闭)就推进自动动作代次:已经发出、仍在等待 split/layout 的在途
|
|
3835
|
+
* 自动动作会在下一次复核时放弃——「关闭 Auto」必须立刻取消还没落地的自动打开,而不是
|
|
3836
|
+
* 等它把 tab 打开之后再交给用户手动关。已存在的 tab 不受影响(关闭 Auto 绝不自动关 tab)。
|
|
3837
|
+
*/
|
|
3838
|
+
sidebarAutoEpochRef.current += 1;
|
|
3839
|
+
// 权威值变化后丢弃本地乐观值,开关始终收敛到读出的事实。
|
|
3840
|
+
setAutoOpenDraft(null);
|
|
3841
|
+
bump((value) => value + 1);
|
|
3842
|
+
}
|
|
3843
|
+
syncAutoSidebarTabs();
|
|
3844
|
+
};
|
|
3230
3845
|
// 挂载 effect 与轮询/observer 回调都只经由这个引用取最新闭包(见 sidebarTabSyncRef 的说明)。
|
|
3231
3846
|
sidebarTabSyncRef.current = syncSidebarTabs;
|
|
3232
3847
|
|
|
@@ -3238,12 +3853,19 @@
|
|
|
3238
3853
|
* - `kind === "subagentchat"`:active 可能仍是用户原本在看的别的 tab;
|
|
3239
3854
|
* - `contentId === 资源地址`:同一个 kind 下也可能是另一个子代理的地址。
|
|
3240
3855
|
* 不满足时保持 >> 的打开语义(按钮照旧可再次点击打开),绝不猜测 tabId。
|
|
3856
|
+
*
|
|
3857
|
+
* origin 的来源优先级:显式传入的动作来源 > 在途打开记录里记下的来源 > 已存在记录的来源 >
|
|
3858
|
+
* **manual**。最后那条兜底正是规格要求:「外部已存在但未由自动动作发起的 tab 视为手动来源」,
|
|
3859
|
+
* 因此自动同步永远关不掉一个它没有亲手打开过的 tab。
|
|
3241
3860
|
*/
|
|
3242
|
-
const recordSidebarTab = (key, resourceAddress) => {
|
|
3861
|
+
const recordSidebarTab = (key, resourceAddress, actionOrigin) => {
|
|
3243
3862
|
if (closeSidebarResource === null) return false;
|
|
3244
3863
|
if (readActiveSidebarResource === null && readSidebarOpenTabs() === null) return false;
|
|
3245
3864
|
// 左箭头图标缺失时不会渲染 <<,因此也不该建立只在 << 上才有意义的记录。
|
|
3246
3865
|
if (typeof primitives?.IconChevronLeftOutlineRegular !== "function") return false;
|
|
3866
|
+
const declaredOrigin = actionOrigin === SIDEBAR_TAB_ORIGIN_AUTO || actionOrigin === SIDEBAR_TAB_ORIGIN_MANUAL
|
|
3867
|
+
? actionOrigin
|
|
3868
|
+
: null;
|
|
3247
3869
|
let tab = null;
|
|
3248
3870
|
const inventory = readSidebarOpenTabs();
|
|
3249
3871
|
if (inventory !== null) {
|
|
@@ -3275,13 +3897,32 @@
|
|
|
3275
3897
|
if (text(source.kind, null) !== SUBAGENT_CHAT_KIND) return false;
|
|
3276
3898
|
if (text(source.contentId, null) !== resourceAddress) return false;
|
|
3277
3899
|
const previous = sidebarTabRecordsRef.current.get(key);
|
|
3900
|
+
const opening = sidebarTabOpeningRef.current.get(key);
|
|
3901
|
+
/**
|
|
3902
|
+
* 手动接管优先于调用方声明的来源:用户在那个 key 上留下过「要打开」的手动意图时,即使这次
|
|
3903
|
+
* 打开是 Auto 发出的,最终 tab 也必须记成 manual——手动打开的最终来源必须是 manual,
|
|
3904
|
+
* Auto 绝不能在资源结束后把它当成自己的 tab 关掉。
|
|
3905
|
+
*/
|
|
3906
|
+
const manualTakeover = sidebarManualIntentOf(key);
|
|
3907
|
+
const origin = manualTakeover !== undefined && manualTakeover.intent === "open"
|
|
3908
|
+
? SIDEBAR_TAB_ORIGIN_MANUAL
|
|
3909
|
+
: (declaredOrigin
|
|
3910
|
+
?? (previous !== undefined && previous.sessionId === sidebarSessionId && previous.rootSessionId === rootSessionId
|
|
3911
|
+
? previous.origin
|
|
3912
|
+
: null)
|
|
3913
|
+
?? (opening !== undefined && opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId
|
|
3914
|
+
? opening.origin
|
|
3915
|
+
: null)
|
|
3916
|
+
?? SIDEBAR_TAB_ORIGIN_MANUAL);
|
|
3917
|
+
const recordOrigin = origin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3278
3918
|
if (previous !== undefined && previous.sessionId === sidebarSessionId
|
|
3279
3919
|
&& previous.rootSessionId === rootSessionId && previous.tabId === tabId) {
|
|
3280
3920
|
previous.title = tabTitle ?? previous.title;
|
|
3281
3921
|
previous.misses = 0;
|
|
3922
|
+
previous.origin = recordOrigin;
|
|
3282
3923
|
return true;
|
|
3283
3924
|
}
|
|
3284
|
-
sidebarTabRecordsRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, tabId, title: tabTitle, misses: 0 });
|
|
3925
|
+
sidebarTabRecordsRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, tabId, title: tabTitle, misses: 0, origin: recordOrigin });
|
|
3285
3926
|
bump((value) => value + 1);
|
|
3286
3927
|
return true;
|
|
3287
3928
|
};
|
|
@@ -3293,18 +3934,68 @@
|
|
|
3293
3934
|
* 宿主的提交,重入点击若穿过检查就会发出第二次 openResource(第二个重复 tab)或提前走关闭。
|
|
3294
3935
|
* 抛错处理:openResource / close 抛错只影响这一次动作,绝不写状态——打开失败保持 >>,
|
|
3295
3936
|
* 关闭失败保持 <<(用户可以再点一次),两种情况都不会把按钮和真实世界对不上。
|
|
3937
|
+
*
|
|
3938
|
+
* origin 是**内部**参数,默认 manual:普通 UI 调用(箭头、键盘)一律手动来源;只有自动同步
|
|
3939
|
+
* 显式传 auto。它决定三件事:记录来源、手动动作是否清除抑制/自动来源、关闭时是否登记抑制。
|
|
3940
|
+
*
|
|
3941
|
+
* autoAction 也是**内部**参数,只在自动路径上出现:{ kind, epoch, attemptsBefore }。它让同一条
|
|
3942
|
+
* 实现能在每个 await 之后、真正调用宿主 open/close 之前复核「Auto 是否仍然开启、动作是否已过期、
|
|
3943
|
+
* 用户是否已经手动接管」,并在收尾时无条件释放在途预留(见 finally)。
|
|
3296
3944
|
*/
|
|
3297
|
-
const handleSidebarAction = async (key, resourceAddress) => {
|
|
3945
|
+
const handleSidebarAction = async (key, resourceAddress, actionOrigin, autoAction) => {
|
|
3946
|
+
const origin = actionOrigin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3947
|
+
const autoContext = origin === SIDEBAR_TAB_ORIGIN_AUTO && autoAction !== null && typeof autoAction === "object"
|
|
3948
|
+
? {
|
|
3949
|
+
kind: autoAction.kind === "close" ? "close" : "open",
|
|
3950
|
+
epoch: Number.isFinite(Number(autoAction.epoch)) ? Number(autoAction.epoch) : sidebarAutoEpochRef.current,
|
|
3951
|
+
attemptsBefore: Number.isFinite(Number(autoAction.attemptsBefore)) ? Number(autoAction.attemptsBefore) : 0,
|
|
3952
|
+
}
|
|
3953
|
+
: null;
|
|
3298
3954
|
// React 重挂载或 root 切换后,旧 DOM 可能短暂保留旧 handler;它不能操作当前 session-scoped 服务。
|
|
3299
3955
|
if (mountedRef.current !== true
|
|
3300
3956
|
|| currentRootRef.current !== rootSessionId
|
|
3301
|
-
|| currentSidebarSessionRef.current !== sidebarSessionId)
|
|
3957
|
+
|| currentSidebarSessionRef.current !== sidebarSessionId) {
|
|
3958
|
+
/**
|
|
3959
|
+
* 连入口都没通过的自动动作:这里必须自己释放候选选择时写下的预留(以及自动关闭的重试记账)。
|
|
3960
|
+
* 这些键属于已经被换掉的 surface,当前闭包之后再也不会为它们做清理。
|
|
3961
|
+
*/
|
|
3962
|
+
if (autoContext !== null) {
|
|
3963
|
+
sidebarAutoReservationRef.current.delete(key);
|
|
3964
|
+
releaseAutoCloseAttempt(key, autoContext);
|
|
3965
|
+
}
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3302
3968
|
const pending = sidebarTabPendingRef.current;
|
|
3303
|
-
if (pending.has(key))
|
|
3969
|
+
if (pending.has(key)) {
|
|
3970
|
+
/**
|
|
3971
|
+
* 同一个 key 上已经有动作在途(典型:Auto 正在等 split 返回 pane / 等布局提交 / 等打开落地)。
|
|
3972
|
+
* 用户此刻的手动点击绝不能因为「锁被占用」而消失:它被记成手动意图,由在途动作在副作用之前
|
|
3973
|
+
* 让位、或由本函数收尾后立即结算(见 applyPendingManualIntent)。手动意图只在手动路径上记录。
|
|
3974
|
+
*/
|
|
3975
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) recordSidebarManualIntent(key, resourceAddress);
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3304
3978
|
pending.add(key);
|
|
3979
|
+
/** 本次动作是否真的把 openResource 发给了宿主:只有它才允许建立 auto 来源标记。 */
|
|
3980
|
+
let openIssued = false;
|
|
3305
3981
|
try {
|
|
3982
|
+
/**
|
|
3983
|
+
* 副作用之前的第一道复核(自动路径):Auto 开关的权威值与动作代次都可能在等待期间变化。
|
|
3984
|
+
* 关闭 Auto 时必须在这里放弃——包括「已经 split 出 pane」的前一阶段遗留在途记录的情况。
|
|
3985
|
+
*/
|
|
3986
|
+
if (autoContext !== null && autoActionStillValid(autoContext) !== true) {
|
|
3987
|
+
abandonAutoSidebarAction(key, autoContext);
|
|
3988
|
+
return;
|
|
3989
|
+
}
|
|
3306
3990
|
const record = sidebarTabRecordsRef.current.get(key);
|
|
3307
|
-
|
|
3991
|
+
if (record === undefined && sidebarTabOpeningRef.current.has(key)) {
|
|
3992
|
+
/**
|
|
3993
|
+
* 打开已经发出、落地证据还没到:手动点击同样不能丢。按意图记账,等它落地(或超时)后
|
|
3994
|
+
* 由同步链路结算;在这之前绝不补发第二次 openResource,也绝不猜 tabId 去关闭。
|
|
3995
|
+
*/
|
|
3996
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) recordSidebarManualIntent(key, resourceAddress);
|
|
3997
|
+
return;
|
|
3998
|
+
}
|
|
3308
3999
|
// 关闭态:只调用记录里的精确 tabId。绝不根据 active() 当前是谁来决定关哪个 tab。
|
|
3309
4000
|
if (record !== undefined && closeSidebarResource !== null) {
|
|
3310
4001
|
let closed = false;
|
|
@@ -3323,6 +4014,19 @@
|
|
|
3323
4014
|
*/
|
|
3324
4015
|
if (sidebarSplitIntentRef.current?.key === key) cancelPendingSidebarSplit("close");
|
|
3325
4016
|
sidebarTabRecordsRef.current.delete(key);
|
|
4017
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
4018
|
+
/**
|
|
4019
|
+
* 关闭成功后收口来源与抑制记账(两种开放的语义差别就在这两行):
|
|
4020
|
+
*
|
|
4021
|
+
* - 自动关闭:只撤销来源标记——本次运行不会再对同一个资源重复动作;
|
|
4022
|
+
* - 手动关闭:除撤销来源外还要登记**抑制**,Auto 不得立刻把它重新打开。抑制只由
|
|
4023
|
+
* 用户手动重新打开同一资源清除(手动意图优先)。
|
|
4024
|
+
*/
|
|
4025
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
4026
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
|
|
4027
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
4028
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
4029
|
+
}
|
|
3326
4030
|
bump((value) => value + 1);
|
|
3327
4031
|
// 关闭改变了右栏组成,必须立刻重估一次:宽度与手柄锁都在同一条同步链路上。
|
|
3328
4032
|
syncSidebarTabs();
|
|
@@ -3330,6 +4034,25 @@
|
|
|
3330
4034
|
return;
|
|
3331
4035
|
}
|
|
3332
4036
|
if (openSidebarResource === null) return;
|
|
4037
|
+
/**
|
|
4038
|
+
* 自动动作的副作用复核点:真实的用户可见副作用(split / openResource)之前必须重新确认
|
|
4039
|
+
* 「Auto 仍开启」「动作代次没过期」「用户没有在同一个 key 上手动接管」。返回值:
|
|
4040
|
+
* - "proceed":照常执行;
|
|
4041
|
+
* - "abort":Auto 已关闭/动作已过期,或用户要求关闭 → 撤销在途记账,绝不发出副作用;
|
|
4042
|
+
* - "manual":用户手动要求打开 → 动作可以继续,但最终 tab 必须记成 manual 来源。
|
|
4043
|
+
* 手动路径(autoContext === null)永远是 "proceed",历史行为逐字不变。
|
|
4044
|
+
*/
|
|
4045
|
+
const reviewAutoSideEffect = () => {
|
|
4046
|
+
if (autoContext === null) return "proceed";
|
|
4047
|
+
if (autoContext === null) return "proceed";
|
|
4048
|
+
if (autoActionStillValid(autoContext) !== true) return "abort";
|
|
4049
|
+
const takeover = autoTakeoverStateOf(key);
|
|
4050
|
+
if (takeover === "close") return "abort";
|
|
4051
|
+
if (takeover === "open") return "manual";
|
|
4052
|
+
return "proceed";
|
|
4053
|
+
};
|
|
4054
|
+
/** 本次打开最终要记的来源:自动动作只在没有手动接管时才是 auto。 */
|
|
4055
|
+
let effectiveOrigin = origin;
|
|
3333
4056
|
const canTrackOpen = closeSidebarResource !== null
|
|
3334
4057
|
&& (readActiveSidebarResource !== null || sidebarOpenTabsAvailable === true)
|
|
3335
4058
|
&& typeof primitives?.IconChevronLeftOutlineRegular === "function";
|
|
@@ -3341,8 +4064,21 @@
|
|
|
3341
4064
|
rootSessionId,
|
|
3342
4065
|
resourceAddress,
|
|
3343
4066
|
expiresAt: Date.now() + SIDEBAR_TAB_OPEN_CONFIRMATION_TIMEOUT_MS,
|
|
4067
|
+
origin,
|
|
3344
4068
|
});
|
|
3345
4069
|
}
|
|
4070
|
+
/**
|
|
4071
|
+
* 手动打开的意图优先于自动记账:
|
|
4072
|
+
*
|
|
4073
|
+
* - 清除抑制:用户手动重新打开 = 他重新要这个会话,Auto 之后可以继续管理它;
|
|
4074
|
+
* - 撤销自动来源:这次打开是手动来源,Auto 绝不能在资源结束时把它当成自己的 tab 关掉。
|
|
4075
|
+
* 两条都在发动作之前写,因此这一段没有 await,不存在与自动同步交叉的窗口。
|
|
4076
|
+
*/
|
|
4077
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
|
|
4078
|
+
sidebarAutoSuppressedRef.current.delete(key);
|
|
4079
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
4080
|
+
sidebarAutoCloseAttemptsRef.current.delete(key);
|
|
4081
|
+
}
|
|
3346
4082
|
const targetPaneId = existingSubagentPaneId();
|
|
3347
4083
|
let openOptions = null;
|
|
3348
4084
|
let splitToken = null;
|
|
@@ -3400,6 +4136,21 @@
|
|
|
3400
4136
|
sidebarTabOpeningRef.current.delete(key);
|
|
3401
4137
|
return;
|
|
3402
4138
|
}
|
|
4139
|
+
/**
|
|
4140
|
+
* 布局等待是这条路径上唯一会跨越用户操作窗口的 await:用户可能在这里关闭了 Auto,
|
|
4141
|
+
* 也可能在同一个 key 上手动接管。此时连 split() 都还没调用,直接放弃是代价最小的时机。
|
|
4142
|
+
*/
|
|
4143
|
+
const afterLayoutReview = reviewAutoSideEffect();
|
|
4144
|
+
if (afterLayoutReview === "abort") {
|
|
4145
|
+
abandonAutoSidebarAction(key, autoContext);
|
|
4146
|
+
if (autoTakeoverStateOf(key) === "close") {
|
|
4147
|
+
// 用户要的是关闭:立刻登记抑制,Auto 不得在下一轮同步里把它重新选成候选。
|
|
4148
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
4149
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
4150
|
+
}
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
if (afterLayoutReview === "manual") effectiveOrigin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3403
4154
|
}
|
|
3404
4155
|
let paneId = null;
|
|
3405
4156
|
try { paneId = splitSidebarPane.call(sidebarRight); } catch { paneId = null; }
|
|
@@ -3431,7 +4182,33 @@
|
|
|
3431
4182
|
// 旧宿主没有公开 split():保留历史 preferNewPane 请求,兼容旧版本。
|
|
3432
4183
|
openOptions = { kind: SUBAGENT_CHAT_KIND, preferNewPane: true };
|
|
3433
4184
|
}
|
|
4185
|
+
/**
|
|
4186
|
+
* 真正发出副作用之前的最后一道复核:从「读记录」到这里可能已经跨过 split 前的布局等待,
|
|
4187
|
+
* Auto 开关与用户手动意图都可能变化。只有通过复核才允许调用宿主 openResource()——绝不把
|
|
4188
|
+
* 一个已经关掉的 Auto 变成一次迟到的自动打开。
|
|
4189
|
+
*/
|
|
4190
|
+
const beforeOpenReview = reviewAutoSideEffect();
|
|
4191
|
+
if (beforeOpenReview === "abort") {
|
|
4192
|
+
abandonAutoSidebarAction(key, autoContext);
|
|
4193
|
+
if (autoTakeoverStateOf(key) === "close") {
|
|
4194
|
+
// 用户要的是关闭:立刻登记抑制,绝不在同一轮或下一轮同步里把这个资源重新打开。
|
|
4195
|
+
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
4196
|
+
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
4197
|
+
}
|
|
4198
|
+
return;
|
|
4199
|
+
}
|
|
4200
|
+
if (beforeOpenReview === "manual") effectiveOrigin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
3434
4201
|
openSidebarResource.call(sidebarRight, resourceAddress, openOptions);
|
|
4202
|
+
openIssued = true;
|
|
4203
|
+
/**
|
|
4204
|
+
* 预留 → 来源的升级点:只有 openResource 真的成功发出、且宿主具备可追踪能力(能读到 tabId、
|
|
4205
|
+
* 能精确关闭)时,才把这次打开记成「Auto 亲手打开」。openResource 抛错时直接走 catch,
|
|
4206
|
+
* 来源标记绝不写;能力不足时也不写——将来无法精确回收的 tab 不归 Auto 管。
|
|
4207
|
+
*/
|
|
4208
|
+
if (effectiveOrigin === SIDEBAR_TAB_ORIGIN_AUTO && canTrackOpen === true) {
|
|
4209
|
+
sidebarAutoOriginRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
4210
|
+
trimAutoKeyMap(sidebarAutoOriginRef.current);
|
|
4211
|
+
}
|
|
3435
4212
|
if (splitToken !== null) {
|
|
3436
4213
|
let acknowledged = true;
|
|
3437
4214
|
if (markSidebarSplitOpen !== null) {
|
|
@@ -3444,7 +4221,7 @@
|
|
|
3444
4221
|
}
|
|
3445
4222
|
}
|
|
3446
4223
|
// 记录必须发生在 openResource 之后:它读的是宿主提交后的 active tab,是打开的证据。
|
|
3447
|
-
const opened = recordSidebarTab(key, resourceAddress);
|
|
4224
|
+
const opened = recordSidebarTab(key, resourceAddress, effectiveOrigin);
|
|
3448
4225
|
// 只要这次打开的 tab 已经落定,就不能留下 opening 记录:它的语义是「这次打开还没有确定
|
|
3449
4226
|
// 证据」(split 事务仍在等待 pane 证据、或本来就没有事务时都一样)。残留的 opening 会让
|
|
3450
4227
|
// 用户收回这个 tab 之后的下一次同步把关闭记录重新建回来(<< 复活),并把之后的点击挡成
|
|
@@ -3458,11 +4235,33 @@
|
|
|
3458
4235
|
sidebarTabOpeningRef.current.delete(key);
|
|
3459
4236
|
/* 宿主暂时打不开/关不掉这个地址:只影响这一次侧栏动作,状态绝不被污染。 */
|
|
3460
4237
|
}
|
|
3461
|
-
finally {
|
|
4238
|
+
finally {
|
|
4239
|
+
pending.delete(key);
|
|
4240
|
+
/**
|
|
4241
|
+
* 自动动作收尾时无条件释放在途记账(split busy、准备失败、layout 失效、open 抛错、
|
|
4242
|
+
* surface 变化、被撤销都走这里):
|
|
4243
|
+
* - 预留必须释放,否则一次失败会被永久记成「已经打开过」,该资源在本次运行里再也打不开;
|
|
4244
|
+
* - 打开没有真正发出时,这个 key 不存在「Auto 亲手打开」的事实,残留来源标记一并撤销。
|
|
4245
|
+
*/
|
|
4246
|
+
if (autoContext !== null) {
|
|
4247
|
+
sidebarAutoReservationRef.current.delete(key);
|
|
4248
|
+
if (autoContext.kind === "open" && openIssued !== true) {
|
|
4249
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
4250
|
+
// 本次打开没有发出:把 key 记进失败屏障,同一批微任务里绝不再重选它(见其说明)。
|
|
4251
|
+
sidebarAutoFailedKeysRef.current.add(key);
|
|
4252
|
+
}
|
|
4253
|
+
if (openIssued === true) sidebarAutoFailedKeysRef.current.delete(key);
|
|
4254
|
+
}
|
|
4255
|
+
/**
|
|
4256
|
+
* 在途动作收尾后立刻结算用户在这个窗口里表达的手动意图:手动恒优先,绝不因为「那次点击撞上
|
|
4257
|
+
* 了 pending 锁」而丢失。没有意图时这一步是幂等空操作。
|
|
4258
|
+
*/
|
|
4259
|
+
applyPendingManualIntent(key);
|
|
4260
|
+
}
|
|
3462
4261
|
};
|
|
3463
4262
|
|
|
3464
4263
|
/**
|
|
3465
|
-
* 批量关闭的唯一入口(主代理行 = 当前 root
|
|
4264
|
+
* 批量关闭的唯一入口(主代理行 = 当前 root 的运行中专家/分片;空闲分组标题行 = ready 专家/已结算 fan-out 的空闲分片)。
|
|
3466
4265
|
*
|
|
3467
4266
|
* 与单会话箭头共用的三条并发与生命周期边界:
|
|
3468
4267
|
* 1) 聚合锁覆盖整段 await 序列:双击/连击只允许第一次穿过,pending 期间按钮 disabled + aria-busy;
|
|
@@ -3764,7 +4563,7 @@
|
|
|
3764
4563
|
const idleTitleChildren = [
|
|
3765
4564
|
react.createElement("div", { className: "deg_expert_group_line deg_expert_group_task" }, "空闲子代理(" + members.length + ")"),
|
|
3766
4565
|
];
|
|
3767
|
-
// 空闲分组的批量候选只来自当前 idleChildren
|
|
4566
|
+
// 空闲分组的批量候选只来自当前 idleChildren;运行中的专家或分片会被排除。
|
|
3768
4567
|
// slot 与计数标题同属第三列的 inline flex,标题按可用宽度省略,slot 不会被推到行尾。
|
|
3769
4568
|
if (trailing !== undefined && trailing !== null) idleTitleChildren.push(trailing);
|
|
3770
4569
|
return react.createElement("div", { className: "deg_expert_group_idle_group", "data-expanded": open },
|
|
@@ -3772,7 +4571,7 @@
|
|
|
3772
4571
|
statusIcon(react, primitives, "ready"),
|
|
3773
4572
|
renderChevron(group, open),
|
|
3774
4573
|
react.createElement("div", { className: "deg_expert_group_idle_title_line" }, ...idleTitleChildren)),
|
|
3775
|
-
open ? react.createElement("div", { className: "deg_expert_group_children deg_expert_group_idle_children" }, members.map((node, index) => renderExpert(node, index, rootSessionId))) : null);
|
|
4574
|
+
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);
|
|
3776
4575
|
};
|
|
3777
4576
|
|
|
3778
4577
|
const pendingSet = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
|
|
@@ -3897,9 +4696,8 @@
|
|
|
3897
4696
|
};
|
|
3898
4697
|
|
|
3899
4698
|
const children = Array.isArray(snapshot?.root?.children) ? snapshot.root.children : [];
|
|
3900
|
-
//
|
|
3901
|
-
//
|
|
3902
|
-
// fan-out 仍只在自己的取消/重试逻辑中读取 statusOf(task)。
|
|
4699
|
+
// 普通专家按实际运行状态分组,fan-out 父任务则按 manager 的聚合终态进入空闲组。
|
|
4700
|
+
// fan-out 展示与取消/重试仍使用各自的 manager 状态,不受分组位置影响。
|
|
3903
4701
|
const idleChildren = sidebarIdleChildren();
|
|
3904
4702
|
const visibleChildren = children.filter((node) => !idleChildren.includes(node));
|
|
3905
4703
|
// 两行标题后的批量 slot 内容:主代理行只覆盖运行中的普通专家/最新分片,空闲标题行只覆盖
|
|
@@ -3920,9 +4718,32 @@
|
|
|
3920
4718
|
...visibleChildren.map((node, index) => node.kind === "fanout" ? renderFanout(node, index) : renderExpert(node, index, rootSessionId)),
|
|
3921
4719
|
renderIdleGroup(idleChildren, idleBatchSlot),
|
|
3922
4720
|
]) : null));
|
|
4721
|
+
/**
|
|
4722
|
+
* 标题栏「Auto」开关(紧凑、与标题同一行,不遮挡标题与行内批量控件):
|
|
4723
|
+
*
|
|
4724
|
+
* - 勾选态:权威读面(install 注入的 getter,每次渲染现读)叠加本地乐观值;
|
|
4725
|
+
* - 可访问性:input 自带 aria-label="Auto",可见文字也是 Auto,说明性文案只放在 title 里,
|
|
4726
|
+
* 不新增任何长段落;
|
|
4727
|
+
* - 写面缺失(直接 createExpertGroupPanel 的旧宿主/单测)时 disabled:绝不渲染一个点了没反应的开关。
|
|
4728
|
+
*/
|
|
4729
|
+
const autoOpenChecked = autoOpenDraft === null ? autoOpenEnabled() : autoOpenDraft === true;
|
|
4730
|
+
const autoOpenToggle = react.createElement("label", {
|
|
4731
|
+
className: "deg_expert_group_header_auto",
|
|
4732
|
+
title: "自动打开运行中的子代理侧栏会话",
|
|
4733
|
+
},
|
|
4734
|
+
react.createElement("input", {
|
|
4735
|
+
type: "checkbox",
|
|
4736
|
+
className: "deg_expert_group_header_auto_input",
|
|
4737
|
+
"aria-label": "Auto",
|
|
4738
|
+
checked: autoOpenChecked,
|
|
4739
|
+
disabled: writeAutoOpenSetting === null,
|
|
4740
|
+
onChange: (event) => toggleAutoOpen(event?.target?.checked === true),
|
|
4741
|
+
}),
|
|
4742
|
+
react.createElement("span", { className: "deg_expert_group_header_auto_label" }, "Auto"));
|
|
3923
4743
|
return react.createElement("section", { className: "deg_expert_group", "data-version": version, "aria-label": "Expert Group" },
|
|
3924
4744
|
react.createElement("header", { className: "deg_expert_group_header" },
|
|
3925
4745
|
react.createElement("span", { className: "deg_expert_group_header_title" }, "Expert Group"),
|
|
4746
|
+
autoOpenToggle,
|
|
3926
4747
|
viewState.error !== null ? react.createElement("span", { className: "deg_expert_group_error", title: viewState.error }, "快照读取失败") : null,
|
|
3927
4748
|
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);
|
|
3928
4749
|
}
|