dsh-expert-group 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,34 @@
26
26
  */
27
27
  const RIGHTBAR_WIDTH_RATIO = 0.2;
28
28
  const OTHER_RIGHTBAR_WIDTH_RATIO = 0.5;
29
+ /**
30
+ * 「宿主里那份右栏宽度是否仍是插件写的」的匹配容差(px)。
31
+ *
32
+ * 面板的实际渲染宽度会被浏览器做子像素取整与布局舍入,不可能与插件写入的整数 target 逐字节
33
+ * 相等;1px 足以覆盖这类规范化,又远小于任何一次真实用户拖拽(拖拽是数十像素量级),因此
34
+ * 不会把「用户已经改过宽度」误判成「还是插件那份值」。
35
+ */
36
+ const RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX = 1;
37
+ /**
38
+ * 同一个「未收敛的宽度 mismatch」允许的**恢复写入**上限(次)。
39
+ *
40
+ * 背景:宿主可能持续 clamp(请求 640,只渲染 320),或 React 延迟若干帧才把新宽度提交到 DOM。
41
+ * 这两种情况下「面板实际渲染宽度 != 插件 target」会一直是真,而它同时又是「宿主夺回了那份 px」
42
+ * 的证据;若每条同步路径(面板 observer / 2.5 秒轮询 / sessions 通知)都据此 force 重写,
43
+ * 就会与宿主的 clamp 无限拉锯:layout store 被同一个值反复改写、宿主反复重渲染(用户看到抖动)。
44
+ *
45
+ * 因此用一份 per-session 状态跟踪同一个恢复事件(session、**当前视口下预期**的 target、
46
+ * 观测到的实际宽度),同一个未收敛 mismatch 最多执行 2 次恢复写入:一次立即恢复 + 一次重试,
47
+ * 足以覆盖「宿主延迟一帧才提交」这类真实时序;再多就只是拉锯。上限**不能**被正常轮询自行重置——
48
+ * 只有观测到实际宽度已经等于 target(收敛)、**预期 target 真的改变**、所有权周期变化或 dispose
49
+ * 时才清除状态。
50
+ *
51
+ * 事件同一性里的 target 必须是**按当前视口算出来的预期值**,而不是「上次成功写入的值」:宿主持续
52
+ * clamp 时,视口从 3200 挪到 3201 / 3202,20% 取整后仍是同一个 640,那份 px 依然代表 20%——
53
+ * 这不是新的一次 mismatch,绝不能因此重新获得预算;只有窗口真正最大化(3200 → 4000,预期
54
+ * 640 → 800)才是一次新事件。否则用户轻微拖动窗口边缘就能无限重写,正是本预算要挡住的形状。
55
+ */
56
+ const RIGHTBAR_WIDTH_RECOVERY_MAX_ATTEMPTS = 2;
29
57
  /**
30
58
  * DEG 的品牌图标 artwork:与宿主「智能体团队」入口同源的双人(users)图标。
31
59
  *
@@ -131,6 +159,20 @@
131
159
  const SIDEBAR_SPLIT_TRANSACTION_TIMEOUT_MS = 15000;
132
160
  /** RAF 被后台节流时的短 fallback;只负责放行一次 split 尝试,不改变事务提交 deadline。 */
133
161
  const SIDEBAR_SPLIT_LAYOUT_WAIT_FALLBACK_MS = 250;
162
+ /**
163
+ * 「分栏配比暂不可读」时的短延迟重试参数。
164
+ *
165
+ * 场景:DSH 重启后宿主从持久化布局恢复右栏分栏,第二列的 tab 条 / inventory 归属会**比
166
+ * split 容器晚若干帧**才提交。此时既不能证明组成仍成立,也不能证明组成已失效;旧实现把
167
+ * 「读不出来」直接当成「组成失效」立刻交还宿主,用户看到 40/60 → 50/50 的闪回。
168
+ *
169
+ * 因此这类「暂不可读」只在**已经写进过目标配比**之后进入 pending:保持当前样式并安排一次
170
+ * 短延迟重试(去重:同一时刻最多一个待执行重试),读面恢复后由重试或任何一次既有同步自动
171
+ * 重新落回目标配比。重试预算用尽仍读不出来时按「组成确实失效」交还宿主——预算有界,绝不
172
+ * 无限重排定时器,也不会把 0.4/0.6 无限期留在真实的单列结构上。
173
+ */
174
+ const SIDEBAR_SPLIT_PENDING_RETRY_DELAY_MS = 400;
175
+ const SIDEBAR_SPLIT_PENDING_MAX_ATTEMPTS = 4;
134
176
  /** 侧栏打开状态的复合键分隔符:会话 id、rootSessionId 与资源地址都是单行文本,NUL 不会出现在其中。 */
135
177
  const SIDEBAR_TAB_KEY_SEPARATOR = "\u0000";
136
178
 
@@ -143,6 +185,120 @@
143
185
  return value.trim();
144
186
  }
145
187
 
188
+ /**
189
+ * 读取当前视口宽度(px);读不到(没有 window、非有限数、非正数)时返回 null。
190
+ *
191
+ * 右栏宽度是**视口比例**(Expert Group 页 20%、其它右侧分页 50%),但写进宿主 layout store 的
192
+ * 是像素值;因此「换算」与「视口是否变了」必须共用这一个读面,绝不能在两处各读一次
193
+ * (那会让比例换算与变化检测读到不同的视口)。读不到时返回 null,调用方一律放弃写入,
194
+ * 保留宿主当前值,绝不猜一个宽度。
195
+ */
196
+ function viewportWidthOf() {
197
+ let value = null;
198
+ try { value = globalThis.window?.innerWidth ?? globalThis.innerWidth; } catch { value = null; }
199
+ const width = Number(value);
200
+ return Number.isFinite(width) && width > 0 ? width : null;
201
+ }
202
+
203
+ /**
204
+ * 元素查询的三种结果。**必须区分「没命中」与「读面有歧义」**,两者对调用方的含义完全不同:
205
+ *
206
+ * - `missing`:这个选择器在当前 DOM 里确实一个都没命中。它表示「当前宿主版本没有这个读法」,
207
+ * 调用方可以继续尝试其它选择器;
208
+ * - `unique`:恰好命中一个元素,可以当作证据使用;
209
+ * - `ambiguous`:命中多个(读面有歧义,例如新旧 frame 同时残留),或查询本身抛错 / 读面不可用
210
+ * (无 document、没有 querySelectorAll、代理抛错)。它**不是**「这个读法不存在」,因此绝不允许
211
+ * 被别的选择器「兜底」掩盖掉——把多命中降级成 missing 会让旧节点的唯一候选冒充当前 AppFrame。
212
+ */
213
+ const ELEMENT_QUERY_MISSING = "missing";
214
+ const ELEMENT_QUERY_UNIQUE = "unique";
215
+ const ELEMENT_QUERY_AMBIGUOUS = "ambiguous";
216
+
217
+ /**
218
+ * 查询一个选择器,返回 `{ status, element }`(element 只在 unique 时非 null)。
219
+ *
220
+ * 只返回这三种状态,任何异常都归一为 `ambiguous`(读不出来 ≠ 读到了空),绝不抛给调用方,
221
+ * 也绝不按 DOM 顺序取第一个命中:第一个命中无法证明它就是目标元素。
222
+ */
223
+ function elementQueryOf(doc, selector) {
224
+ let nodes = null;
225
+ try {
226
+ if (doc === null || doc === undefined || typeof doc.querySelectorAll !== "function") {
227
+ return { status: ELEMENT_QUERY_AMBIGUOUS, element: null };
228
+ }
229
+ nodes = Array.from(doc.querySelectorAll(selector));
230
+ } catch {
231
+ return { status: ELEMENT_QUERY_AMBIGUOUS, element: null };
232
+ }
233
+ if (nodes.length === 0) return { status: ELEMENT_QUERY_MISSING, element: null };
234
+ if (nodes.length > 1) return { status: ELEMENT_QUERY_AMBIGUOUS, element: null };
235
+ return { status: ELEMENT_QUERY_UNIQUE, element: nodes[0] };
236
+ }
237
+
238
+ /**
239
+ * 解析当前宿主的 AppFrame 根元素;无法**唯一**定位时返回 null。
240
+ *
241
+ * 只用宿主公开的 DOM 标记,与本文件既有的 AppFrame 读法(rightbarTransitionTargets)一致:
242
+ * 1. 主读法 [data-shell-overlay]:覆盖层是 AppFrame 的**直接子节点**,其 parentElement 即 AppFrame;
243
+ * 2. 兜底 [data-rightbar-collapsed] / [data-rightbar-instant]:AppFrame 自己的公开状态属性
244
+ * (旧宿主可能没有覆盖层)。
245
+ *
246
+ * 歧义传播规则(本函数的 fail-closed 边界):
247
+ * - 任何一个选择器**多命中或查询异常** → 立刻返回 null,不再看后面的读法:此时读面已经不可信,
248
+ * 让另一个选择器的唯一候选去「兜底」等于用旧 frame 冒充当前 AppFrame;
249
+ * - 只有真正 `missing`(零命中)才继续尝试下一个选择器;
250
+ * - 候选元素去重后必须**恰好一个**:零个表示定位不到 AppFrame,多个不同元素表示读面有歧义
251
+ * (比如新旧 frame 同时残留),两者都返回 null——调用方按「读不到」处理,绝不挑一个猜。
252
+ */
253
+ function appFrameElementOf(doc) {
254
+ const candidates = [];
255
+ const push = (element) => {
256
+ if (element === null || element === undefined || element.nodeType !== 1) return;
257
+ if (candidates.includes(element) === false) candidates.push(element);
258
+ };
259
+ const overlay = elementQueryOf(doc, "[data-shell-overlay]");
260
+ if (overlay.status === ELEMENT_QUERY_AMBIGUOUS) return null;
261
+ if (overlay.status === ELEMENT_QUERY_UNIQUE) {
262
+ let parent = null;
263
+ try { parent = overlay.element.parentElement ?? null; } catch { parent = null; }
264
+ push(parent);
265
+ }
266
+ for (const selector of ["[data-rightbar-collapsed]", "[data-rightbar-instant]"]) {
267
+ const state = elementQueryOf(doc, selector);
268
+ if (state.status === ELEMENT_QUERY_AMBIGUOUS) return null;
269
+ if (state.status === ELEMENT_QUERY_UNIQUE) push(state.element);
270
+ }
271
+ return candidates.length === 1 ? candidates[0] : null;
272
+ }
273
+
274
+ /**
275
+ * 读取当前宿主 AppFrame 的宽度(px);定位不到、尺寸非法时返回 null。
276
+ *
277
+ * 为什么不能拿 window.innerWidth 顶替:宿主 LayoutController 维护的 layout store 里那份
278
+ * viewportWidth 是用 **AppFrame 根元素的 getBoundingClientRect().width** 更新的,它是右栏宽度
279
+ * 上限 clamp 的参照系,并不恒等于 window.innerWidth(页面缩放、滚动条、外层留白、嵌入形态都会
280
+ * 让两者不等)。把 innerWidth 写进宿主那个权威槽位等于换了一个量去改宿主的判据:frame 比 window
281
+ * 窄时反而抬高上限,写出宿主自己永远不会写的宽度。
282
+ *
283
+ * fail-closed 边界(绝不猜测宿主尺寸):
284
+ * - AppFrame 定位不到或归属有歧义(见 appFrameElementOf)→ null;
285
+ * - 解析结果就是 document.body / document.documentElement → null:它们是「整个文档」,
286
+ * 不是 AppFrame,宽度语义完全不同,绝不拿它们顶替;
287
+ * - 没有 getBoundingClientRect、矩形读不出来、宽度不是有限正数 → null。
288
+ */
289
+ function hostAppFrameWidthOf() {
290
+ try {
291
+ const doc = globalThis.document;
292
+ const frame = appFrameElementOf(doc);
293
+ if (frame === null) return null;
294
+ if (frame === doc?.body || frame === doc?.documentElement) return null;
295
+ const rect = typeof frame.getBoundingClientRect === "function" ? frame.getBoundingClientRect() : null;
296
+ if (rect === null || typeof rect !== "object") return null;
297
+ const width = Number(rect.width);
298
+ return Number.isFinite(width) && width > 0 ? width : null;
299
+ } catch { return null; }
300
+ }
301
+
146
302
  /**
147
303
  * 取一个方法而不读取它的调用形态。
148
304
  *
@@ -833,6 +989,21 @@
833
989
  ".deg_expert_group_state_dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:currentColor}",
834
990
  ".deg_expert_group_state_dot[data-state=running]{color:var(--dsw-static-deepseek-450,var(--dsw-alias-state-business-primary))}",
835
991
  ".deg_expert_group_state_dot[data-state=ready]{color:var(--dsw-alias-state-success-primary)}",
992
+ // 固定 30px 批量控件紧跟标题文字;不占独立 grid 尾列,也不使用自动外边距推向行尾。
993
+ // 标题自身 min-width:0 + 单行省略,窄宽度下只收缩文字,箭头与批量控件保持固定占位。
994
+ ".deg_expert_group_trailing{display:flex;flex:0 0 30px;align-items:center;justify-content:center;width:30px;height:20px;min-width:30px}",
995
+ ".deg_expert_group_task_line_trailing{justify-content:flex-start}",
996
+ ".deg_expert_group_idle_title_line{display:flex;align-items:center;min-width:0;gap:2px}",
997
+ ".deg_expert_group_idle_title_line>.deg_expert_group_task{flex:0 1 auto;min-width:0}",
998
+ // 批量关闭按钮:与单会话箭头同尺寸(30×20)与同色系(主题 business/info,缺 token 退回 cyan),
999
+ // 形状是同一枚「两个左尖括号」图标。pending 期间 disabled + aria-busy,绝不重复发出关闭序列。
1000
+ ".deg_expert_group_sidebar_batch_action{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;flex:0 0 30px;width:30px;height:20px;margin:0;padding:0;border:0;border-radius:5px;background:transparent;cursor:pointer;color:var(--dsw-alias-state-business-primary,var(--dsw-alias-button-info-fill,#22d3ee))}",
1001
+ ".deg_expert_group_sidebar_batch_action:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-business-primary,var(--dsw-alias-button-info-fill,#22d3ee))}",
1002
+ ".deg_expert_group_sidebar_batch_action:focus-visible{outline:2px solid var(--dsw-alias-interactive-border-focus,var(--dsw-alias-label-primary));outline-offset:1px}",
1003
+ ".deg_expert_group_sidebar_batch_action:disabled{opacity:.45;cursor:wait}",
1004
+ // 「零」占位:inventory 已知为空时的非交互蓝色实心圆(直径 12px)。它只占位:
1005
+ // 没有 button / hover / tabindex,不进入键盘与可访问性交互面。
1006
+ ".deg_expert_group_sidebar_batch_zero{display:inline-block;width:12px;height:12px;border-radius:50%;background:var(--dsw-static-deepseek-450,var(--dsw-alias-state-business-primary,#22d3ee))}",
836
1007
  // 树线锚点固定在每一级父行的 20px 状态列中心,避免依赖窗口测量。
837
1008
  ".deg_expert_group_children{position:relative;min-width:0;margin:0;padding:0;--deg-parent-row-height:60px}",
838
1009
  ".deg_expert_group_children>.deg_expert_group_node,.deg_expert_group_children>.deg_expert_group_fanout,.deg_expert_group_children>.deg_expert_group_idle_group{--deg-tree-anchor:14px;position:relative}",
@@ -846,7 +1017,11 @@
846
1017
  // 空闲分组没有隐式首项 spacer;折叠、展开和空列表都保持直接相邻。
847
1018
  ".deg_expert_group_idle_group{position:relative;min-width:0;padding-left:16px;--deg-tree-anchor:14px}",
848
1019
  ".deg_expert_group_idle_group>.deg_expert_group_children{margin:0;padding:0;--deg-parent-row-height:28px}",
849
- ".deg_expert_group_idle_group>.deg_expert_group_row.deg_expert_group_idle_header{height:28px;min-height:28px;padding:4px 0}",
1020
+ // 空闲分组标题行同样是 grid:状态列 + chevron 列 + 可收缩标题列 + 批量 trailing slot。
1021
+ // 第四列用 auto(与 .deg_expert_group_fanout_header 的 actions 列同款):slot 存在时按它自己的
1022
+ // 30px 宽占位,slot 为空(没有批量能力)时该列宽度为 0——标题宽度因此与改动前逐像素一致,
1023
+ // 绝不会因为「能力缺失」而平白被压掉 30px。空 idleChildren 时该 slot 承载不可交互的零圆点。
1024
+ ".deg_expert_group_idle_group>.deg_expert_group_row.deg_expert_group_idle_header{display:grid;grid-template-columns:20px 20px minmax(0,1fr);align-items:center;height:28px;min-height:28px;padding:4px 0}",
850
1025
  ".deg_expert_group_idle_group>.deg_expert_group_row{margin:0}",
851
1026
  ".deg_expert_group_fanout{position:relative;margin-top:0;padding-left:16px}",
852
1027
  ".deg_expert_group_fanout_header{display:grid;grid-template-columns:20px 20px minmax(0,1fr) auto;align-items:center;min-width:0;min-height:28px;gap:4px}",
@@ -919,49 +1094,103 @@
919
1094
  }
920
1095
 
921
1096
  /**
922
- * 读取一个 dock 列自己的 tab 条容器(`data-dockkit-strip-tabs`)。
1097
+ * 读一个 dock host 的 DOM 布尔 `hidden`。
923
1098
  *
924
- * 真实 DockKit 把 tab 芯片挂在**列内 strip-tabs 的后代**上,不是列/pane 的直接子节点,因此
925
- * 必须先找到 strip 容器再取后代 tab。列的内容区里嵌着的另一个 dock 也有自己的 strip,
926
- * 靠 withinSameDockScope 排除(父链上跨过 host/split 的 strip 不算当前列)。
927
- * 返回 null 表示读面不可用;返回空数组表示这一列没有任何自己的 tab 条(调用方 fail-closed)。
1099
+ * 真实 DockLayout(app.asar 里 US 组件渲染 host 的那一处)写的是 React 的 `hidden: !w`,
1100
+ * 其中 w = 「这个 host 的 tab 就是它所属 pane 的 activeTabId」:同一 pane 只有**一个** host
1101
+ * 不带 hidden,其余 host 全部带 hidden。hidden 是 HTMLElement 的布尔 IDL 属性,因此这里只认
1102
+ * 真正的布尔值;属性读面缺失、抛错或返回非布尔(替身没实现、宿主换了渲染方式)一律返回 null,
1103
+ * 调用方按「读不出来」fail-closed,绝不把 undefined 当成「可见」。
928
1104
  */
929
- function cellStripNodesOf(cell) {
930
- if (cell === null || cell === undefined || typeof cell.querySelectorAll !== "function") return null;
931
- let nodes = null;
932
- try { nodes = Array.from(cell.querySelectorAll(RIGHTBAR_STRIP_TABS_SELECTOR)); } catch { return null; }
933
- const strips = [];
934
- for (const node of nodes) {
935
- const scoped = withinSameDockScope(node, cell, [RIGHTBAR_DOCK_HOST_ATTRIBUTE, RIGHTBAR_SPLIT_ATTRIBUTE]);
936
- if (scoped === null) return null;
937
- if (scoped === true) strips.push(node);
1105
+ function hostHiddenStateOf(host) {
1106
+ try {
1107
+ const value = host?.hidden;
1108
+ return typeof value === "boolean" ? value : null;
1109
+ } catch { return null; }
1110
+ }
1111
+
1112
+ /**
1113
+ * 读取一个 dock **逻辑列**(= 同一 data-dockkit-column 下的全部 host)的**唯一活跃 tab 条**。
1114
+ *
1115
+ * 真实 DockLayout 对**每个 tab** 渲染一个 div[data-dockkit-host="dock"],同一 pane 的多个 tab
1116
+ * 共享同一个 data-dockkit-column;而 tab 条本身**只挂在活跃 tab 的那个 host 上**:
1117
+ * hidden: !w ← 非活跃 host 带 DOM hidden
1118
+ * children: [ w && <TabStrip …/>, … ] ← 非活跃 host 连 strip 都不渲染
1119
+ * 活跃 host 的那条 strip 里列出的是**该 pane 的全部 tab**(TabStrip 渲染 pane.tabs),所以只要
1120
+ * 取到唯一活跃 strip,这一逻辑列的 tab 组成就是完整的——多开一个子代理 tab 只会多出一个
1121
+ * hidden 的空 host,绝不能让整列因此变成「读不出来」而退回宿主的 1:1。
1122
+ *
1123
+ * 判定(任一条不成立即整列 fail-closed,绝不猜):
1124
+ * - 每个 host 的 hidden 必须是可读的布尔值;
1125
+ * - hidden === true 且该 host 没有自己的 strip → 真实非活跃 host,跳过;
1126
+ * - hidden === false 且**恰好一条**自己的 strip → 活跃 host,收集这条 strip;
1127
+ * - 可见 host 没有 strip、hidden host 却带着自己的 strip、querySelectorAll 或父链读面抛错
1128
+ * → 结构不是本插件认识的那一种,fail-closed;
1129
+ * - 一个逻辑列出现多个活跃 strip host,或整列一个活跃 strip 都没有 → fail-closed。
1130
+ *
1131
+ * 列内容区里嵌着的另一个 dock 也有自己的 strip,靠 withinSameDockScope 排除(父链上跨过
1132
+ * host/split 的 strip 不算本列)。返回 null 表示读不出来;可读时返回**恰好一条** strip 的数组。
1133
+ */
1134
+ function cellStripNodesOf(column) {
1135
+ const hosts = column?.hosts ?? null;
1136
+ if (!Array.isArray(hosts) || hosts.length === 0) return null;
1137
+ let activeStrip = null;
1138
+ let activeCount = 0;
1139
+ for (const host of hosts) {
1140
+ if (host === null || host === undefined || typeof host.querySelectorAll !== "function") return null;
1141
+ const hidden = hostHiddenStateOf(host);
1142
+ if (hidden === null) return null;
1143
+ let nodes = null;
1144
+ try { nodes = Array.from(host.querySelectorAll(RIGHTBAR_STRIP_TABS_SELECTOR)); } catch { return null; }
1145
+ const owned = [];
1146
+ for (const node of nodes) {
1147
+ const scoped = withinSameDockScope(node, host, [RIGHTBAR_DOCK_HOST_ATTRIBUTE, RIGHTBAR_SPLIT_ATTRIBUTE]);
1148
+ if (scoped === null) return null;
1149
+ if (scoped === true) owned.push(node);
1150
+ }
1151
+ if (hidden === true) {
1152
+ // 真实非活跃 host 完全没有 strip;它若带着自己的 tab 条,说明结构不是这一种,fail-closed。
1153
+ if (owned.length !== 0) return null;
1154
+ continue;
1155
+ }
1156
+ // 可见 host 必须恰好有一条自己的 tab 条:零条(tab 条没渲染出来)或多条都无法证明组成。
1157
+ if (owned.length !== 1) return null;
1158
+ activeCount += 1;
1159
+ if (activeCount > 1) return null;
1160
+ activeStrip = owned[0];
938
1161
  }
939
- return strips;
1162
+ // 整列必须恰好一个活跃 host:整列全 hidden(一条 strip 都没有)同样读不出组成。
1163
+ if (activeCount !== 1 || activeStrip === null) return null;
1164
+ return [activeStrip];
940
1165
  }
941
1166
 
942
1167
  /**
943
- * 读取一列自己的 tabId 集合。
1168
+ * 读取一个逻辑列自己的 tabId 集合。
944
1169
  *
945
- * 只认「这一列自己的 strip-tabs」及其后代 tab 芯片:strip 内部更深一层的 strip 同样属于嵌套
946
- * dock,不算当前列。未知或空 tabId、一列没有任何自己的 tab、读面抛错都让整列读不出来,
1170
+ * 只认**唯一活跃 strip** 里的 tab 芯片(strip 内部更深一层的 strip 属于嵌套 dock,不算本列)。
1171
+ * 读面抛错、活跃 strip 不是恰好一条、strip 里没有任何 tab、未知或空 tabId 都让整列读不出来,
947
1172
  * 调用方据此 fail-closed,绝不把「读不到」当成空列。
1173
+ *
1174
+ * 同一条 strip 里出现重复 tabId 同样是结构异常(一个 tab 不可能在同一个 tablist 里出现两次):
1175
+ * 这里绝不能用 Set 静默吞掉重复,否则「读面不完整」会被误判成正常组成。
948
1176
  */
949
- function cellTabIdsOf(cell) {
1177
+ function cellTabIdsOf(column) {
950
1178
  const ids = new Set();
951
- const strips = cellStripNodesOf(cell);
952
- if (strips === null || strips.length === 0) return { ids, readable: false };
953
- for (const strip of strips) {
954
- if (typeof strip.querySelectorAll !== "function") return { ids, readable: false };
955
- let tabNodes = null;
956
- try { tabNodes = Array.from(strip.querySelectorAll(RIGHTBAR_TAB_SELECTOR)); } catch { return { ids, readable: false }; }
957
- for (const node of tabNodes) {
958
- const scoped = withinSameDockScope(node, strip, [RIGHTBAR_STRIP_TABS_ATTRIBUTE]);
959
- if (scoped === null) return { ids, readable: false };
960
- if (scoped !== true) continue;
961
- const tabId = text(attributeOf(node, RIGHTBAR_TAB_ID_ATTRIBUTE), null);
962
- if (tabId === null) return { ids, readable: false };
963
- ids.add(tabId);
964
- }
1179
+ const strips = cellStripNodesOf(column);
1180
+ // 读面契约:可读时恰好一条活跃 strip;null 或长度不为 1 都表示这一列读不出来。
1181
+ if (strips === null || strips.length !== 1) return { ids, readable: false };
1182
+ const strip = strips[0];
1183
+ if (typeof strip.querySelectorAll !== "function") return { ids, readable: false };
1184
+ let tabNodes = null;
1185
+ try { tabNodes = Array.from(strip.querySelectorAll(RIGHTBAR_TAB_SELECTOR)); } catch { return { ids, readable: false }; }
1186
+ for (const node of tabNodes) {
1187
+ const scoped = withinSameDockScope(node, strip, [RIGHTBAR_STRIP_TABS_ATTRIBUTE]);
1188
+ if (scoped === null) return { ids, readable: false };
1189
+ if (scoped !== true) continue;
1190
+ const tabId = text(attributeOf(node, RIGHTBAR_TAB_ID_ATTRIBUTE), null);
1191
+ if (tabId === null) return { ids, readable: false };
1192
+ if (ids.has(tabId)) return { ids, readable: false };
1193
+ ids.add(tabId);
965
1194
  }
966
1195
  if (ids.size === 0) return { ids, readable: false };
967
1196
  return { ids, readable: true };
@@ -1106,26 +1335,34 @@
1106
1335
  }
1107
1336
 
1108
1337
  /**
1109
- * 读取 split 容器的直接 dock 列。
1338
+ * 读取 split 容器的直接 dock 列,并按列号归并成**逻辑列**。
1110
1339
  *
1111
1340
  * 只有 split 的**直接子节点**里 `data-dockkit-host="dock"` 且带非空 `data-dockkit-column`
1112
1341
  * 的才是真实列:浮动 host(data-dockkit-host="floating")、resize 手柄、嵌套 split 的子列
1113
- * 都不算列。列号缺失/重复一律整轮 fail-closed——拿不到唯一列号的节点不能被当成一列。
1342
+ * 都不算列。列号缺失(读不出来或空串)一律整轮 fail-closed——拿不到列号的节点不能被当成一列。
1343
+ *
1344
+ * 真实 DockLayout 是「**每个 tab 一个 host**」:同一个 pane 的多个 tab 各有自己的
1345
+ * div[data-dockkit-host="dock"],它们共享同一个 data-dockkit-column(只有活跃 tab 的那个 host
1346
+ * 不带 hidden、也只有它渲染 tab 条)。因此同列多个 host 是**合法**结构,这里按列号归并成
1347
+ * { column, hosts }:hosts 保留 DOM 顺序,顺序本身没有语义(组成判定只看活跃 strip 里的
1348
+ * tab 集合),列号到 host 的归属仍然是精确的。
1114
1349
  */
1115
1350
  function dockCellsOf(split) {
1116
1351
  const cells = [];
1117
1352
  let children = null;
1118
1353
  try { children = split?.children ?? null; } catch { children = null; }
1119
1354
  if (children === null || children === undefined) return { cells, readable: false };
1120
- const columns = new Set();
1355
+ const byColumn = new Map();
1121
1356
  for (const child of Array.from(children)) {
1122
1357
  if (child === null || typeof child.getAttribute !== "function") return { cells, readable: false };
1123
1358
  if (attributeOf(child, RIGHTBAR_DOCK_HOST_ATTRIBUTE) !== RIGHTBAR_DOCK_HOST_VALUE) continue;
1124
1359
  const column = text(attributeOf(child, RIGHTBAR_PANE_COLUMN_ATTRIBUTE), null);
1125
- if (column === null || columns.has(column)) return { cells, readable: false };
1126
- columns.add(column);
1127
- cells.push({ cell: child, column });
1360
+ if (column === null) return { cells, readable: false };
1361
+ const merged = byColumn.get(column);
1362
+ if (merged === undefined) byColumn.set(column, { column, hosts: [child] });
1363
+ else merged.hosts.push(child);
1128
1364
  }
1365
+ for (const entry of byColumn.values()) cells.push(entry);
1129
1366
  return { cells, readable: true };
1130
1367
  }
1131
1368
 
@@ -1133,16 +1370,22 @@
1133
1370
  * 判断一个 split 容器是不是「列 0 = Expert Group、列 1 = 当前 session 的另一个已知页」。
1134
1371
  *
1135
1372
  * fail-closed 的边界(任一条不成立就 false,插件一个字节都不写):
1136
- * - 直接 dock 列不是**恰好两列**,或列号不是 0/1(三列、缺列、未知列号都没有可证明的 40:60);
1137
- * - 任一列读不出自己的 strip-tabs / tabId(空列、未知 tabId、读面抛错);
1373
+ * - 归并后的逻辑列不是**恰好两列**,或列号不是精确的 0/1(三列、缺列、未知列号都没有可证明的 40:60);
1374
+ * - 任一逻辑列读不出自己的活跃 strip / tabId(整列没有活跃 strip、多个活跃 strip、
1375
+ * 可见 host 没有 strip、hidden host 却带 strip、hidden 状态不可读、未知 tabId、读面抛错);
1138
1376
  * - 列 0 里混进非 Expert Group 页,或列 1 里出现 Expert Group 页;
1139
- * - 同一个 tabId 同时出现在两列(重复归属),或出现本会话 inventory 里没有的 tab(未知归属)。
1377
+ * - 同一个 tabId 同时出现在两列,或同一条 strip 里重复出现(重复归属),
1378
+ * 或出现本会话 inventory 里没有的 tab(未知归属)。
1379
+ *
1380
+ * 同列多个 host(真实「每个 tab 一个 host」形态)本身合法:判定用的是归并后的逻辑列与它唯一的
1381
+ * 活跃 strip(该 strip 列出整个 pane 的 tab),因此多个子代理 tab 与单个子代理 tab 得到完全
1382
+ * 相同的 40:60 结论。
1140
1383
  */
1141
1384
  function splitOwnsExpertPair(split, tabSets) {
1142
1385
  if (tabSets === null || tabSets === undefined || typeof tabSets !== "object") return false;
1143
1386
  const breakdown = dockCellsOf(split);
1144
1387
  if (breakdown.readable !== true || breakdown.cells.length !== 2) return false;
1145
- const byColumn = new Map(breakdown.cells.map((entry) => [entry.column, entry.cell]));
1388
+ const byColumn = new Map(breakdown.cells.map((entry) => [entry.column, entry]));
1146
1389
  if (!byColumn.has("0") || !byColumn.has("1")) return false;
1147
1390
  const expertTabs = cellTabIdsOf(byColumn.get("0"));
1148
1391
  const otherTabs = cellTabIdsOf(byColumn.get("1"));
@@ -1163,25 +1406,124 @@
1163
1406
  }
1164
1407
 
1165
1408
  /**
1166
- * 在面板里找出**唯一**可解析的分栏容器。
1409
+ * 一个逻辑列的「暂不可读」判定(与 cellStripNodesOf 同一套读面,但区分失败原因)。
1167
1410
  *
1168
- * 面板内部可能同时存在多个 split(列的内容区里嵌着的另一个 dock 也有自己的 split),但只有
1169
- * 满足 splitOwnsExpertPair 的那一个才是本插件的目标:零个表示还没分栏或形态不认识,多个表示
1170
- * 归属不明确——两种情况都返回 null(调用方 fail-closed,绝不挑第一个猜测)。
1411
+ * 返回 "readable" | "unavailable" | "foreign":
1412
+ * - "readable":恰好一条活跃 tab 条且 tab 组成可读——这一列有完整组成,轮不到 pending 分支;
1413
+ * - "unavailable":整列只有 hidden host(tab 条还没渲染出来)。这是重启恢复窗口里第二列的
1414
+ * 典型形状,只有这种形状才允许 pending;
1415
+ * - "foreign":可见 host 没有 tab 条、非活跃 host 却带着自己的 tab 条、一个逻辑列出现多个活跃
1416
+ * strip host、hidden 状态不可读、host 列表为空、读面抛错。这些都不是「还没读完」而是
1417
+ * 「结构不是本插件认识的那一种」,必须按原有 fail-closed 语义交还宿主。
1171
1418
  */
1172
- function resolveSplitContainer(panel, tabSets) {
1173
- if (panel === null || panel === undefined || typeof panel.querySelectorAll !== "function") return null;
1419
+ function cellPendingStateOf(column) {
1420
+ const hosts = column?.hosts ?? null;
1421
+ if (!Array.isArray(hosts) || hosts.length === 0) return "foreign";
1422
+ let activeStrip = null;
1423
+ let activeCount = 0;
1424
+ for (const host of hosts) {
1425
+ if (host === null || host === undefined || typeof host.querySelectorAll !== "function") return "foreign";
1426
+ const hidden = hostHiddenStateOf(host);
1427
+ if (hidden === null) return "foreign";
1428
+ let nodes = null;
1429
+ try { nodes = Array.from(host.querySelectorAll(RIGHTBAR_STRIP_TABS_SELECTOR)); } catch { return "foreign"; }
1430
+ const owned = [];
1431
+ for (const node of nodes) {
1432
+ const scoped = withinSameDockScope(node, host, [RIGHTBAR_DOCK_HOST_ATTRIBUTE, RIGHTBAR_SPLIT_ATTRIBUTE]);
1433
+ if (scoped === null) return "foreign";
1434
+ if (scoped === true) owned.push(node);
1435
+ }
1436
+ if (hidden === true) {
1437
+ // 真实非活跃 host 完全没有 strip;带了自己的 tab 条说明结构不是这一种。
1438
+ if (owned.length !== 0) return "foreign";
1439
+ continue;
1440
+ }
1441
+ if (owned.length !== 1) return "foreign";
1442
+ activeCount += 1;
1443
+ if (activeCount > 1) return "foreign";
1444
+ activeStrip = owned[0];
1445
+ }
1446
+ if (activeCount !== 1 || activeStrip === null) return "unavailable";
1447
+ return cellTabIdsOf(column).readable === true ? "readable" : "foreign";
1448
+ }
1449
+
1450
+ /**
1451
+ * 区分「两列但第二列内容暂不可读」与「组成明确不再是目标」。
1452
+ *
1453
+ * 为什么需要它:重启恢复(宿主从持久化布局恢复右栏)时,split 容器往往比列内的 tab 条 /
1454
+ * inventory 归属早几帧出现。这段窗口里 splitOwnsExpertPair 会返回 false,但 false 同时
1455
+ * 覆盖了两种完全不同的现实:
1456
+ * - **暂不可读**(本函数返回 "unavailable"):结构上仍是两列,列 0 明确是本会话的
1457
+ * Expert Group 页,只有列 1 整列还没有活跃 tab 条(tab 条比 split 容器晚若干帧提交)。
1458
+ * 此时组成只是「还没读完」,插件应当保持已写下的配比并短延迟重试,绝不闪回宿主默认值;
1459
+ * - **明确非目标 / 单列**(返回 "foreign"):列数不是精确的 0/1 两列、列 0 不是 Expert Group
1460
+ * 页、列 0 的 tab 无法归属、或两列里混进重复/未知归属。这些都没有可证明的 40:60,必须
1461
+ * 按原有 fail-closed 语义交还宿主。
1462
+ *
1463
+ * 归属判定与 splitOwnsExpertPair 完全同源(同一份 tabSets、同一条唯一活跃 strip 读法),
1464
+ * 因此这里**不引入任何新的宽松证据**:pending 只在「目标那一侧已经被证明」时才成立,
1465
+ * 首次接管(从未写过目标配比)也绝不会因为列 1 读不出来就去猜页面归属。
1466
+ */
1467
+ function splitPairPendingOf(split, tabSets) {
1468
+ if (tabSets === null || tabSets === undefined || typeof tabSets !== "object") return "foreign";
1469
+ const breakdown = dockCellsOf(split);
1470
+ if (breakdown.readable !== true || breakdown.cells.length !== 2) return "foreign";
1471
+ const byColumn = new Map(breakdown.cells.map((entry) => [entry.column, entry]));
1472
+ if (!byColumn.has("0") || !byColumn.has("1")) return "foreign";
1473
+ // 列 0 必须是可读且明确属于本会话的 Expert Group 页:读不出来就没有「已确认的目标」。
1474
+ const expertTabs = cellTabIdsOf(byColumn.get("0"));
1475
+ if (expertTabs.readable !== true || expertTabs.ids.size === 0) return "foreign";
1476
+ for (const tabId of expertTabs.ids) {
1477
+ if (tabSets.known.has(tabId) !== true) return "foreign";
1478
+ if (tabSets.expert.has(tabId) !== true) return "foreign";
1479
+ }
1480
+ // 列 1 只有「整列还没有活跃 tab 条」这一种形状算暂不可读;其余(组成读得出来、可见 host
1481
+ // 没有 strip、一个逻辑列多个活跃 strip、hidden 不可读等)都是「明确不是目标」,交回严格判定。
1482
+ return cellPendingStateOf(byColumn.get("1")) === "unavailable" ? "unavailable" : "foreign";
1483
+ }
1484
+
1485
+ /**
1486
+ * 在面板里按给定判据挑出**唯一**的分栏容器。
1487
+ *
1488
+ * 面板内部可能同时存在多个 split(列的内容区里嵌着的另一个 dock 也有自己的 split),因此
1489
+ * 判据命中多个时一律视为归属不明确并返回 null(调用方 fail-closed,绝不挑第一个猜测);
1490
+ * 返回数组的形态让调用方可以自行决定「零个 / 一个 / 多个」的处理方式(见下面的两个包装)。
1491
+ */
1492
+ function uniqueSplitBy(panel, matches) {
1493
+ if (panel === null || panel === undefined || typeof panel.querySelectorAll !== "function") return [];
1174
1494
  let nodes = null;
1175
- try { nodes = Array.from(panel.querySelectorAll(RIGHTBAR_SPLIT_SELECTOR)); } catch { return null; }
1176
- let resolved = null;
1495
+ try { nodes = Array.from(panel.querySelectorAll(RIGHTBAR_SPLIT_SELECTOR)); } catch { return []; }
1496
+ const resolved = [];
1177
1497
  for (const node of nodes) {
1178
- if (splitOwnsExpertPair(node, tabSets) !== true) continue;
1179
- if (resolved !== null) return null;
1180
- resolved = node;
1498
+ if (matches(node) !== true) continue;
1499
+ resolved.push(node);
1500
+ if (resolved.length > 1) break;
1181
1501
  }
1182
1502
  return resolved;
1183
1503
  }
1184
1504
 
1505
+ /**
1506
+ * 在面板里找出**唯一**可解析的分栏容器。
1507
+ *
1508
+ * 只有满足 splitOwnsExpertPair 的那一个才是本插件的目标:零个表示还没分栏或形态不认识,
1509
+ * 多个表示归属不明确——两种情况都返回 null(调用方 fail-closed)。
1510
+ */
1511
+ function resolveSplitContainer(panel, tabSets) {
1512
+ const resolved = uniqueSplitBy(panel, (node) => splitOwnsExpertPair(node, tabSets) === true);
1513
+ return resolved.length === 1 ? resolved[0] : null;
1514
+ }
1515
+
1516
+ /**
1517
+ * 找出面板里**唯一**的「暂不可读 pending」分栏容器(见 splitPairPendingOf 的说明)。
1518
+ *
1519
+ * 零个表示这一轮没有任何可保持目标配比的两列结构,多个表示归属不明确——调用方据此走
1520
+ * 原有的交还宿主分支,绝不挑第一个猜测。返回数组(而不是元素或 null),因为「零个」与
1521
+ * 「多个」在这里都必须与「恰好一个」区分开。
1522
+ */
1523
+ function splitPairPendingContainers(panel, tabSets) {
1524
+ return uniqueSplitBy(panel, (node) => splitPairPendingOf(node, tabSets) === "unavailable");
1525
+ }
1526
+
1185
1527
  /**
1186
1528
  * 解析 grid-template-columns 的三轨形态。
1187
1529
  *
@@ -1391,6 +1733,33 @@
1391
1733
  let hostCapture = null;
1392
1734
  /** dispose 之后所有同步(含已经排队的迟到回调)一律短路,绝不再碰宿主样式。 */
1393
1735
  let disposed = false;
1736
+ /**
1737
+ * 「暂不可读」pending 的记账(**只在已经写进过目标配比之后**才可能成立)。
1738
+ *
1739
+ * 语义:容器上当前就是插件写下的目标配比,但这一轮读面读不出「列 1 是本会话的另一个已知页」,
1740
+ * 也没有任何证据说明组成真的失效了(结构仍是两列、列 0 仍是 Expert Group 页)。
1741
+ * 此时用 applyRatio 维持/修复目标配比,并安排一次短延迟重试去重新求值——这正是重启恢复
1742
+ * 窗口里避免 target → host default 闪回的机制。
1743
+ *
1744
+ * attempts 的语义是「**实际执行过的** pending retry timer 轮数」:只有 schedulePendingRetry
1745
+ * 的回调真正跑起来才自增,外部同步(sessions 广播 / observer / 轮询)只读取它、绝不修改,
1746
+ * 因此同一个 400ms 等待期内的任意多次通知都不会提前耗尽预算。
1747
+ */
1748
+ let pending = null;
1749
+ /** 去重用的待执行重试定时器句柄:同一时刻最多一个,重复同步绝不堆积。 */
1750
+ let pendingRetryTimer = null;
1751
+ /**
1752
+ * 最近一次同步传入的**真实读面函数**(install 收尾接线的 splitTrackReader.readTarget)。
1753
+ *
1754
+ * 重试 timer 必须用它重新读一遍当前 document / session / panel / split / inventory 状态,
1755
+ * 绝不能把上一轮那个 "pending" 结论原样回放:回放的话 timer 自己永远看不到「读面已经恢复」,
1756
+ * 收敛就只能依赖外部的 sessions / observer 通知,而重启恢复窗口里恰恰没有这些通知——
1757
+ * 结果是重试预算被白白烧完,配比反而被交还给宿主默认值(闪回)。
1758
+ *
1759
+ * 读面本身是纯函数式的现读(不依赖 this,也不缓存 DOM),因此这里只保存函数引用即可,
1760
+ * 每次调用都会重新求值。读面为 null(install 收尾前)时 pending 不可能被建立。
1761
+ */
1762
+ let lastReader = null;
1394
1763
 
1395
1764
  /** 读一条内联值与优先级;任何能力缺失/抛错都返回 null,由调用方按「读不到」处理。 */
1396
1765
  const readInline = (style, name, readerName) => {
@@ -1506,16 +1875,91 @@
1506
1875
  } catch { /* 还原失败只影响外观,后续同步仍按同一规则重试。 */ }
1507
1876
  };
1508
1877
 
1878
+ /** 取消待执行的重试(去重的唯一出口):dispose、容器更换、状态离开 pending 时都必须调用。 */
1879
+ const cancelPendingRetry = () => {
1880
+ if (pendingRetryTimer === null) return;
1881
+ const timer = pendingRetryTimer;
1882
+ pendingRetryTimer = null;
1883
+ try {
1884
+ // 句柄由所在环境的 setTimeout 生成,必须用同一环境的 clearTimeout 释放。
1885
+ globalThis.clearTimeout?.(timer);
1886
+ } catch { /* 取消失败不能阻止其它所有权释放。 */ }
1887
+ };
1888
+
1889
+ /** 结束 pending:清掉容器身份、重试预算与待执行定时器(三个字段必须一起复位,绝不留半状态)。 */
1890
+ const clearPending = () => {
1891
+ pending = null;
1892
+ cancelPendingRetry();
1893
+ };
1894
+
1895
+ /**
1896
+ * 为「暂不可读」安排一次短延迟重试。
1897
+ *
1898
+ * 三条硬约束(对应契约里的「有界、去重、可取消」):
1899
+ * - 去重:pendingRetryTimer 非空就直接返回,重复同步(面板 observer / 2.5 秒轮询 / 宿主广播)
1900
+ * 绝不堆积定时器;
1901
+ * - 有界:**实际执行过的 timer 轮数**达到上限后不再重排,按「组成确实失效」交还宿主样式;
1902
+ * - 可取消:dispose、容器更换、状态离开 pending 时都会 cancelPendingRetry。
1903
+ *
1904
+ * 预算的记账点只有一个:**这个回调真正开始执行的那一刻**。外部同步(sessions 广播 /
1905
+ * observer / 轮询)无论触发多少次都不消耗预算——它们只是在同一个 400ms 等待期内重复求值,
1906
+ * 若也去自增 attempts,真实 timer 一轮都没跑预算就被烧完,重启恢复窗口会提前闪回宿主比例。
1907
+ *
1908
+ * 回调里先出队,把这一轮计入预算,再用**真实读面**重新求值一次(sync(lastReader)):
1909
+ * 若读面已经恢复,sync 会走正常路径重新落到目标配比并立即清空 pending;若仍是 pending,
1910
+ * sync 会在预算内重排下一个 timer,预算耗尽时则 clearPending + restore(有界收敛)。
1911
+ * 没有可用调度器时只是「这一轮不重试」,任何一次既有同步仍会重新求值,绝不影响页面其它行为。
1912
+ */
1913
+ const schedulePendingRetry = (container) => {
1914
+ if (disposed || pendingRetryTimer !== null) return;
1915
+ try {
1916
+ pendingRetryTimer = globalThis.setTimeout(() => {
1917
+ pendingRetryTimer = null;
1918
+ if (disposed) return;
1919
+ // 迟到 timer:容器/状态已经变过(session 切换、容器重挂载、已收敛),一律不再动作。
1920
+ if (pending === null || pending.container !== container) return;
1921
+ const entry = pending;
1922
+ // 读面函数缺失时不做任何猜测性写入:这一轮什么都不做,等下一次真实同步。
1923
+ if (typeof lastReader !== "function") return;
1924
+ // 这一轮 timer 确实执行了 → 只有这里消耗一轮预算(外部同步绝不走这条路径)。
1925
+ pending = { container, attempts: entry.attempts + 1 };
1926
+ sync(lastReader);
1927
+ // sync 自己会在「仍是 pending 且预算未耗尽」时重排。这里只兜底它提前返回的情况
1928
+ // (例如 document 读面不可用):pending 还在、预算还有、却没有 timer,必须补一个,
1929
+ // 否则有界重试会静默中断;attempts 已经自增,因此补排也必然有界。
1930
+ if (pending !== null && pending.container === container
1931
+ && pendingRetryTimer === null
1932
+ && pending.attempts < SIDEBAR_SPLIT_PENDING_MAX_ATTEMPTS) {
1933
+ schedulePendingRetry(container);
1934
+ }
1935
+ }, SIDEBAR_SPLIT_PENDING_RETRY_DELAY_MS);
1936
+ } catch {
1937
+ // 没有可用调度器:放弃这一轮重试(pending 保留,下一次同步会重新求值)。
1938
+ pendingRetryTimer = null;
1939
+ }
1940
+ };
1941
+
1509
1942
  /**
1510
1943
  * 按当前右栏 DOM 组成重估一次列宽配比。
1511
1944
  *
1512
1945
  * 读面全部由调用方以参数交进来(readTarget 每次现读 session / panel / split 容器):
1513
1946
  * 这里**不引用** install 体内的任何后置 const(那是 TDZ 陷阱的来源),所有输入都是显式依赖,
1514
1947
  * 调用点必须自己保证它们就绪。
1515
- * 返回 true 表示这一轮之后配比确实落在容器上(含幂等命中),false 表示已还原/让位/未写入。
1948
+ *
1949
+ * readTarget 返回 { status, container }:
1950
+ * - status 缺省 / "target":组成可证明,按 applyRatio 施加目标配比;
1951
+ * - status "pending":**已经写过目标配比**、但这一轮读不出列 1 的组成(重启恢复窗口)——
1952
+ * 继续用 applyRatio 维持/修复目标配比并短延迟重试,绝不闪回宿主默认值,也绝不覆盖外部值;
1953
+ * - 其余(null、单列、明确非目标):交还宿主样式。
1954
+ *
1955
+ * 返回 true 表示这一轮之后插件仍持有 pending 或配比确实落在容器上(含幂等命中),
1956
+ * false 表示已还原 / 让位 / 未写入。调用方(requestSplitTrackSync 的各入口)不使用这个布尔值,
1957
+ * 它只服务于重试回调的兜底重排。
1516
1958
  */
1517
1959
  const sync = (readTarget) => {
1518
1960
  if (disposed) return false;
1961
+ // 记住本次的真实读面:pending 重试 timer 必须用它重新现读状态,而不是回放上一轮结论。
1962
+ if (typeof readTarget === "function") lastReader = readTarget;
1519
1963
  // document 读面不可用:连「有没有分栏容器」都无法证明,保持现状(绝不猜测性还原)。
1520
1964
  let doc = null;
1521
1965
  try { doc = globalThis.document ?? null; } catch { doc = null; }
@@ -1527,19 +1971,57 @@
1527
1971
  // (那些调用点只包了「失败不影响宿主」的兜底,异常外溢会让后面的宽度/手柄逻辑一起被跳过)。
1528
1972
  try { target = typeof readTarget === "function" ? readTarget(doc) : null; } catch { target = null; }
1529
1973
  if (target === null || target.container === null || target.container === undefined) {
1974
+ clearPending();
1530
1975
  restore();
1531
1976
  return false;
1532
1977
  }
1533
- // 容器换了(宿主重挂载 / 切到另一条 surface):先交还旧容器,再接管新容器。
1978
+ // 容器换了(宿主重挂载 / 切到另一条 surface):先作废上一轮的 pending 记账,再交还旧容器。
1979
+ if (pending !== null && pending.container !== target.container) clearPending();
1534
1980
  if (applied !== null && applied.container !== target.container) restore();
1981
+ // 「暂不可读」只在**已经写过目标配比**之后才成立:没有 applied 就没有需要保持的样式,
1982
+ // 首次接管必须继续走原有 fail-closed 语义——绝不因为列 1 读不出来就去猜页面归属,
1983
+ // 也绝不凭空列或缺失 inventory 把未知页面当成目标(这条边界不能放松)。
1984
+ if (target.status === "pending" && applied !== null && applied.container === target.container) {
1985
+ // attempts 只统计**实际执行过的 pending retry timer 轮数**(由 schedulePendingRetry 的
1986
+ // 回调自增)。外部同步——sessions 广播、面板 observer、2.5 秒轮询——无论在同一等待期内
1987
+ // 触发多少次,都既不消耗预算也不重置预算,只维持 pending 并保证最多一个 timer。
1988
+ const attempts = pending !== null && pending.container === target.container ? pending.attempts : 0;
1989
+ if (attempts < SIDEBAR_SPLIT_PENDING_MAX_ATTEMPTS) {
1990
+ // 暂不可读期间同样要维持配比,不能只「保持当前样式」:宿主可能在这段窗口里重渲染回
1991
+ // 它自己的等分形态(甚至清掉内联声明),那样错误比例会一直留在屏幕上,直到预算耗尽
1992
+ // 也回不到 40/60。这里复用与「组成可证明」时**完全相同**的 applyRatio:
1993
+ // - 目标值 / 宿主自己的等分 / 宿主清空内联声明 → 保持或修复回目标配比;
1994
+ // - 外部刻意写下的非等分值 → 由 shouldApply 让位,绝不覆盖(不引入第二套写入规则)。
1995
+ applyRatio(target.container);
1996
+ // 首次进入 pending 才建立记账(attempts 从 0 起);已存在时原样保留已消耗的预算,
1997
+ // 绝不因外部同步累加(那会让预算在 timer 一轮都没跑时就被烧完)或重置。
1998
+ if (pending === null) pending = { container: target.container, attempts: 0 };
1999
+ schedulePendingRetry(target.container);
2000
+ // 保持 pending:组成还没恢复,继续有界重试(applyRatio 让位时也不改写外部值)。
2001
+ return true;
2002
+ }
2003
+ // 预算已经被**实际 timer 轮次**耗尽仍读不出来:按「组成确实失效」交还宿主样式并停止重排。
2004
+ clearPending();
2005
+ restore();
2006
+ return false;
2007
+ }
2008
+ // 组成可证明:清空 pending 记账后按目标配比重新求值(读面恢复时这一步会清掉重试预算)。
2009
+ clearPending();
2010
+ // 「暂不可读」但还没有可保持的样式:这一轮什么都不写(不是目标,也不猜测),
2011
+ // 由后续同步在组成可证明时再施加。
2012
+ if (target.status === "pending") return false;
1535
2013
  return applyRatio(target.container);
1536
2014
  };
1537
2015
 
1538
2016
  return {
1539
2017
  sync,
1540
- /** 卸载收尾:还原内联声明并置位 disposed,之后任何迟到同步都不再写入。 */
2018
+ /**
2019
+ * 卸载收尾:取消待执行的重试、还原内联声明并置位 disposed,之后任何迟到同步都不再写入。
2020
+ * 定时器必须在这里释放——卸载后插件不得再碰宿主样式,也不留下泄漏的 timer。
2021
+ */
1541
2022
  dispose() {
1542
2023
  disposed = true;
2024
+ clearPending();
1543
2025
  restore();
1544
2026
  },
1545
2027
  };
@@ -1845,6 +2327,33 @@
1845
2327
  const sidebarSplitIntentRef = typeof react.useRef === "function" ? react.useRef(null) : { current: null };
1846
2328
  /** 侧栏箭头动作的同 key 在途集合(打开/关闭共用一把锁):重复点击只允许一次动作穿过。 */
1847
2329
  const sidebarTabPendingRef = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
2330
+ /**
2331
+ * 批量关闭动作的聚合锁,按 (sidebar session, root) 组合键隔离(主代理行 / 空闲分组标题行共用同一把)。
2332
+ *
2333
+ * 一次点击会顺序 await 多个 close(tabId):整段序列在途期间必须拒绝第二次点击,否则双击会
2334
+ * 让同一个 tabId 被关闭两次(第二次必然抛错),也会让两次序列交错发出关闭请求。
2335
+ *
2336
+ * 锁键必须含 root + sidebar session,而不是一个组件级 boolean:宿主 close 返回的 Promise
2337
+ * 可能长时间不 settle,而用户完全可以在它挂起期间切换 root。单一 boolean 会让旧 root 的
2338
+ * 在途序列把**新 root** 的批量按钮一并置成 disabled —— 一次与它无关的切换被旧锁阻塞,
2339
+ * 且旧序列一旦永不 settle 就永久阻塞。集合里只保留仍在途的 scope,结算时精确删除自己那一项。
2340
+ */
2341
+ const sidebarBatchPendingRef = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
2342
+ /**
2343
+ * 批量关闭锁的复合键:当前 sidebar session + 当前 root。
2344
+ *
2345
+ * 两者缺一不可:同一个 root 的 root/child surface 各有自己的右栏,不同 root 更是完全独立的
2346
+ * surface;只有完全相同的两者才共享同一把锁(同一 scope 内 running/idle 两个批量入口互斥)。
2347
+ */
2348
+ const sidebarBatchScopeKey = (sessionId, rootId) => String(sessionId) + SIDEBAR_TAB_KEY_SEPARATOR + String(rootId);
2349
+ /**
2350
+ * 最近一次同步算出的「批量候选 tabId 签名」。
2351
+ *
2352
+ * 主代理行与空闲分组标题行的批量控件/零圆点由渲染期现读的 openTabs inventory 决定,
2353
+ * 因此 inventory 变化必须触发一次重渲染——但 2.5 秒轮询在没有变化时绝不能再 bump,
2354
+ * 否则空闲时会变成定时重渲染。这里只在签名真的变化时 bump(见 syncSidebarTabs)。
2355
+ */
2356
+ const sidebarBatchSignatureRef = typeof react.useRef === "function" ? react.useRef(null) : { current: null };
1848
2357
  /**
1849
2358
  * 最近一次渲染的右栏 tab 同步器(每次渲染同步写入,写法与 refreshRef 一致)。
1850
2359
  *
@@ -2408,6 +2917,149 @@
2408
2917
  for (const child of children) add(child, rootSessionId);
2409
2918
  return addresses;
2410
2919
  };
2920
+ /**
2921
+ * 当前 root 专家树里的「运行中」资源地址(普通专家、嵌套专家与最新 fan-out 分片)。
2922
+ *
2923
+ * 与 renderThreeLines 使用**同一个**规范地址 helper(subagentAddressOfChild + subagentChatAddress),
2924
+ * 因此批量候选与单会话箭头指向的是同一个 contentId,绝不存在第二套地址推导。
2925
+ *
2926
+ * 纳入条件只有一条:runtimeStatusOfNode(...) === "running"(与行内圆点、第三行摘要同源)。
2927
+ * ready/unknown 一律不纳入——未知状态绝不混进「可以关闭」的集合,空闲专家由空闲分组单独负责。
2928
+ *
2929
+ * parent 证据缺失或非法时 fail-closed:subagentAddressOfChild 会返回 null,该节点直接跳过,
2930
+ * 绝不用猜测的 parent 补出一个地址(猜出来的地址可能关掉另一个会话的 tab)。
2931
+ */
2932
+ const sidebarExpertResourceAddresses = (node, parentId, addresses) => {
2933
+ const source = object(node);
2934
+ const kind = text(source.kind, null);
2935
+ if (kind === "fanout") {
2936
+ const shards = Array.isArray(source.shards) ? source.shards : [];
2937
+ for (const shard of shards) {
2938
+ const shardSource = object(shard);
2939
+ // 只认最新 attempt 的 childId:旧 attempt 的子会话不再由这一行代表,也不是可导航目标。
2940
+ const childId = text(shardSource.childId, null);
2941
+ if (childId === null) continue;
2942
+ if (runtimeStatusOfNode(shardSource, parentId, childId) !== "running") continue;
2943
+ const address = subagentChatAddress(subagentAddressOfChild(childId, parentId, {
2944
+ ...shardSource,
2945
+ kind: "expert",
2946
+ }));
2947
+ if (address !== null) addresses.add(address);
2948
+ }
2949
+ return;
2950
+ }
2951
+ if (kind !== "expert") return;
2952
+ const childId = text(source.sessionId, text(source.childId, null));
2953
+ if (childId !== null && runtimeStatusOfNode(source, parentId, childId) === "running") {
2954
+ const address = subagentChatAddress(subagentAddressOfChild(childId, parentId, source));
2955
+ if (address !== null) addresses.add(address);
2956
+ }
2957
+ // 嵌套专家:parent 就是它的宿主专家会话;宿主缺失时内层地址推导 fail-closed。
2958
+ if (Array.isArray(source.children)) {
2959
+ for (const child of source.children) sidebarExpertResourceAddresses(child, childId, addresses);
2960
+ }
2961
+ };
2962
+
2963
+ /** 当前快照的 root 直接子节点(快照缺失时为空数组)。 */
2964
+ const sidebarRootChildren = () => (Array.isArray(snapshot?.root?.children) ? snapshot.root.children : []);
2965
+
2966
+ /**
2967
+ * 空闲分组的成员判定(ready 直接专家)——渲染与同步期共用这一份定义,
2968
+ * 避免两处各推导一次而产生第二事实源。
2969
+ */
2970
+ const sidebarIdleChildren = () => sidebarRootChildren()
2971
+ .filter((node) => node.kind === "expert" && runtimeStatusOfNode(node, rootSessionId, text(node.sessionId, null)) === "ready");
2972
+
2973
+ /**
2974
+ * 当前 root 专家树里「运行中」的资源地址集合(主代理行批量关闭的唯一候选来源)。
2975
+ * 与空闲分组互斥:这里只遍历 root.children 中的专家/fan-out 节点,空闲成员不参与。
2976
+ */
2977
+ const runningSidebarResourceAddresses = () => {
2978
+ const addresses = new Set();
2979
+ for (const child of sidebarRootChildren()) sidebarExpertResourceAddresses(child, rootSessionId, addresses);
2980
+ return addresses;
2981
+ };
2982
+
2983
+ /**
2984
+ * 空闲分组(ready 直接专家)的资源地址集合;成员判定严格复用当前 idleChildren,绝不重新推导。
2985
+ */
2986
+ const idleSidebarResourceAddresses = (nodes) => {
2987
+ const addresses = new Set();
2988
+ for (const node of Array.isArray(nodes) ? nodes : []) {
2989
+ const source = object(node);
2990
+ const childId = text(source.sessionId, text(source.childId, null));
2991
+ if (childId === null) continue;
2992
+ const address = subagentChatAddress(subagentAddressOfChild(childId, rootSessionId, source));
2993
+ if (address !== null) addresses.add(address);
2994
+ }
2995
+ return addresses;
2996
+ };
2997
+
2998
+ /**
2999
+ * 一次批量动作的候选集合:当前 openTabs inventory ∩ 给定的规范地址集合。
3000
+ *
3001
+ * 匹配条件缺一不可(全部来自公开只读 inventory 的字段):
3002
+ * - candidate.sessionId === 当前 sidebar session:绝不操作别的 surface 的 tab;
3003
+ * - kind === "subagentchat":同 session 下别的 kind 不是本插件的资源;
3004
+ * - tabId 与 contentId 都非空:缺任何一个都无法精确关闭,也就没有可关闭的候选;
3005
+ * - contentId 精确等于集合中的地址:同一个 kind 下也可能是另一个子代理的地址。
3006
+ *
3007
+ * 结果按 tabId 去重:宿主 inventory 理论上可能有重复记录,重复 tabId 只能关一次。
3008
+ */
3009
+ const sidebarBatchCandidates = (addresses, inventory) => {
3010
+ const seen = new Set();
3011
+ const candidates = [];
3012
+ for (const entry of inventory) {
3013
+ // 单条损坏记录(getter 抛错、非对象、缺字段)只跳过这一条:其余可关闭的 tab 照旧成候选。
3014
+ let candidate = null;
3015
+ try {
3016
+ candidate = object(entry);
3017
+ if (text(candidate.sessionId, null) !== sidebarSessionId) continue;
3018
+ if (text(candidate.kind, null) !== SUBAGENT_CHAT_KIND) continue;
3019
+ const tabId = text(candidate.tabId, null);
3020
+ if (tabId === null) continue;
3021
+ const contentId = text(candidate.contentId, null);
3022
+ if (contentId === null || addresses.has(contentId) !== true) continue;
3023
+ if (seen.has(tabId) === true) continue;
3024
+ seen.add(tabId);
3025
+ candidates.push(tabId);
3026
+ } catch { continue; }
3027
+ }
3028
+ return candidates;
3029
+ };
3030
+
3031
+ /**
3032
+ * 两类标题行的批量状态(渲染期纯读,一次渲染只算一次)。
3033
+ *
3034
+ * 四态语义严格区分「未知」「零」「有候选但关不掉」:
3035
+ * - inventory 读不出来(旧宿主 / 读面抛错 / 非数组)→ kind "none":不渲染零圆点、也不渲染按钮;
3036
+ * - inventory 已知是数组且候选数为 0 → kind "zero":非交互蓝色实心圆占位(需要左箭头图标可用)。
3037
+ * 零状态只由「候选集合为空」决定,**不依赖 close 能力**:本行确实没有可关闭的会话时,
3038
+ * 蓝点表达的就是这个事实,与宿主能否关闭无关;
3039
+ * - 候选数 > 0 且 close 能力可用 → kind "close":可点击的双左尖括号批量关闭按钮;
3040
+ * - 候选数 > 0 但 close 不可用 → kind "none":既不能误报「零」(inventory 里明明有本行的
3041
+ * 打开会话),也绝不渲染一个点了不会关的假按钮,这一行整个不出现行尾控件。
3042
+ *
3043
+ * 判定的顺序有实质意义:必须先算候选再看 close 能力。反过来(先看 close)会把「有候选但
3044
+ * 关不掉」误判成「零」,那正是蓝点最不该出现的场合。
3045
+ */
3046
+ const sidebarBatchStateFor = (addresses) => {
3047
+ const inventory = readSidebarOpenTabs();
3048
+ if (inventory === null) return { kind: "none", candidates: [] };
3049
+ const candidates = sidebarBatchCandidates(addresses, inventory);
3050
+ if (candidates.length === 0) return { kind: "zero", candidates: [] };
3051
+ if (closeSidebarResource === null) return { kind: "none", candidates: [] };
3052
+ return { kind: "close", candidates };
3053
+ };
3054
+
3055
+ /**
3056
+ * 两行批量状态的合并签名:只在候选集合真的变化时触发一次重渲染(空闲轮询绝不空转重渲染)。
3057
+ * 两行必须一起进签名——只跟踪主代理行会让空闲分组的手动开/关 tab 迟迟不被反映。
3058
+ */
3059
+ const sidebarBatchSignatureOf = (runningState, idleState) => {
3060
+ const part = (state) => (state.kind === "close" ? "close:" + state.candidates.join(",") : state.kind);
3061
+ return part(runningState) + "|" + part(idleState);
3062
+ };
2411
3063
 
2412
3064
  /**
2413
3065
  * 等宿主完成一次布局提交,再读取 DockLayout 的可分栏判定。
@@ -2507,6 +3159,21 @@
2507
3159
  // 右栏拖拽手柄锁复用这条既有生命周期(挂载 / body 子树变更 / 2.5 秒轮询 / 可见性变化):
2508
3160
  // 活跃 tab 一变就重估,绝不为它单独再挂一个长期 observer。请求失败只影响这一次重估。
2509
3161
  try { requestRightbarDragSync?.(); } catch { /* 手柄锁只是体验优化,绝不影响面板。 */ }
3162
+ /**
3163
+ * 标题行批量控件/零圆点由渲染期现读的 openTabs inventory 决定,因此这里必须在**任何提前 return
3164
+ * 之前**比较一次候选签名:records 为空时旧实现会直接返回,inventory 变化就再也不会被反映,
3165
+ * 用户手动打开的 tab(没有本插件的打开记录)也会永远看不到可关闭的批量按钮。
3166
+ *
3167
+ * 只有签名真的变化才 bump:2.5 秒轮询在没有变化时绝不产生重渲染。
3168
+ */
3169
+ const batchSignatureNow = sidebarBatchSignatureOf(
3170
+ sidebarBatchStateFor(runningSidebarResourceAddresses()),
3171
+ sidebarBatchStateFor(idleSidebarResourceAddresses(sidebarIdleChildren())),
3172
+ );
3173
+ if (sidebarBatchSignatureRef.current !== batchSignatureNow) {
3174
+ sidebarBatchSignatureRef.current = batchSignatureNow;
3175
+ bump((value) => value + 1);
3176
+ }
2510
3177
  const records = sidebarTabRecordsRef.current;
2511
3178
  const openings = sidebarTabOpeningRef.current;
2512
3179
  const now = Date.now();
@@ -2532,6 +3199,9 @@
2532
3199
  // 先完成精确 split 提交,再执行关闭同步;guide-only 或错误 pane 仍继续等待。
2533
3200
  tryCommitSidebarSplit();
2534
3201
  // 先完成打开记录,再执行关闭同步;同 pane 回落不再被转换成独立浮窗。
3202
+ // 这里保留「没有打开记录就直接返回」的既有短路(它保证空闲时不会发生任何 DOM 查询);
3203
+ // 批量控件的聚合状态更新已经在**这个短路之前**完成,因此 records 为空时 inventory 的变化
3204
+ // 照旧能被反映,而用户手动打开的 tab 也不再依赖本插件的打开记录。
2535
3205
  if (records.size === 0) return;
2536
3206
  const present = presentSidebarTabIds();
2537
3207
  if (present === null) return;
@@ -2791,6 +3461,126 @@
2791
3461
  finally { pending.delete(key); }
2792
3462
  };
2793
3463
 
3464
+ /**
3465
+ * 批量关闭的唯一入口(主代理行 = 当前 root 的运行中专家/最新分片;空闲分组标题行 = ready 空闲成员)。
3466
+ *
3467
+ * 与单会话箭头共用的三条并发与生命周期边界:
3468
+ * 1) 聚合锁覆盖整段 await 序列:双击/连击只允许第一次穿过,pending 期间按钮 disabled + aria-busy;
3469
+ * 锁按 (sidebar session, root) 组合键隔离,只拒绝**同一个 surface** 的重复点击——别的
3470
+ * root/session 的批量按钮保持可用,绝不被一个与它无关的在途序列(甚至永不 settle 的
3471
+ * 宿主 Promise)阻塞;
3472
+ * 2) 候选集合在**点击那一刻**从同一个 inventory 快照固定下来(渲染期算好、随点击闭包传入),
3473
+ * 后续再点同一个按钮也不会把新出现的 tab 混进这一轮;
3474
+ * 3) 每次 await 前后都校验 mountedRef / currentRootRef / currentSidebarSessionRef:root 或
3475
+ * sidebar session 切换、组件卸载后立即停止后续关闭,绝不让旧点击继续操作新 surface。
3476
+ *
3477
+ * 单项失败严格隔离:close 同步抛错或返回 rejected Promise 只跳过这一项并继续其余项,
3478
+ * 也绝不把失败项标成已关闭——关闭记录一律交给宿主 inventory 与既有同步器确认(这里不删记录)。
3479
+ */
3480
+ const handleSidebarBatchClose = async (candidates) => {
3481
+ if (mountedRef.current !== true
3482
+ || currentRootRef.current !== rootSessionId
3483
+ || currentSidebarSessionRef.current !== sidebarSessionId) return;
3484
+ if (closeSidebarResource === null) return;
3485
+ if (Array.isArray(candidates) !== true || candidates.length === 0) return;
3486
+ // 锁只覆盖「这一个 surface」:同一个 scope 的重复点击被拦下,别的 root/session 的批量按钮
3487
+ // 完全不受影响(它的键不在集合里)。
3488
+ const scopeKey = sidebarBatchScopeKey(sidebarSessionId, rootSessionId);
3489
+ const pendingScopes = sidebarBatchPendingRef.current;
3490
+ if (pendingScopes.has(scopeKey) === true) return;
3491
+ pendingScopes.add(scopeKey);
3492
+ // 立刻进入 pending 态:按钮 disabled + aria-busy,第二次点击在 has 检查处被拦下。
3493
+ bump((value) => value + 1);
3494
+ try {
3495
+ for (const tabId of candidates) {
3496
+ // await 之前:root/session 切换或卸载即停止后续关闭。
3497
+ if (mountedRef.current !== true
3498
+ || currentRootRef.current !== rootSessionId
3499
+ || currentSidebarSessionRef.current !== sidebarSessionId) return;
3500
+ try {
3501
+ // 单项 await:同步抛错与 rejected Promise 都在这里被隔离,序列继续。
3502
+ await closeSidebarResource.call(sidebarRight, tabId);
3503
+ } catch { /* 单项失败只影响这一项:失败项绝不被标成已关闭,也不改写任何记录。 */ }
3504
+ // await 之后:surface 已切换时不再继续(已经发出的那一次关闭照旧由宿主结算)。
3505
+ if (mountedRef.current !== true
3506
+ || currentRootRef.current !== rootSessionId
3507
+ || currentSidebarSessionRef.current !== sidebarSessionId) return;
3508
+ }
3509
+ } finally {
3510
+ // 只释放自己那一个 scope:旧序列的收尾绝不改写别的 root/session 的锁。
3511
+ pendingScopes.delete(scopeKey);
3512
+ // 重渲染与同步只服务**自己这一轮渲染**的 surface:root/session 已经切换时它们会去操作
3513
+ // 新 surface(旧 scope 的 bump 会让新 root 白白重渲染、旧闭包的 sync 会去同步旧 root)。
3514
+ // 回到同一个 root/session(键相同)时仍照常收尾,按钮不会因为缺少重渲染而停在 disabled。
3515
+ if (mountedRef.current === true
3516
+ && currentRootRef.current === rootSessionId
3517
+ && currentSidebarSessionRef.current === sidebarSessionId) {
3518
+ bump((value) => value + 1);
3519
+ // 关闭序列结束后推进一次同步:候选集合与签名都按宿主 inventory 重新读取,
3520
+ // 真正被宿主关掉的 tab 由既有同步器(present/active/浮窗标题证据)结算,插件不抢先删记录。
3521
+ syncSidebarTabs();
3522
+ }
3523
+ }
3524
+ };
3525
+
3526
+ /**
3527
+ * 标题行批量控件(主代理行与空闲分组标题行共用)。
3528
+ *
3529
+ * 三态渲染:
3530
+ * - 候选 > 0 且 close 可用:与单会话箭头同款的双左尖括号按钮(30×20),
3531
+ * aria-label/title 说明会关闭几个 tab,type=button,preventDefault + stopPropagation 必须同时存在;
3532
+ * - inventory 已知且候选为 0:非交互蓝色实心圆(12px)占位,没有 button / hover / tabindex;
3533
+ * - inventory 不可读,或候选 > 0 但 close 不可用:什么都不渲染(未知态绝不假报「零」,
3534
+ * 「关不掉」也绝不退化成「零」)。
3535
+ *
3536
+ * 这里**不看 openResource**:批量关闭只读取公开 inventory 并调用 close(),不需要打开任何资源。
3537
+ * 以打开能力当门槛会让「宿主能关不能开」(或注入面只给了 close)的右栏整行失去控件。
3538
+ * 唯一的渲染能力门槛是左尖括号图标:缺它时连零占位也不渲染。
3539
+ *
3540
+ * 绝不使用 aria-pressed:这是批量命令,不是 toggle 状态。
3541
+ */
3542
+ const renderSidebarBatchSlot = (addresses, label) => {
3543
+ const state = sidebarBatchStateFor(addresses);
3544
+ if (state.kind === "none") return null;
3545
+ if (state.kind === "zero") {
3546
+ // 左箭头图标缺失时连零占位也不渲染:没有可用的批量按钮能力就不该出现「本行有批量入口」的暗示。
3547
+ if (typeof primitives?.IconChevronLeftOutlineRegular !== "function") return null;
3548
+ return react.createElement("div", { className: "deg_expert_group_trailing" },
3549
+ react.createElement("span", {
3550
+ className: "deg_expert_group_sidebar_batch_zero",
3551
+ "data-zero": "true",
3552
+ "aria-hidden": "true",
3553
+ }));
3554
+ }
3555
+ if (typeof primitives?.IconChevronLeftOutlineRegular !== "function") return null;
3556
+ const count = state.candidates.length;
3557
+ const accessibleName = "在侧边栏关闭 " + count + " 个" + label;
3558
+ // 只看**当前** scope 的锁:别的 root/session 的在途序列绝不影响这一行的按钮状态。
3559
+ const pending = sidebarBatchPendingRef.current.has(sidebarBatchScopeKey(sidebarSessionId, rootSessionId));
3560
+ return react.createElement("div", { className: "deg_expert_group_trailing" },
3561
+ react.createElement("button", {
3562
+ type: "button",
3563
+ className: "deg_expert_group_sidebar_batch_action",
3564
+ title: accessibleName,
3565
+ "aria-label": accessibleName,
3566
+ "aria-busy": pending || undefined,
3567
+ disabled: pending,
3568
+ onClick: (event) => {
3569
+ event?.preventDefault?.();
3570
+ event?.stopPropagation?.();
3571
+ // 候选集合固定为这次渲染算出的那一份:点击后 inventory 再变也不影响本轮。
3572
+ return handleSidebarBatchClose(state.candidates);
3573
+ },
3574
+ onKeyDown: (event) => { event?.stopPropagation?.(); },
3575
+ }, react.createElement("span", {
3576
+ className: "deg_expert_group_double_arrow",
3577
+ "data-direction": "close",
3578
+ "aria-hidden": "true",
3579
+ },
3580
+ makeIcon(primitives, "IconChevronLeftOutlineRegular", { size: 20, className: "deg_expert_group_double_arrow_icon", "aria-hidden": true }),
3581
+ makeIcon(primitives, "IconChevronLeftOutlineRegular", { size: 20, className: "deg_expert_group_double_arrow_icon", "aria-hidden": true }))));
3582
+ };
3583
+
2794
3584
  /**
2795
3585
  * 标题后「在右侧栏打开 / 关闭」箭头按钮:与 DSH 原生子代理行同款箭头图标。
2796
3586
  *
@@ -2920,10 +3710,19 @@
2920
3710
  const metadata = metadataOf(source);
2921
3711
  const taskClassName = "deg_expert_group_line deg_expert_group_task"
2922
3712
  + (oneLine ? "" : " deg_expert_group_shard_task");
3713
+ const trailing = options.trailing === undefined ? null : options.trailing;
3714
+ const taskChildren = [
3715
+ react.createElement("div", { className: taskClassName, title: fullName, onClick: onNavigate, ...navigateProps }, fullName),
3716
+ renderSidebarOpenAction(address, fullName),
3717
+ ];
3718
+ if (trailing !== null) taskChildren.push(trailing);
3719
+ const taskLine = react.createElement("div", {
3720
+ className: "deg_expert_group_task_line" + (trailing === null ? "" : " deg_expert_group_task_line_trailing"),
3721
+ }, ...taskChildren);
3722
+ // 主代理批量控件作为标题行的相邻 flex 项,标题收缩省略时控件仍固定在标题末尾。
3723
+ // 其它专家 / 分片 / 空闲成员不传 trailing,单会话箭头结构保持不变。
2923
3724
  return react.createElement("div", { className: "deg_expert_group_row_main", "data-kind": source.kind || "expert", "data-expanded": true },
2924
- react.createElement("div", { className: "deg_expert_group_task_line" },
2925
- react.createElement("div", { className: taskClassName, title: fullName, onClick: onNavigate, ...navigateProps }, fullName),
2926
- renderSidebarOpenAction(address, fullName)),
3725
+ taskLine,
2927
3726
  react.createElement("div", { className: "deg_expert_group_meta", title: metadata }, metadata),
2928
3727
  react.createElement("div", { className: "deg_expert_group_line deg_expert_group_content", title: summary, onClick: onNavigate, ...summaryProps }, summary));
2929
3728
  };
@@ -2956,16 +3755,24 @@
2956
3755
  * 空闲分组是容器而非代理:它的圆点直接用规范值 ready(绿色静态圆点),
2957
3756
  * 分组内成员各自按官方目录证据决定 running/ready。
2958
3757
  */
2959
- const renderIdleGroup = (nodes) => {
2960
- if (!Array.isArray(nodes) || nodes.length === 0) return null;
2961
- const group = { kind: "idle-group", id: rootSessionId + "__idle__", taskName: "空闲子代理", status: "idle", children: nodes };
3758
+ const renderIdleGroup = (nodes, trailing) => {
3759
+ // 空列表同样要渲染:snapshot 已经加载(调用方只在有快照时调用)时「空闲子代理(0)」与零圆点
3760
+ // 是用户看得到的零状态;加载中 / 没有快照时调用方根本不会走到这里,绝不伪造零。
3761
+ const members = Array.isArray(nodes) ? nodes : [];
3762
+ const group = { kind: "idle-group", id: rootSessionId + "__idle__", taskName: "空闲子代理", status: "idle", children: members };
2962
3763
  const open = expand(group);
3764
+ const idleTitleChildren = [
3765
+ react.createElement("div", { className: "deg_expert_group_line deg_expert_group_task" }, "空闲子代理(" + members.length + ")"),
3766
+ ];
3767
+ // 空闲分组的批量候选只来自当前 idleChildren(ready 直接专家);active 成员保留。
3768
+ // slot 与计数标题同属第三列的 inline flex,标题按可用宽度省略,slot 不会被推到行尾。
3769
+ if (trailing !== undefined && trailing !== null) idleTitleChildren.push(trailing);
2963
3770
  return react.createElement("div", { className: "deg_expert_group_idle_group", "data-expanded": open },
2964
3771
  react.createElement("div", { className: "deg_expert_group_row deg_expert_group_idle_header", "data-status": "ready", "data-expanded": open },
2965
3772
  statusIcon(react, primitives, "ready"),
2966
3773
  renderChevron(group, open),
2967
- react.createElement("div", { className: "deg_expert_group_line deg_expert_group_task" }, "空闲子代理(" + nodes.length + ")")),
2968
- open ? react.createElement("div", { className: "deg_expert_group_children deg_expert_group_idle_children" }, nodes.map((node, index) => renderExpert(node, index, rootSessionId))) : null);
3774
+ 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);
2969
3776
  };
2970
3777
 
2971
3778
  const pendingSet = typeof react.useRef === "function" ? react.useRef(new Set()) : { current: new Set() };
@@ -3093,8 +3900,13 @@
3093
3900
  // 默认折叠依据展示层的 running/ready,而不是 Host 内部的旧任务 status。
3094
3901
  // 这样 status=unknown 但官方目录 activity=inactive 的专家也会进入空闲分组;
3095
3902
  // fan-out 仍只在自己的取消/重试逻辑中读取 statusOf(task)。
3096
- const idleChildren = children.filter((node) => node.kind === "expert" && runtimeStatusOfNode(node, rootSessionId, text(node.sessionId, null)) === "ready");
3903
+ const idleChildren = sidebarIdleChildren();
3097
3904
  const visibleChildren = children.filter((node) => !idleChildren.includes(node));
3905
+ // 两行标题后的批量 slot 内容:主代理行只覆盖运行中的普通专家/最新分片,空闲标题行只覆盖
3906
+ // idleChildren。两者都只在渲染期纯读 openTabs inventory 与 close 能力(inventory 读不出来、
3907
+ // 或候选存在但 close 缺失时返回 null,不渲染任何东西);打开能力不参与这里的判定。
3908
+ const rootBatchSlot = renderSidebarBatchSlot(runningSidebarResourceAddresses(), "运行中的子代理");
3909
+ const idleBatchSlot = renderSidebarBatchSlot(idleSidebarResourceAddresses(idleChildren), "空闲子代理");
3098
3910
  const root = snapshot?.root;
3099
3911
  const rootOpen = root === null ? true : expand(root);
3100
3912
  const body = viewState.loading && snapshot === null
@@ -3103,10 +3915,10 @@
3103
3915
  ? react.createElement("div", { className: "deg_expert_group_empty" }, "暂无 Expert Group 任务")
3104
3916
  : react.createElement("div", { className: "deg_expert_group_tree" },
3105
3917
  react.createElement("div", { className: "deg_expert_group_node deg_expert_group_node_root", "data-root": "true" },
3106
- react.createElement("div", { className: "deg_expert_group_row", "data-status": runtimeStatusOf(root, null), "data-expanded": true }, statusIcon(react, primitives, runtimeStatusOf(root, null)), renderChevron(root, rootOpen), react.createElement("div", null, renderThreeLines(root, { sessionId: root.sessionId, parentId: null, expanded: true }))),
3918
+ react.createElement("div", { className: "deg_expert_group_row", "data-status": runtimeStatusOf(root, null), "data-expanded": true }, statusIcon(react, primitives, runtimeStatusOf(root, null)), renderChevron(root, rootOpen), react.createElement("div", null, renderThreeLines(root, { sessionId: root.sessionId, parentId: null, expanded: true, trailing: rootBatchSlot }))),
3107
3919
  rootOpen ? react.createElement("div", { className: "deg_expert_group_children" }, [
3108
3920
  ...visibleChildren.map((node, index) => node.kind === "fanout" ? renderFanout(node, index) : renderExpert(node, index, rootSessionId)),
3109
- renderIdleGroup(idleChildren),
3921
+ renderIdleGroup(idleChildren, idleBatchSlot),
3110
3922
  ]) : null));
3111
3923
  return react.createElement("section", { className: "deg_expert_group", "data-version": version, "aria-label": "Expert Group" },
3112
3924
  react.createElement("header", { className: "deg_expert_group_header" },
@@ -3939,6 +4751,14 @@
3939
4751
  * B 激活时一定会写自己的 20%,两者互不覆盖。
3940
4752
  */
3941
4753
  const rightbarWidths = new Map();
4754
+ /**
4755
+ * sessionId → 上次成功写入宽度时所用的**视口宽度**(px)。
4756
+ *
4757
+ * 与 rightbarWidths 同为 per-session 的目标值记账(同样只在 install 生命周期内有效、
4758
+ * dispose 一并清空),但记的是「那份像素值是按哪个视口算出来的」。窗口最大化 / 全屏后
4759
+ * 视口变化,这份记账就是常驻同步路径发现「必须重算」的判据(见 rightbarWidthViewportChanged)。
4760
+ */
4761
+ const rightbarWidthViewports = new Map();
3942
4762
  /**
3943
4763
  * 最近一次成功写入宽度的 surface 会话:宿主那份全局宽度当前归谁。
3944
4764
  *
@@ -3964,6 +4784,58 @@
3964
4784
  const rightbarWidthPreparedSessions = new Set();
3965
4785
  /** 最近一次被公开 active() 证明确实处于 Expert Group 页、且宽度已经落到宿主的会话集合。 */
3966
4786
  const rightbarWidthActiveSessions = new Set();
4787
+ /**
4788
+ * 同一个「未收敛的宽度 mismatch」的恢复写入记账:sessionId → { target, actualWidth, attempts }。
4789
+ *
4790
+ * 只记**恢复边沿**(宿主夺回了那份 px)的写入,不记首次接管 / 从其它分页返回 Expert Group /
4791
+ * 分栏事务的 50% 与收口 20% 这些正常写入;因此它既不会挡住正常写入,也不会让正常轮询把预算
4792
+ * 重置(见 RIGHTBAR_WIDTH_RECOVERY_MAX_ATTEMPTS)。
4793
+ *
4794
+ * 事件同一性由 (target, 观测到的实际宽度) 决定,其中 target 是**按当前视口算出来的预期值**
4795
+ * (不是「上次成功写入的值」,见 recoverRightbarWidth):
4796
+ * - 没有记账、预期 target 变了、或观测到的实际宽度变了 → 新的一次 mismatch 事件,重新获得完整预算;
4797
+ * - 三者都相同 → 同一个未收敛事件,预算用尽后一律拒绝写入。
4798
+ *
4799
+ * 「预期 target 变了」必须用当前视口重算:宿主持续 clamp 时视口挪一两个像素、20% 取整后仍是同一个
4800
+ * px,那不是新事件;只有窗口真正最大化到取整结果改变才是一次新事件。
4801
+ */
4802
+ const rightbarWidthRecoveries = new Map();
4803
+ /** 清除某个会话的恢复写入记账(收敛 / 所有权周期变化 / dispose 共用)。 */
4804
+ const clearRightbarWidthRecovery = (sessionId) => {
4805
+ if (sessionId === null || sessionId === undefined) return;
4806
+ rightbarWidthRecoveries.delete(sessionId);
4807
+ };
4808
+ /**
4809
+ * 恢复边沿的**有界预算**判定:返回 true 表示这一次「宿主夺回了宽度」的恢复写入允许执行。
4810
+ *
4811
+ * 只在恢复边沿调用(统一入口是 recoverRightbarWidth:recomputeOwnedWidth 的 hostReclaimed 分支
4812
+ * 与 confirmed 分支的同一判据),因此首次接管、从其它分页返回、分栏事务的 50% / 收口 20% 等
4813
+ * 正常写入完全不受影响。
4814
+ *
4815
+ * target 参数必须是**按当前视口算出来的预期值**(`Math.round(视口宽度 × 比例)`),不能传「上次
4816
+ * 成功写入的值」:宿主持续 clamp 时,视口 3200 → 3201 → 3202 的预期值都是 640,用预期值做事件
4817
+ * 同一性,这些视口变化才会被正确地归入同一个未收敛事件、不再重新获得预算。
4818
+ *
4819
+ * 预算在**调用时**消耗(而不是写入成功后):写入本身几乎不会失败,而「尝试过就计数」能保证
4820
+ * 即使宿主每次都把写入吞掉,也不会出现无限重试——这正是本预算要挡住的形状。
4821
+ */
4822
+ const allowRightbarRecoveryWrite = (sessionId, target, actualWidth) => {
4823
+ if (sessionId === null || sessionId === undefined) return false;
4824
+ if (typeof target !== "number" || Number.isFinite(target) !== true) return false;
4825
+ if (typeof actualWidth !== "number" || Number.isFinite(actualWidth) !== true) return false;
4826
+ const recorded = rightbarWidthRecoveries.get(sessionId);
4827
+ const sameEvent = recorded !== undefined
4828
+ && recorded.target === target
4829
+ && Math.abs(recorded.actualWidth - actualWidth) <= RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX;
4830
+ if (sameEvent !== true) {
4831
+ // 新的一次 mismatch 事件(首次观测 / target 变了 / 观测到的实际宽度变了)。
4832
+ rightbarWidthRecoveries.set(sessionId, { target, actualWidth, attempts: 1 });
4833
+ return true;
4834
+ }
4835
+ if (recorded.attempts >= RIGHTBAR_WIDTH_RECOVERY_MAX_ATTEMPTS) return false;
4836
+ recorded.attempts += 1;
4837
+ return true;
4838
+ };
3967
4839
  /** 读取某个会话当前的宽度模式;没有已确认模式时返回 null。 */
3968
4840
  const widthModeOf = (sessionId) => (sessionId === null || sessionId === undefined
3969
4841
  ? null
@@ -3971,8 +4843,17 @@
3971
4843
  /** 写入(mode 非空)或清除(mode 为 null)某个会话的宽度模式。 */
3972
4844
  const setWidthMode = (sessionId, mode) => {
3973
4845
  if (sessionId === null || sessionId === undefined) return;
3974
- if (mode === null || mode === undefined) rightbarWidthModes.delete(sessionId);
3975
- else rightbarWidthModes.set(sessionId, mode);
4846
+ const previous = rightbarWidthModes.get(sessionId) ?? null;
4847
+ const next = mode === null || mode === undefined ? null : mode;
4848
+ if (next === null) rightbarWidthModes.delete(sessionId);
4849
+ else rightbarWidthModes.set(sessionId, next);
4850
+ // 模式(= 宽度所有权周期)**发生变化**时,恢复事件记账随之失效:新周期里的第一次 mismatch
4851
+ // 是新事件,重新获得完整预算。两条刻意收紧的边界:
4852
+ // - 只在**已有非空模式**被换掉(expert ↔ other、或交还成 null)时清除。null → expert 是
4853
+ // 「首次确认这一份 expert 所有权」,恢复记账仍属于同一个周期,不能被重置;
4854
+ // - 无条件清除会让 confirmed 分支每一轮同步都重置预算(它每轮都用同一个值调用本函数),
4855
+ // 正常轮询就能无限重写——正是本预算要挡住的形状。
4856
+ if (previous !== null && previous !== next) clearRightbarWidthRecovery(sessionId);
3976
4857
  };
3977
4858
  /** 某个会话是否刚被 openTargets 预置过宽度(同一同步栈内不重复写)。 */
3978
4859
  const isWidthPrepared = (sessionId) => sessionId !== null && sessionId !== undefined
@@ -3997,6 +4878,8 @@
3997
4878
  setWidthMode(sessionId, null);
3998
4879
  setWidthPrepared(sessionId, false);
3999
4880
  setWidthActive(sessionId, false);
4881
+ // 模式/所有权周期结束时,这个会话的「恢复事件」记账一并失效:下一次 mismatch 是新事件。
4882
+ clearRightbarWidthRecovery(sessionId);
4000
4883
  };
4001
4884
  /**
4002
4885
  * 把宿主那份全局宽度的归属迁移到 sessionId(每次成功写入后调用)。
@@ -4061,37 +4944,274 @@
4061
4944
  * 视口宽度变化导致同一会话的目标 px 变化时会重写一次。
4062
4945
  *
4063
4946
  * 20% 是精确比例:插件不做任何最小值 clamp(宿主 setRightbar 自己有 clamp,它才是权威边界)。
4947
+ *
4948
+ * 写入前会 best-effort 同步宿主自己那份视口缓存:宿主 clamp 用的是 AppFrame 根元素宽度,
4949
+ * 冷启动时它可能还停在旧值,会把目标宽度夹小(2560 视口写 1280,宿主按 1280 缓存夹成 896),
4950
+ * 而插件的记账已认为「这个视口写过」,偏差不会自愈。同步值只取**能唯一、可靠读到的 AppFrame
4951
+ * 宽度**(见 hostAppFrameWidthOf),绝不拿 window.innerWidth 顶替;读不到就完全跳过同步。
4952
+ * 目标像素值仍按 window.innerWidth 的比例换算,本次已读取的同一个视口宽度贯穿始终。
4953
+ *
4064
4954
  * 兼容:layout 服务缺失、服务代理读属性抛错、setRightbar 不是函数或调用抛错时一律返回 false
4065
4955
  * 且**不记账**——写入失败不算应用,后续通知仍可重试,宿主自己的默认宽度继续生效。
4956
+ * 可选的视口同步 action 缺失、读属性抛错或调用抛错都不阻断既有 setRightbar 路径。
4066
4957
  */
4067
4958
  const applyRightbarWidth = (sessionId, ratio = RIGHTBAR_WIDTH_RATIO, force = false) => {
4068
4959
  if (sessionId === null || sessionId === undefined) return false;
4069
4960
  // 视口宽度读不到(无 DOM 环境)就没有可换算的比例:放弃写入,保留宿主当前值。
4070
- const viewportWidth = Number(globalThis.window?.innerWidth ?? globalThis.innerWidth);
4071
- if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) return false;
4961
+ const viewportWidth = viewportWidthOf();
4962
+ if (viewportWidth === null) return false;
4072
4963
  const normalizedRatio = Number(ratio);
4073
4964
  if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) return false;
4074
4965
  const target = Math.round(viewportWidth * normalizedRatio);
4075
4966
  if (force !== true && rightbarWidths.get(sessionId) === target) return false;
4076
4967
  const layout = config.layout || optionalService("layout");
4077
- let setRightbar = null;
4078
- try {
4079
- const candidate = layout?.panels?.setRightbar;
4080
- if (typeof candidate === "function") setRightbar = candidate;
4081
- } catch { /* Cordis 服务代理对未声明属性会抛错:按「宿主没有这个 action」处理。 */ }
4968
+ // panels 与两个 action 的属性读取必须**逐项独立隔离**:可选的 setViewportWidth 读属性抛错
4969
+ // (Cordis 服务代理对未声明属性就会抛)绝不能把必要的 setRightbar 一起挡掉。
4970
+ let panels = null;
4971
+ try { panels = layout?.panels ?? null; } catch { panels = null; }
4972
+ const setRightbar = methodOf(panels, "setRightbar");
4082
4973
  if (setRightbar === null) return false;
4974
+ const setViewportWidth = methodOf(panels, "setViewportWidth");
4975
+ // 宿主 setRightbar 按 layout store 的 viewportWidth 做上限 clamp,而那份缓存来自 AppFrame 根
4976
+ // 元素宽度(冷启动时可能还停在旧值,把目标宽度夹小)。只有能唯一、可靠读到 AppFrame 宽度时
4977
+ // 才先把它同步给宿主;读不到 AppFrame、尺寸非法时**不同步、不猜测宿主尺寸**,直接走原有写入。
4978
+ const appFrameWidth = hostAppFrameWidthOf();
4979
+ if (appFrameWidth !== null) {
4980
+ try { setViewportWidth?.call(panels, appFrameWidth); } catch { /* 校准不是展示侧栏的前置条件,失败时保留原有写入兼容路径。 */ }
4981
+ }
4083
4982
  try {
4084
4983
  // bound action 是闭包,不依赖 this;仍显式带上接收者,兼容将来改成方法式绑定。
4085
- setRightbar.call(layout.panels, target);
4984
+ setRightbar.call(panels, target);
4086
4985
  } catch {
4087
4986
  // 调用失败不算写入成功:保持未记账(后续通知仍可重试),宿主当前宽度继续生效。
4088
4987
  return false;
4089
4988
  }
4090
4989
  rightbarWidths.set(sessionId, target);
4990
+ // 连同「这次换算用的视口宽度」一起记账:它才是「宿主那份像素值是否还对应当前视口」的判据,
4991
+ // 也是常驻同步路径发现窗口最大化 / 全屏 / 拖拽后必须重新写入的唯一来源。
4992
+ rightbarWidthViewports.set(sessionId, viewportWidth);
4091
4993
  // 写入成功即接管宿主那份全局宽度:旧会话的 transient 记账随归属一起失效(见 claim 说明)。
4092
4994
  claimRightbarWidthOwnership(sessionId);
4093
4995
  return true;
4094
4996
  };
4997
+ /**
4998
+ * 某个会话「上次写入宽度时用的视口宽度」是否已经变了(窗口最大化 / 全屏 / 拖拽 / 旋转屏幕)。
4999
+ *
5000
+ * 右栏宽度是视口比例,视口一变,之前写下的像素值就不再代表 20% / 50%:必须按新视口重新
5001
+ * 换算并写入。这是常驻同步路径**唯一**的重新写入判据——同一视口下的重复通知仍然一个字节
5002
+ * 都不写(保留「宿主里那份值可能是用户拖拽结果」的原有去重语义)。
5003
+ *
5004
+ * 没有写过宽度(没有记账)时返回 false:那种情况由「首次接管 / 从其它分页返回」等既有
5005
+ * 分支负责写入,不在这里重复承担。
5006
+ */
5007
+ const rightbarWidthViewportChanged = (sessionId) => {
5008
+ if (sessionId === null || sessionId === undefined) return false;
5009
+ const recorded = rightbarWidthViewports.get(sessionId);
5010
+ if (recorded === undefined) return false;
5011
+ const viewportWidth = viewportWidthOf();
5012
+ if (viewportWidth === null) return false;
5013
+ return recorded !== viewportWidth;
5014
+ };
5015
+ /**
5016
+ * 读当前会话对应右栏面板的**实际渲染宽度**(px)。
5017
+ *
5018
+ * 这是「宿主里那份宽度到底是谁写的」唯一可验证的公开证据:只读
5019
+ * [data-sidebar-right-panel] 与 getBoundingClientRect(),不注册 class、不读任何私有状态。
5020
+ *
5021
+ * 一律 fail-closed(返回 null):面板归属无法精确解析(零个 / 多个 / 缺
5022
+ * data-sidebar-right-session)、没有 getBoundingClientRect、矩形读不出来、宽度不是有限正数
5023
+ * (null / 0 / NaN / 负值 / 非数字)——这些都不构成「宽度等于某个值」的证据,调用方必须保留
5024
+ * 宿主当前值,等后续可验证的同步。
5025
+ */
5026
+ const readRightbarPanelWidth = (sessionId) => {
5027
+ if (typeof sessionId !== "string" || sessionId === "") return null;
5028
+ let panel = null;
5029
+ try { panel = rightbarPanelForSession(globalThis.document, sessionId); } catch { return null; }
5030
+ if (panel === null || panel === undefined) return null;
5031
+ let rect = null;
5032
+ try {
5033
+ rect = typeof panel.getBoundingClientRect === "function" ? panel.getBoundingClientRect() : null;
5034
+ } catch { return null; }
5035
+ if (rect === null || typeof rect !== "object") return null;
5036
+ let width = null;
5037
+ try { width = Number(rect.width); } catch { return null; }
5038
+ if (Number.isFinite(width) !== true || width <= 0) return null;
5039
+ return width;
5040
+ };
5041
+ /**
5042
+ * 宿主里那份右栏宽度是否**仍是插件上一次成功写入的 target**(见 rightbarWidths)。
5043
+ *
5044
+ * 为什么需要它:other 模式的宽度是**可调**的,用户在其它右侧分页上拖出的宽度由宿主持有。
5045
+ * 只有「面板实际渲染宽度 == 插件上次写的 px」才能证明宿主里那份值仍是插件写的(或用户恰好
5046
+ * 拖回了同一个值,等价于没有改动),此时按模式重算视口才不会覆盖用户的拖拽结果。
5047
+ *
5048
+ * 任何一环读不出来(没有上次 target / 面板宽度读不到 / 宽度对不上)都返回 false:宁可这一次
5049
+ * 不重算,也绝不猜测写入——宿主当前值原样保留,后续 confirmed / other 分支仍会按证据重估。
5050
+ */
5051
+ const hostWidthMatchesPluginTarget = (sessionId) => {
5052
+ const ownership = rightbarOwnershipOf(sessionId);
5053
+ return ownership !== null && ownership.matches === true;
5054
+ };
5055
+ /**
5056
+ * 一次性读出「宿主那份宽度 vs 插件 target」的**所有权观测**;读不出来时返回 null(fail-closed)。
5057
+ *
5058
+ * 返回 { target, actualWidth, matches, differs }:
5059
+ * - matches:面板实际渲染宽度仍等于插件上次写入的 target(所有权仍在插件手里);
5060
+ * - differs:面板实际渲染宽度明确不等于 target(宿主夺回了那份 px)。
5061
+ *
5062
+ * 为什么合成一个函数:同一轮同步里既要判 matches 又要判 differs,还会消费恢复预算,分开调用会
5063
+ * 把同一份 DOM 证据读两遍(readRightbarPanelWidth → getBoundingClientRect 会触发布局)。
5064
+ * 任何一环读不出来(没有 target / 面板定位不到 / 宽度不是有限正数)一律返回 null——读不出来就
5065
+ * 不构成任何证据,调用方必须原样保留宿主当前值。
5066
+ */
5067
+ const rightbarOwnershipOf = (sessionId) => {
5068
+ const target = rightbarWidths.get(sessionId);
5069
+ if (typeof target !== "number" || Number.isFinite(target) !== true) return null;
5070
+ const actualWidth = readRightbarPanelWidth(sessionId);
5071
+ if (actualWidth === null) return null;
5072
+ const delta = Math.abs(actualWidth - target);
5073
+ return {
5074
+ target,
5075
+ actualWidth,
5076
+ matches: delta <= RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX,
5077
+ differs: delta > RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX,
5078
+ };
5079
+ };
5080
+ /**
5081
+ * 消费一次「所有权观测」:观测到收敛(实际宽度 == target)就清掉恢复事件记账。
5082
+ *
5083
+ * 这正是「状态清除」的唯一判据:宿主终于把 target 提交到 DOM 上之后,下一次 mismatch 必须是
5084
+ * **新的一次恢复事件**(重新获得完整预算),而不是被上一次已经用尽的预算永久挡住。
5085
+ * 读不出观测(null)时什么都不做——没有证据就不清除,保持原有记账。
5086
+ */
5087
+ const settleRightbarRecovery = (sessionId, ownership) => {
5088
+ if (ownership !== null && ownership.matches === true) clearRightbarWidthRecovery(sessionId);
5089
+ };
5090
+ /**
5091
+ * 恢复 / 视口重写的**统一入口**:所有「实际宽度明确偏离当前视口预期 target」的写入都走这里。
5092
+ *
5093
+ * 为什么必须统一:恢复写入的判据散落在两条分支上(confirmed 分支与 recomputeOwnedWidth 的
5094
+ * unknown 分支)时,只要其中一条把「视口变化」当成**独立**边沿、不消费预算,宿主持续 clamp 的
5095
+ * 场景就能被无限绕过——视口从 3200 挪到 3201 / 3202,20% 取整后仍是同一个 target 640,
5096
+ * 每一次视口变化都变成「一次新的恢复事件」,用户轻微拖动窗口边缘就会与宿主的 clamp 无限拉锯。
5097
+ * 因此两条分支共用这一份判定(per-session,按预期 target + 观测到的实际宽度记账)。
5098
+ *
5099
+ * 三条口径:
5100
+ * - **没有公开 DOM 证据(ownership 为 null:面板定位不到,或 getBoundingClientRect().width
5101
+ * 不是有限正数)就是不写**:恢复 / 视口重算入口一律 fail-closed,一个字节都不写,既不消费
5102
+ * mismatch 预算,也绝不新建 / 重置一次 mismatch 事件。右栏宽度是宿主 layout store 里的**全局**
5103
+ * 偏好,插件只在能证明「这份 px 现在是谁的」时才允许改写它;读面不可用期间没有任何证据说明
5104
+ * 面板宽度偏离了 target,写下去就是无据改写宿主状态(而且是每次视口变化 / 通知 / 轮询都写的
5105
+ * 无界改写)。宿主那份值原样保留,等后续**可验证**的同步(readRightbarPanelWidth 恢复可读)——
5106
+ * 这条收紧只作用于恢复 / 视口重算入口,首次接管、从其它分页返回、分栏事务收口等正常写入
5107
+ * 语义完全不受影响;
5108
+ * - 实际宽度已经等于**当前视口下预期**的 target:这不是 mismatch,只是视口记账需要更新(例如
5109
+ * 宿主已经自己落到了正确宽度),正常写一次即可,不消费预算;
5110
+ * - 实际宽度明确偏离预期 target:同一个 (预期 target, 实际宽度) 的写入共用 per-session 预算,
5111
+ * 用尽后一律拒绝;预期 target 真正改变(窗口最大化导致取整结果变化)时是一次**新事件**,
5112
+ * 重新获得完整预算。
5113
+ *
5114
+ * 调用方负责「该不该尝试写入」的边沿判定(视口变化 / 宿主夺回宽度 / 模式证据),这里只负责
5115
+ * 「这次写入允不允许」:不读任何私有状态、不新增轮询或定时器。
5116
+ */
5117
+ const recoverRightbarWidth = (sessionId, ratio, ownership) => {
5118
+ if (sessionId === null || sessionId === undefined) return false;
5119
+ // 没有任何公开 DOM 证据:直接 fail-closed。这里刻意不调用 applyRightbarWidth(更不带 force),
5120
+ // 因此既不会写宿主宽度,也不会触碰恢复预算记账(不新建、不重置、不消费)。
5121
+ if (ownership === null || ownership === undefined) return false;
5122
+ // 预期 target 必须与 applyRightbarWidth 用同一套换算(同一视口宽度 × 同一比例、同样取整),
5123
+ // 否则事件同一性会把「同一份预期值」判成两次不同事件。
5124
+ const viewportWidth = viewportWidthOf();
5125
+ if (viewportWidth === null) return false;
5126
+ const expectedTarget = Math.round(viewportWidth * ratio);
5127
+ if (Math.abs(ownership.actualWidth - expectedTarget) <= RIGHTBAR_WIDTH_OWNERSHIP_TOLERANCE_PX) {
5128
+ // 实际宽度已经等于预期 target:只是视口记账需要更新,不属于 mismatch,不消费预算。
5129
+ return applyRightbarWidth(sessionId, ratio, true) === true;
5130
+ }
5131
+ if (allowRightbarRecoveryWrite(sessionId, expectedTarget, ownership.actualWidth) !== true) return false;
5132
+ return applyRightbarWidth(sessionId, ratio, true) === true;
5133
+ };
5134
+ /**
5135
+ * 组成证据为 unknown 时,按**本会话已经证明过的宽度模式**重算「视口变化 / 宿主夺回宽度」。
5136
+ *
5137
+ * 两条触发边沿(都只作用于 expert 模式,见下面的 other 分支说明):
5138
+ * 1. 视口宽度变了(窗口最大化 / 全屏 / 拖拽窗口边缘):那份 px 不再代表原来的比例;
5139
+ * 2. **宿主夺回了那份 px**(面板实际渲染宽度 != 插件上次写入的 target):DSH 重启后宿主从
5140
+ * 持久化布局恢复它自己那份旧值的时序正是「视口没变但那份值已经不是插件的」,
5141
+ * 只按视口判据永远等不到重算(见 rightbarOwnershipOf 的 differs)。
5142
+ *
5143
+ * 为什么「面板宽度 != 插件 target」就足以证明宿主夺回了所有权(只在 expert 模式消费):
5144
+ * Expert Group 页的手柄由插件锁住(rightbarDragLock),用户在这一页**拖不动**右栏;而 20%
5145
+ * 又是插件独占的固定比例,不存在「用户在该页自定义宽度」的语义。因此这一页上面板宽度偏离
5146
+ * target 只有两种可能:宿主自己重渲染回了它那份持久化值,或用户恰好拖到了同一个值(等价于
5147
+ * 没有改动)。两种情形都应当把 20% 写回去——但**必须有界**(见 recoverRightbarWidth)。
5148
+ *
5149
+ * 为什么 unknown 仍然可以重算:组成读面(inventory / DOM)回答的是「当前右栏里有哪些页」,
5150
+ * 而这里消费的是本会话**自己**的宽度记账——它记的是「宿主那份 px 是按哪个比例、哪个视口
5151
+ * 写下去的」,与组成读面无关。视口一变(窗口最大化 / 全屏 / 拖拽窗口边缘),那份 px 就不再
5152
+ * 代表原来的比例(实测:1600 视口写 320,最大化到 3200 后仍是 320,而正确值是 640),因此
5153
+ * 必须按同一比例重算;否则读面暂不可用的宿主会一直停在过期宽度上。
5154
+ *
5155
+ * fail-closed 边界(绝不放松):
5156
+ * - 没有任何模式证据(mode 为空且 prepared/active 都没有)时返回 false,一个字节都不写;
5157
+ * 组成读面不可用时**绝不臆测** 20% 或 50%,也绝不把 unknown 当成 confirmed;
5158
+ * - **没有宽度 DOM 证据(面板宽度读不出来)时同样一个字节都不写**:expert 模式虽然不要求
5159
+ * 「面板宽度仍等于插件 target」(那是 other 的可调保护),但**要求这条读面本身可用**——
5160
+ * ownership 为 null 时 recoverRightbarWidth 直接 fail-closed,既不写宽度也不动恢复预算
5161
+ * (见该函数第一条口径)。模式证据只回答「该写哪个比例」,不构成「可以无据改写宿主全局
5162
+ * 宽度」的证据;
5163
+ * - **other 模式额外要求宿主宽度所有权**:other 的 50% 是可调状态,用户可以在该页手动拖宽,
5164
+ * 仅凭「模式是 other」就重算会覆盖用户的拖拽结果。因此只有 hostWidthMatchesPluginTarget
5165
+ * 证明宿主里那份 px 仍是插件上次写的 target 时才重算;读不到或对不上一律不写(见该函数说明)。
5166
+ * expert 模式不受此限制:20% 是插件**独占**的固定比例,不存在「用户在该页拖宽」的语义;
5167
+ * - **other 模式只认视口变化这一条边沿**:宿主夺回宽度在 other 模式里正是「用户拖过一次」
5168
+ * 的典型形状,据此重算会覆盖用户的拖拽结果,因此那一模式绝不消费 hostReclaimed;
5169
+ * other 的视口重算还额外要求 hostWidthMatchesPluginTarget(实际宽度仍等于插件上次的 target),
5170
+ * 这条真实用户拖拽保护不受本预算逻辑影响,绝不放宽;
5171
+ * - expert 模式去重:视口没变、且宿主那份值仍等于插件 target 时什么都不做;
5172
+ * - 写入失败由 applyRightbarWidth 自己保证不记账,后续通知仍可重试;
5173
+ * - 手柄锁与组合归属语义完全不变:本函数不碰 rightbarDragLock,也不改写任何 transient 记账
5174
+ * (prepared / active 保持原样,因此下一次视口变化仍然有据可依)。
5175
+ */
5176
+ const recomputeOwnedWidth = (sessionId) => {
5177
+ if (sessionId === null || sessionId === undefined) return false;
5178
+ const viewportChanged = rightbarWidthViewportChanged(sessionId) === true;
5179
+ // expert 模式下还有第二条触发边沿:宿主已经夺回那份 px(面板宽度不再等于插件 target)。
5180
+ // DSH 重启后宿主恢复持久化旧值的时序正是「视口没变但那份值已经不是插件的」,只按视口判据
5181
+ // 永远等不到重算。一次面板读数同时给出 matches / differs 两份结论(见 rightbarOwnershipOf)。
5182
+ const ownership = rightbarOwnershipOf(sessionId);
5183
+ // 收敛(实际宽度 == target)即清除恢复事件记账:下一次 mismatch 必须是新事件。
5184
+ settleRightbarRecovery(sessionId, ownership);
5185
+ const hostReclaimed = ownership !== null && ownership.differs === true;
5186
+ if (viewportChanged !== true && hostReclaimed !== true) return false;
5187
+ const mode = widthModeOf(sessionId);
5188
+ // 只有已确认的模式才给出比例:expert = 20%,other = 50%。split-pending 不会走到这里
5189
+ // (在途事务在 rightbarDragSyncImpl 里更早 return,收口时也必然被改写成 expert/other/null),
5190
+ // 万一出现也一律不猜。
5191
+ let ratio = null;
5192
+ if (mode === "expert") {
5193
+ // 20% 是插件独占的固定比例:视口变化与「宿主夺回宽度」两条边沿都必须重算。
5194
+ ratio = RIGHTBAR_WIDTH_RATIO;
5195
+ } else if (mode === "other") {
5196
+ // other 是可调模式,**只认视口变化这一条边沿**:
5197
+ // - 宿主夺回宽度(面板宽度 != 插件 target)在 other 模式里恰恰是「用户拖过一次」的
5198
+ // 典型形状,绝不能据此重算,否则会把用户的拖拽结果覆盖成 50%;
5199
+ // - 视口变化本身也必须先证明所有权(面板宽度仍等于插件 target)才允许重算;
5200
+ // 读不到面板宽度时所有权不成立 → 一个字节都不写,用户的宽度原样保留。
5201
+ if (viewportChanged !== true) return false;
5202
+ if (ownership === null || ownership.matches !== true) return false;
5203
+ ratio = OTHER_RIGHTBAR_WIDTH_RATIO;
5204
+ } else if (mode === null && (isWidthPrepared(sessionId) || isWidthActive(sessionId))) {
5205
+ // prepared / active 只由**成功的 20% 写入**置位(openTargets 预置,或 confirmed 分支 /
5206
+ // 事务收口时按 20% 恢复成功),因此它们证明的模式就是 expert;这里绝不据此推断 50%。
5207
+ ratio = RIGHTBAR_WIDTH_RATIO;
5208
+ }
5209
+ if (ratio === null) return false;
5210
+ // 写入判据与 confirmed 分支**共用**同一份有界预算(见 recoverRightbarWidth):视口变化本身不再
5211
+ // 自动等于「可以再写一次」——取整后仍是同一个 target 的视口变化不是新的 mismatch 事件,
5212
+ // 否则宿主持续 clamp 时轻微拖动窗口边缘就能无限重写。
5213
+ return recoverRightbarWidth(sessionId, ratio, ownership) === true;
5214
+ };
4095
5215
  /**
4096
5216
  * 自动展开右侧栏:按 surface 会话隔离 + 异步延迟执行。
4097
5217
  *
@@ -4987,7 +6107,13 @@
4987
6107
  // (isActiveOnlySplitCompatible),那个例外必须单独判定,绝不与这里共用 fail-open。
4988
6108
  evidence = "confirmed";
4989
6109
  }
4990
- if (evidence === "unknown") return;
6110
+ if (evidence === "unknown") {
6111
+ // 组成不可判定 → 绝不改手柄锁、也绝不臆测模式(fail-closed 语义不变);但本会话
6112
+ // **已经接管过**的宽度模式与组成读面无关,视口变了(或宿主重启后夺回了那份 px)就必须按
6113
+ // 同一比例重算,否则窗口最大化后宿主会一直保留旧视口下的 px 偏好(见 recomputeOwnedWidth)。
6114
+ recomputeOwnedWidth(current);
6115
+ return;
6116
+ }
4991
6117
  if (evidence === "confirmed") {
4992
6118
  // openTargets 已在同一同步栈为这个 surface 成功预置过 20% 时,只消费 prepared 标记;
4993
6119
  // 否则这是从其它可调分页重新进入 Expert Group,必须强制恢复 20%。只有宽度已经成功
@@ -4999,6 +6125,33 @@
4999
6125
  if (!widthReady) {
5000
6126
  // 归属由写入路径接管(applyRightbarWidth → claimRightbarWidthOwnership)。
5001
6127
  widthReady = applyRightbarWidth(current, RIGHTBAR_WIDTH_RATIO, true);
6128
+ } else {
6129
+ const viewportChanged = rightbarWidthViewportChanged(current) === true;
6130
+ // 一次面板读数同时给出 matches / differs 两份结论(见 rightbarOwnershipOf)。
6131
+ const ownership = rightbarOwnershipOf(current);
6132
+ // 收敛(实际宽度 == target)即清除恢复事件记账:下一次 mismatch 必须是新事件。
6133
+ settleRightbarRecovery(current, ownership);
6134
+ const hostReclaimed = ownership !== null && ownership.differs === true;
6135
+ if (viewportChanged || hostReclaimed) {
6136
+ // 两条重新写入边沿,缺一不可:
6137
+ // 1. 窗口最大化 / 全屏 / 拖拽改变了视口:20% 是**视口比例**,必须按新视口重新换算并写入,
6138
+ // 否则右栏会一直停在最大化之前那份旧像素宽度上(1600 写 320,3200 仍是 320);
6139
+ // 2. **宿主夺回了那份 px**:DSH 重启后宿主从持久化布局恢复它自己那份旧值,而这份恢复
6140
+ // 晚于插件的首次写入(视口一直没变)。Expert Group 页的手柄被插件锁住、20% 又是插件
6141
+ // 独占比例,因此「面板宽度不再等于插件 target」就是宿主夺回所有权的可验证证据,必须
6142
+ // 把 20% 重新写回去,否则用户看到的是「重启后右栏从 640 缩到 320」。
6143
+ // 两条判据都为假时一个字节都不写:同一视口的重复通知仍然保持原有去重语义。
6144
+ //
6145
+ // 写入许可与 recomputeOwnedWidth(unknown 分支)**共用**同一份有界预算:视口变化不再是
6146
+ // 绕过预算的独立边沿——取整后仍是同一个 target 的视口变化(3200 → 3201 → 3202 都是 640)
6147
+ // 不是新的一次 mismatch 事件;只有预期 target 真的改变才重新获得预算。宿主持续 clamp 或
6148
+ // React 延迟提交时「实际宽度 != target」会一直为真,否则就会与宿主的 clamp 无限拉锯。
6149
+ //
6150
+ // 这条边沿同样要过证据关:面板宽度读不出来(ownership 为 null)时 recoverRightbarWidth
6151
+ // 直接 fail-closed——即使视口真的变了,这里也一个字节都不写(读面不可用期间没有任何证据
6152
+ // 说明面板宽度偏离了 target,写下去就是无据改写宿主那份**全局**宽度偏好)。
6153
+ recoverRightbarWidth(current, RIGHTBAR_WIDTH_RATIO, ownership);
6154
+ }
5002
6155
  }
5003
6156
  setWidthPrepared(current, false);
5004
6157
  setWidthActive(current, widthReady);
@@ -5015,10 +6168,19 @@
5015
6168
  // 第一次确认任一 pane 存在其它页时把总右栏预置到 50%;随后只交还手柄,用户拖动出的
5016
6169
  // 宽度由宿主持有。重复通知不会重写 50%(只按当前会话的 other 模式去重),宽度桥接失败
5017
6170
  // 则保持模式未提交,便于以后重试。
5018
- if (widthModeOf(current) !== "other") {
6171
+ const otherModeMissing = widthModeOf(current) !== "other";
6172
+ // 「首次确认其它页」这条边沿无条件预置 50%;视口变化触发的重算则**必须先证明宿主宽度所有权**
6173
+ // (面板实际宽度仍等于插件上次写入的 target)。50% 是可调状态,用户在其它右侧分页上拖出的
6174
+ // 宽度由宿主持有,仅凭「视口变了」就重算会直接覆盖用户的拖拽结果——这与 unknown 分支里的
6175
+ // 同一判据(见 recomputeOwnedWidth)保持完全一致的口径。
6176
+ const otherNeedsRecompute = otherModeMissing
6177
+ || (rightbarWidthViewportChanged(current) && hostWidthMatchesPluginTarget(current));
6178
+ if (otherNeedsRecompute) {
6179
+ // 写入失败时只在「首次确认其它页」这条边沿把模式置回未提交,视口重算失败不得抹掉
6180
+ // 已经确认的 other 模式。
5019
6181
  if (applyRightbarWidth(current, OTHER_RIGHTBAR_WIDTH_RATIO, true)) {
5020
6182
  setWidthMode(current, "other");
5021
- } else {
6183
+ } else if (otherModeMissing) {
5022
6184
  setWidthMode(current, null);
5023
6185
  }
5024
6186
  }
@@ -5322,6 +6484,49 @@
5322
6484
  }
5323
6485
  openTargets();
5324
6486
  }
6487
+ /**
6488
+ * 视口尺寸变化(窗口最大化 / 全屏 / 拖拽窗口边缘 / 旋转屏幕)后重新换算右栏宽度。
6489
+ *
6490
+ * 右栏宽度是**视口比例**(Expert Group 页 20%、其它右侧分页 50%),但写进宿主 layout store
6491
+ * 的是像素值。没有这条边沿,窗口最大化后宿主会一直沿用最大化之前那份旧像素宽度,比例不再
6492
+ * 成立(1600 视口写 320,最大化到 3200 后仍是 320,而正确值是 640)。
6493
+ *
6494
+ * 四条约束:
6495
+ * - 只读公开的 window 事件,且全程按**可选 API** 处理:没有 window、没有 addEventListener、
6496
+ * 代理读取抛错时静默降级为「只靠既有 observer + 2.5 秒轮询兜底」——那条链路同样会靠
6497
+ * 视口记账发现变化并补写(见 rightbarWidthViewportChanged),因此老宿主不会失效;
6498
+ * - **add 与 remove 必须同时存在**才注册:宿主只有 addEventListener 时(旧 WebView、被冻结
6499
+ * 的事件面),注册会成功而 dispose 的注销永远失败,那次 install 的闭包就被永久留在宿主
6500
+ * window 上——每装一次泄漏一个,卸载后仍会被 resize 事件唤醒。这种宿主整条放弃快路径,
6501
+ * 同样退回上面那条 observer + 2.5 秒轮询兜底,功能不缺失;
6502
+ * - 监听在 install 注册、dispose 移除(走统一的 disposers),绝不在宿主上留下长期监听;
6503
+ * - 高频 resize 只在**视口宽度真的变了**时才请求同步:同一宽度的连发事件直接短路,既不做
6504
+ * 无谓的组成读面求值,也让行为保持确定性(同步栈内立即生效,不需要推进任何定时器)。
6505
+ */
6506
+ let lastObservedViewportWidth = viewportWidthOf();
6507
+ const onViewportResize = () => {
6508
+ if (disposed) return;
6509
+ const viewportWidth = viewportWidthOf();
6510
+ if (viewportWidth === null) return;
6511
+ if (lastObservedViewportWidth === viewportWidth) return;
6512
+ lastObservedViewportWidth = viewportWidth;
6513
+ // 复用既有的宽度/手柄/配比同步链路:不新开一条并行路径,也不直接在这里写宽度。
6514
+ requestRightbarDragSync();
6515
+ };
6516
+ try {
6517
+ const viewportTarget = globalThis.window ?? null;
6518
+ // 注册条件必须**同时**要求 add 与 remove:宿主只有 addEventListener 时注册会成功、
6519
+ // dispose 的注销却永远失败,闭包被永久留在宿主 window 上(每次安装泄漏一个,且卸载后
6520
+ // 仍会被 resize 事件唤醒)。宁可少一条快路径,也绝不在宿主上留下摘不掉的监听——
6521
+ // 缺少任一 API 时完全依赖既有的 observer + 2.5 秒轮询兜底,它们同样按视口记账补写。
6522
+ if (typeof viewportTarget?.addEventListener === "function"
6523
+ && typeof viewportTarget?.removeEventListener === "function") {
6524
+ viewportTarget.addEventListener("resize", onViewportResize);
6525
+ disposers.push(() => {
6526
+ try { viewportTarget.removeEventListener("resize", onViewportResize); } catch { /* 注销抛错的宿主只能放弃这条监听。 */ }
6527
+ });
6528
+ }
6529
+ } catch { /* 没有 window 或代理读取抛错:只靠既有 observer 与 2.5 秒轮询兜底。 */ }
5325
6530
  // 到这里 install 函数体里的全部读面(readSurfaceOpenTabs / currentSurfaceSessionId /
5326
6531
  // rightbarDragSyncImpl)都已初始化完成:接线配比读面,后续每一轮同步都能安全求值。
5327
6532
  // 刻意排在 install 函数体的**最后**:任何一次同步(包括下面这次立即求值)都要等这些读面
@@ -5331,8 +6536,16 @@
5331
6536
  /**
5332
6537
  * 读当前 surface 那条 surface 的分栏容器:session → panel → split 三级读面都必须精确唯一。
5333
6538
  *
5334
- * 任一级证据不完整就返回 null(调用方交还宿主样式):会话读不出来、inventory 不可用、
5335
- * 没有属于本会话的 panel、panel 归属有歧义、或面板里的列组成不成立。
6539
+ * 返回 { status, container }:
6540
+ * - "target":组成可证明,容器就是本次要施加配比的那一个;
6541
+ * - "pending":结构上仍是两列、列 0 明确是本会话的 Expert Group 页,但列 1 的内容这一轮
6542
+ * 读不出来(重启恢复窗口:tab 条 / inventory 归属还没提交)。容器照常返回,调用方据此
6543
+ * 保持已写下的配比并短延迟重试——这正是「不闪回宿主默认值」的唯一来源;
6544
+ * - null:会话读不出来、inventory 不可用、没有属于本会话的 panel、panel 归属有歧义、
6545
+ * 或组成明确不是目标(单列 / 三列 / Expert Group 消失 / 归属冲突)。调用方交还宿主样式。
6546
+ *
6547
+ * fail-closed 边界不因 pending 放松:pending 只在列 0 的目标身份**已被证明**时才可能返回,
6548
+ * 任何未知归属、重复或混合列都仍然走 null(明确非目标),绝不靠猜测进入 pending。
5336
6549
  */
5337
6550
  readTarget(doc) {
5338
6551
  let sessionId = null;
@@ -5345,8 +6558,12 @@
5345
6558
  const panel = rightbarPanelForSession(doc, sessionId);
5346
6559
  if (panel === null) return null;
5347
6560
  const container = resolveSplitContainer(panel, tabSets);
5348
- if (container === null) return null;
5349
- return { container };
6561
+ if (container !== null) return { status: "target", container };
6562
+ // 两列结构仍在、列 0 仍是 Expert Group 页,只有列 1 的内容读不出来:暂不可读。
6563
+ // 单列 / 三列 / 混合列 / 重复列号等形状在这里都拿不到 "unavailable",一律交还宿主。
6564
+ const candidates = splitPairPendingContainers(panel, tabSets);
6565
+ if (candidates.length === 1) return { status: "pending", container: candidates[0] };
6566
+ return null;
5350
6567
  },
5351
6568
  };
5352
6569
  // install 收尾立即求值一次:中段那一轮 openTargets 触发的同步当时读面还是 null(已跳过),
@@ -5376,10 +6593,14 @@
5376
6593
  // 宿主原本的值与优先级,并置位 disposed(此后的迟到同步一律不再写入)。
5377
6594
  splitTrackRatio.dispose();
5378
6595
  rightbarWidths.clear();
6596
+ // 视口记账与 rightbarWidths 同属本次安装的目标值状态,一并清空(resize 监听已由 disposers 移除)。
6597
+ rightbarWidthViewports.clear();
5379
6598
  // per-session 的 transient 记账(模式 / 预置 / 已确认活跃)同样只属于本次安装生命周期。
5380
6599
  rightbarWidthModes.clear();
5381
6600
  rightbarWidthPreparedSessions.clear();
5382
6601
  rightbarWidthActiveSessions.clear();
6602
+ // 恢复事件记账(target / 观测到的实际宽度 / 已用预算)同样只属于本次安装生命周期。
6603
+ rightbarWidthRecoveries.clear();
5383
6604
  for (let index = disposers.length - 1; index >= 0; index -= 1) {
5384
6605
  try { disposers[index](); } catch { /* 一个 disposer 失败不能阻止其它所有权释放。 */ }
5385
6606
  }