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/client.js CHANGED
@@ -168,7 +168,8 @@ window.__ModuleLoader__.load({
168
168
  ".deg_mc_effort input{width:14px;height:14px;accent-color:var(--dsw-alias-interactive-bg-primary,var(--dsw-alias-label-primary))}" +
169
169
  ".deg_mc_input_types{display:flex;flex-wrap:wrap;gap:10px}" +
170
170
  ".deg_mc_hint{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}" +
171
- ".deg_mc_error{font-size:12px;line-height:18px;color:var(--dsw-alias-label-error,var(--dsw-alias-state-error-primary,#f87171));white-space:pre-wrap}" +
171
+ // 删除确认弹窗内的失败文本与卡片错误共用同一条规则(同一个错误视觉,不新增样式规则)。
172
+ ".deg_mc_error,.deg_remove_confirm_error{font-size:12px;line-height:18px;color:var(--dsw-alias-label-error,var(--dsw-alias-state-error-primary,#f87171));white-space:pre-wrap}" +
172
173
  ".deg_mc_card_footer{display:flex;align-items:center;gap:8px;padding-top:2px}" +
173
174
  ".deg_mc_card_footer .deg_mc_status{margin-right:auto}" +
174
175
  ".deg_mc_card_footer .deg_btn{flex:0 0 auto;min-width:76px}" +
@@ -2442,6 +2443,8 @@ window.__ModuleLoader__.load({
2442
2443
  observerPreferred: source.observerPreferred === true,
2443
2444
  // 任务管理缺字段时保持开启:默认必须维持 Expert Group 注册到侧边栏的历史行为。
2444
2445
  taskManagementEnabled: source.taskManagementEnabled !== false,
2446
+ // 自动打开缺字段/非法值时一律按关闭归一:默认不做任何自动 open/close。
2447
+ autoOpenSubagentSidebar: source.autoOpenSubagentSidebar === true,
2445
2448
  };
2446
2449
  }
2447
2450
 
@@ -2451,18 +2454,35 @@ window.__ModuleLoader__.load({
2451
2454
 
2452
2455
  function createSettingsModelCatalog(ctx) {
2453
2456
  let state = { groups: [], status: "idle", error: null };
2457
+ // 目录刷新协调:同一时刻最多一个在途请求(单飞),在途期间到达的刷新事件只记账
2458
+ // 一个尾随刷新(queued),由在途请求收尾时启动。这样一串宿主事件(新增 provider 后
2459
+ // 立即删除会同时触发目录、文档与凭据事件)只会合并成"一个在途 + 一个最新"两次请求,
2460
+ // 而不是每个事件各发一次、互相把对方判成过期。
2454
2461
  let inflight = null;
2462
+ let queued = false;
2463
+ // 销毁标记:cleanup 第一步就置位,之后任何入口都不得再发起远端目录调用。
2464
+ // 只依赖 freshness.dispose() 不够:在途请求的收尾路径(.finally)按 promise 身份
2465
+ // 判断"收尾责任",销毁时若仍持有 inflight / queued 账本,它会把排队中的尾随刷新
2466
+ // 真的发出去——插件已经销毁,却仍触发一次远端目录 RPC。
2467
+ let disposed = false;
2455
2468
  const freshness = createClientFreshnessBoundary();
2456
2469
  const listeners = new Set();
2457
2470
  const publish = (next) => {
2458
2471
  state = next;
2459
2472
  for (const listener of listeners) listener();
2460
2473
  };
2461
- const load = () => {
2462
- if (inflight !== null) return inflight;
2474
+ /**
2475
+ * 启动一次目录请求。只有"当前没有在途请求"时才会被调用(load / refresh / 尾随刷新),
2476
+ * 因此这里不需要再做并发判断。
2477
+ */
2478
+ const startRequest = () => {
2479
+ // 销毁后绝不发起远端调用(也不改写状态):正常路径由 cleanup 清账本拦住尾随刷新,
2480
+ // 这里再兜一层,覆盖 connection/reset 处理器等销毁后才到达的迟到入口。
2481
+ if (disposed) return Promise.resolve();
2463
2482
  const requestToken = freshness.beginRequest("catalog", "settings");
2483
+ // 保留上一次已知目录:加载中只换状态,不清空下拉已经能用的选项。
2464
2484
  publish({ ...state, status: "loading", error: null });
2465
- inflight = Promise.resolve().then(() => ctx.remote.session.modelCatalog()).then((response) => {
2485
+ const promise = Promise.resolve().then(() => ctx.remote.session.modelCatalog()).then((response) => {
2466
2486
  const catalog = response?.ok === true ? response.value : response;
2467
2487
  if (!catalog || !Array.isArray(catalog.groups)) throw new Error("模型目录不可用");
2468
2488
  const normalizedGroups = normalizeSettingsModelGroups(catalog.groups);
@@ -2471,20 +2491,58 @@ window.__ModuleLoader__.load({
2471
2491
  groups: normalizedGroups,
2472
2492
  requestToken,
2473
2493
  });
2494
+ // 过期响应(期间又来了刷新事件)由 freshness 拒绝:旧目录绝不覆盖新目录,
2495
+ // 也绝不把角色下拉提前解锁。
2474
2496
  if (accepted.accepted) publish({ groups: normalizedGroups, status: "ready", error: null });
2475
2497
  }).catch((cause) => {
2476
2498
  if (freshness.isCurrent(requestToken)) {
2477
2499
  publish({ ...state, status: "error", error: cause instanceof Error ? cause.message : String(cause) });
2478
2500
  }
2479
2501
  }).finally(() => {
2480
- if (freshness.isCurrent(requestToken)) inflight = null;
2502
+ // 用 promise 身份而不是 freshness 令牌判断收尾责任:在途期间到达的刷新事件会
2503
+ // 刻意作废本请求的令牌(见 refresh),那时令牌已不再"当前",但释放 inflight 与
2504
+ // 启动尾随刷新的责任仍然在本请求,否则 store 会永久停在 loading。
2505
+ if (inflight !== promise) return;
2506
+ inflight = null;
2507
+ if (!queued) return;
2508
+ queued = false;
2509
+ startRequest();
2481
2510
  });
2482
- return inflight;
2511
+ inflight = promise;
2512
+ return promise;
2513
+ };
2514
+ /**
2515
+ * 幂等读取:已有在途请求时复用它,不再并发第二次。
2516
+ * 销毁后返回一个已完成的 Promise:调用方(设置页挂载 effect)不会被悬挂的等待卡住,
2517
+ * 但 store 不会因此向远端发请求。
2518
+ */
2519
+ const load = () => {
2520
+ if (disposed) return Promise.resolve();
2521
+ if (inflight !== null) return inflight;
2522
+ return startRequest();
2483
2523
  };
2484
2524
  const refresh = (clear) => {
2485
- inflight = null;
2486
- if (clear) publish({ groups: [], status: "idle", error: null });
2487
- void load();
2525
+ // 销毁后刷新事件没有任何意义(监听已摘除);迟到的 connection/reset 也必须被丢弃,
2526
+ // 否则会在插件销毁后启动一次远端目录调用。
2527
+ if (disposed) return;
2528
+ // connection/reset 的 clear 语义:在途请求属于旧连接,它的结算与响应都不可再用,
2529
+ // 因此放弃它(收尾里的身份检查会让它的 finally 变成空操作,startRequest 的新令牌
2530
+ // 也会让它的迟到响应被拒绝)并立刻重新读取——绝不等待一个可能永远不会结算的旧请求。
2531
+ if (clear) {
2532
+ inflight = null;
2533
+ queued = false;
2534
+ publish({ groups: [], status: "idle", error: null });
2535
+ startRequest();
2536
+ return;
2537
+ }
2538
+ if (inflight !== null) {
2539
+ // 单飞:在途期间的刷新事件合并为一个尾随刷新,并立刻作废在途请求的令牌:
2540
+ // 它的响应属于旧目录,必须由 client-freshness 拒绝,不能覆盖即将到来的最新目录。
2541
+ if (!queued) freshness.beginRequest("catalog", "settings");
2542
+ queued = true;
2543
+ return;
2544
+ }
2545
+ startRequest();
2488
2546
  };
2489
2547
  const subscribe = (listener) => {
2490
2548
  listeners.add(listener);
@@ -2497,6 +2555,15 @@ window.__ModuleLoader__.load({
2497
2555
  }
2498
2556
  ctx.on("connection/reset", () => refresh(true));
2499
2557
  ctx.effect(() => () => {
2558
+ // 顺序是语义的一部分:
2559
+ // ① 先置销毁标记,封锁所有入口(load / refresh / startRequest / 尾随刷新);
2560
+ disposed = true;
2561
+ // ② 清掉逻辑账本:在途请求的 .finally 用 promise 身份判断收尾责任,账本清空后
2562
+ // 它只会直接返回,不会再把 queued 的尾随刷新发出去;
2563
+ inflight = null;
2564
+ queued = false;
2565
+ // ③ 最后才摘监听并 dispose freshness:在途请求的迟到响应仍会被 freshness
2566
+ // 拒绝(dispose 后 isCurrent 恒为 false),绝不覆盖目录或把角色下拉解锁。
2500
2567
  for (const stop of eventStops) if (typeof stop === "function") stop();
2501
2568
  freshness.dispose();
2502
2569
  }, "dsh-expert-group: settings catalog events");
@@ -3413,7 +3480,10 @@ window.__ModuleLoader__.load({
3413
3480
  className: "deg_btn deg_mc_delete_btn",
3414
3481
  disabled: saving || card.readOnly,
3415
3482
  "aria-label": card.displayName + " 删除",
3416
- onClick: () => { void onRemove(); },
3483
+ // 事件的 `currentTarget`(真实按钮)只在**同步派发期间**有效,因此原样交给父区域:
3484
+ // 删除成功后要按"点击那一刻的焦点归属 + 点击时捕获的区域作用域"决定是否把焦点还回
3485
+ // DEG 区域内的稳定控件(详见 REMOVE_FOCUS_TARGET_SELECTORS 上方的边界说明)。
3486
+ onClick: (event) => { void onRemove(event); },
3417
3487
  }, "删除")
3418
3488
  : null;
3419
3489
 
@@ -3962,6 +4032,15 @@ window.__ModuleLoader__.load({
3962
4032
  */
3963
4033
  let modelDiscoveryTitleSeq = 0;
3964
4034
 
4035
+ /**
4036
+ * 删除确认弹窗的身份序号。
4037
+ *
4038
+ * 一次确认从"待确认"到"进行中"、失败回退、成功关闭会经历多次状态更新;每次都按这个
4039
+ * 序号配对当前弹窗,**绝不**用一个会被自身更新换掉的旧对象引用来做身份比较——那样
4040
+ * 失败回退与成功关闭都会静默失效(弹窗永远停在"删除中…"),并发的后来者也会被误伤。
4041
+ */
4042
+ let removeConfirmSeq = 0;
4043
+
3965
4044
  /**
3966
4045
  * 弹窗焦点环的候选控件:只保留真正可聚焦(启用且可见)的对话框控件。
3967
4046
  *
@@ -4632,6 +4711,271 @@ window.__ModuleLoader__.load({
4632
4711
  : null));
4633
4712
  }
4634
4713
 
4714
+ // ── 删除 provider 之后的「区域内」焦点归还 ─────────────────────────────────
4715
+ //
4716
+ // 用户报告:在设置页删除一张 provider 卡片后,键盘焦点落回页面根,紧随其后的 DSH
4717
+ // 主输入框光标也随之消失。修复的边界是**只把焦点还给 DEG 区域内的稳定控件**。
4718
+ //
4719
+ // 这些边界是修复的一部分,不是实现细节:
4720
+ // - DEG **绝不**访问、也绝不 focus 宿主的任何控件(尤其是 DSH 主输入框):那是宿主
4721
+ // 自己的焦点管理,插件越界只会制造新的抢焦点问题;
4722
+ // - 只有四个条件同时成立才尝试一次自己的恢复:
4723
+ // ① 点击删除按钮那一刻焦点就在这个按钮上(这次交互确实来自 DEG 卡片),且这次读取
4724
+ // **成功**——读面抛错只是"归属未知",未知不得 arm;
4725
+ // ② 用户接受了原生 confirm;
4726
+ // ③ settings 里的 provider 配置删除成功(凭据阶段与写入阶段的失败都不算);
4727
+ // ④ 删除成功后的目录/视图刷新**整个加载事务**已经结算:`await load()` 等到的不只是
4728
+ // 自己那一轮,还包括在同一轮里被合并进来的所有尾随重跑。早一步恢复,随后的尾随
4729
+ // React 更新就会把焦点又打回根节点(这正是本波修掉的时序缺陷)。
4730
+ // 于是"确认取消""角色联动守卫拦截""凭据删除失败""settings 写入失败"四条路径都不
4731
+ // 恢复焦点,与既有语义完全一致;
4732
+ // - 异步窗口(凭据 / settings / 刷新都可能长时间 pending)里在 document 捕获阶段观察
4733
+ // pointerdown / keydown / focusin:任何用户或宿主动作把意图移走(例如用户点回 DSH
4734
+ // 输入框继续打字)都会把本次恢复标记为 abandoned,收尾时不再抢焦点;
4735
+ // - 恢复目标只在**点击时捕获的作用域**内查询(`trigger.closest(".deg_mc_section")`),
4736
+ // 绝不 document 全局查询:宿主输入框不可能被这里选中;
4737
+ // - 作用域或目标缺失、DOM 读面失败、目标已断开、hidden/disabled/aria-disabled、没有
4738
+ // 渲染矩形时静默跳过(并继续尝试下一个候选):焦点问题绝不影响删除结果(删除已经
4739
+ // 写盘,不能因为焦点读面异常而变成失败)。
4740
+
4741
+ /** 恢复目标优先级:「添加提供方」入口优先,回退到稳定的折叠区标题。 */
4742
+ const REMOVE_FOCUS_TARGET_SELECTORS = Object.freeze([
4743
+ '[data-deg-add-provider="true"]',
4744
+ '[data-deg-model-config-toggle="true"]',
4745
+ ]);
4746
+
4747
+ /** 运行期 document;没有 DOM(Node、裁剪过的宿主替身)时返回 null。 */
4748
+ function removeFocusDocument() {
4749
+ return typeof document === "undefined" ? null : (document ?? null);
4750
+ }
4751
+
4752
+ /**
4753
+ * 读取当前持有焦点的元素。
4754
+ *
4755
+ * 读面的**三个状态必须分开表达**,只有中间那一种是"成功":
4756
+ * - `activeElement` 明确返回 **null**:这是**已知的空焦点**(浏览器在没有焦点、或焦点
4757
+ * 落到 body / 页面根时给出的就是 null)。`ok === true`,收尾按"焦点仍停在原处"继续;
4758
+ * - 属性**不存在**,或 getter 返回 **undefined**:读面没有给出答案,是**未知**,
4759
+ * `ok === false`。这**不是**空焦点:宿主替身 / 被裁剪的宿主压根没有这个读面时,
4760
+ * `undefined` 只说明"读不到";
4761
+ * - getter **抛错**:同样是未知,`ok === false`。
4762
+ *
4763
+ * 后两种曾被 `documentStub.activeElement ?? null` 折叠成"成功读到 null":同一次删除流程
4764
+ * 里点击那一刻读到真实删除按钮、收尾时读面退化成 undefined 的非标准宿主,因此会在收尾
4765
+ * 把**未知**当成**已知的空焦点**,把焦点抢回 DEG 区域——正是"插件越过自己边界动焦点"
4766
+ * 那类事故。所以这里不能再做 nullish 合并,只有显式 null 才允许收尾继续。
4767
+ * 同一次删除里点击那一刻的读取失败也必须阻止 arm,否则一条无法证明归属的流程会在收尾
4768
+ * 时把焦点搬走。因此这里返回读结果对象,失败用 `ok === false` 显式表达,调用方各自决定跳过。
4769
+ */
4770
+ function readFocusedElement(documentStub) {
4771
+ if (documentStub === null || documentStub === undefined) return { ok: false, element: null };
4772
+ try {
4773
+ // 用 `in` 走**原型链**探测"这个宿主到底有没有焦点读面":真实 document 的
4774
+ // activeElement 是 Document.prototype 上的继承访问器,任何 own-property 限定
4775
+ // (hasOwnProperty / 只认数据属性)都会把真实 DOM 判成"没有读面",让整条恢复路径
4776
+ // 静默失效。Proxy / 被裁剪的宿主连 `in` 都可能抛错:那属于"读面归属未知",
4777
+ // 与"读面存在但没有答案"同样按失败处理(宁可不恢复,也不猜)。
4778
+ if (!("activeElement" in documentStub)) return { ok: false, element: null };
4779
+ const element = documentStub.activeElement;
4780
+ // 读面给出 undefined = 没有答案(未知),绝不能当作空焦点。
4781
+ if (element === undefined) return { ok: false, element: null };
4782
+ return { ok: true, element };
4783
+ } catch {
4784
+ return { ok: false, element: null };
4785
+ }
4786
+ }
4787
+
4788
+ /**
4789
+ * 捕获作用域:删除按钮所在的 DEG 区域。
4790
+ *
4791
+ * 必须在**点击时**捕获而不是收尾时再算:删除成功的刷新会给所有卡片换 key,原按钮
4792
+ * 届时已从页面摘除,它的祖先链随之断开,事后再 closest 只会得到 null。
4793
+ */
4794
+ function closestRemoveFocusScope(trigger) {
4795
+ if (trigger === null || typeof trigger.closest !== "function") return null;
4796
+ try {
4797
+ return trigger.closest(".deg_mc_section") ?? null;
4798
+ } catch {
4799
+ return null;
4800
+ }
4801
+ }
4802
+
4803
+ /**
4804
+ * 布局可见性的保守判定:读得到就读,读不到就不猜。
4805
+ *
4806
+ * 这里**不能**再用 `hasOwnProperty("offsetParent")`:真实浏览器里 offsetParent 定义在
4807
+ * `HTMLElement.prototype` 上,own property 探测恒为 false——这条判定在真实 DOM 上从未
4808
+ * 生效过;而换成 `offsetParent !== null` 又会把 fixed 定位元素(offsetParent 本来就是
4809
+ * null)误杀。两个真实可用的读面都按"有读面才判定"处理:
4810
+ * - `checkVisibility({ visibilityProperty: true })`:一次覆盖 display:none、visibility 与
4811
+ * content-visibility。options 必须显式给出:`checkVisibility()` 的默认语义**只**看元素
4812
+ * 是否渲染,被 CSS `visibility: hidden` 藏起来的控件仍然参与排版、仍然会被判为可见,
4813
+ * 恢复焦点就会交给一个用户看不见的控件。宿主实现若不认识 options 而抛错,异常会被
4814
+ * 下面的 catch 吞掉并退到 getClientRects,不会连累删除结果;
4815
+ * - `getClientRects()`:零渲染矩形(未排版 / display:none)说明元素没有盒子,
4816
+ * 而 fixed 定位元素同样有渲染矩形,因此合法目标不会被误杀。
4817
+ * 无布局引擎的替身/宿主两个读面都读不到时保持"不做无根据的判定",避免整体误杀恢复能力。
4818
+ */
4819
+ function isLaidOutRemoveFocusTarget(element) {
4820
+ if (typeof element.checkVisibility === "function") {
4821
+ try {
4822
+ // 必须带 options:默认调用不看 CSS visibility 属性(见上方边界说明)。
4823
+ if (element.checkVisibility({ visibilityProperty: true }) === false) return false;
4824
+ } catch { /* 读面抛错:交给下一个读面,不做无根据的判定。 */ }
4825
+ }
4826
+ if (typeof element.getClientRects === "function") {
4827
+ try {
4828
+ const rects = element.getClientRects();
4829
+ // DOMRectList 与数组都有 length;读面给出非列表值时同样不做无根据的判定。
4830
+ if (rects !== null && rects !== undefined && typeof rects.length === "number" && rects.length === 0) return false;
4831
+ } catch { /* 同上。 */ }
4832
+ }
4833
+ return true;
4834
+ }
4835
+
4836
+ /**
4837
+ * 目标可用性的保守判定:断开、隐藏、disabled、aria-disabled 或没有渲染矩形一律不可用。
4838
+ */
4839
+ function isUsableRemoveFocusTarget(element) {
4840
+ if (element === null || typeof element !== "object") return false;
4841
+ // focus() 缺失(裁剪过的元素替身)时不尝试:恢复焦点绝不允许抛错。
4842
+ if (typeof element.focus !== "function") return false;
4843
+ // isConnected 在真实 DOM 上恒为布尔;读面缺失时按"不可用"处理,宁可不恢复也不猜。
4844
+ if (element.isConnected !== true) return false;
4845
+ if (element.hidden === true) return false;
4846
+ if (element.disabled === true) return false;
4847
+ const ariaDisabled = typeof element.getAttribute === "function"
4848
+ ? element.getAttribute("aria-disabled")
4849
+ : element["aria-disabled"];
4850
+ if (ariaDisabled === "true" || ariaDisabled === true) return false;
4851
+ if (!isLaidOutRemoveFocusTarget(element)) return false;
4852
+ return true;
4853
+ }
4854
+
4855
+ /** 在作用域内按优先级挑一个恢复目标;一个都不可用时返回 null。 */
4856
+ function pickRemoveFocusTarget(scope) {
4857
+ if (scope === null || typeof scope.querySelector !== "function") return null;
4858
+ for (const selector of REMOVE_FOCUS_TARGET_SELECTORS) {
4859
+ let candidate = null;
4860
+ try {
4861
+ candidate = scope.querySelector(selector);
4862
+ } catch {
4863
+ candidate = null;
4864
+ }
4865
+ if (isUsableRemoveFocusTarget(candidate)) return candidate;
4866
+ }
4867
+ return null;
4868
+ }
4869
+
4870
+ /**
4871
+ * 一次删除期间的"焦点意图"观察器。
4872
+ *
4873
+ * document 捕获阶段的 pointerdown / keydown / focusin 任一命中即把本次恢复标记为
4874
+ * abandoned(用户或宿主已经把意图移到别处)。`dispose()` 幂等:调用方必须在提前返回、
4875
+ * 异常与成功路径上都调用它,ModelConfigSection 卸载时还会再兜一次——删除流程里有多个
4876
+ * await,远端 Promise 可能长时间不结算,全局监听绝不能留在页面上。
4877
+ */
4878
+ function observeRemoveFocusIntent(documentStub) {
4879
+ const events = ["pointerdown", "keydown", "focusin"];
4880
+ const registered = [];
4881
+ let abandoned = false;
4882
+ let disposed = false;
4883
+ const markAbandoned = () => { abandoned = true; };
4884
+ if (documentStub !== null && typeof documentStub.addEventListener === "function") {
4885
+ for (const type of events) {
4886
+ try {
4887
+ // capture=true:即使宿主在冒泡阶段 stopPropagation,也必须看到这次操作。
4888
+ documentStub.addEventListener(type, markAbandoned, true);
4889
+ registered.push(type);
4890
+ } catch { /* 登记失败:少观察一类事件,不影响删除语义。 */ }
4891
+ }
4892
+ }
4893
+ return {
4894
+ isAbandoned: () => abandoned,
4895
+ dispose() {
4896
+ if (disposed) return;
4897
+ disposed = true;
4898
+ if (documentStub === null || typeof documentStub.removeEventListener !== "function") return;
4899
+ for (const type of registered) {
4900
+ try {
4901
+ documentStub.removeEventListener(type, markAbandoned, true);
4902
+ } catch { /* 摘监听失败:监听随页面销毁一起消失,不影响删除结果。 */ }
4903
+ }
4904
+ registered.length = 0;
4905
+ },
4906
+ };
4907
+ }
4908
+
4909
+ /**
4910
+ * 在途焦点意图观察器的账本(每个删除流程各拥有自己那一条)。
4911
+ *
4912
+ * 为什么必须由组件持有而不是各流程自己记:删除流程里有多个 await,远端 Promise 可能
4913
+ * 长时间不结算,捕获监听绝不能留在页面上,所以卸载时必须有一个统一的兜底口。
4914
+ *
4915
+ * 三条不变量:
4916
+ * ① 集合在**首个渲染期**(useRef 初始化)就建立,绝不等到 passive effect 才赋值——
4917
+ * 真实 React 的 passive effect 在提交之后异步执行,删除流程完全可能在它之前就登记,
4918
+ * 那样的登记会落进 null 而永远无人清理(等价于把捕获监听永久留在 document 上);
4919
+ * ② 重叠的删除各自只移除自己那一条(`delete` 按对象身份,与顺序无关),单槽位账本
4920
+ * 会在双击删除时漏掉先发起的那条;
4921
+ * ③ 卸载即 `close()`:关闭后到达的登记**当场自我清理**,绝不允许再出现无人认领的监听。
4922
+ * 因此 `delete`/`close` 与 `dispose` 一样必须幂等。
4923
+ */
4924
+ function createRemoveFocusObservationLedger() {
4925
+ const observations = new Set();
4926
+ let closed = false;
4927
+ return {
4928
+ add(observation) {
4929
+ if (observation === null || observation === undefined) return;
4930
+ if (closed) {
4931
+ // 账本已随卸载关闭:这条登记没有任何人去摘它,只能当场清理。
4932
+ observation.dispose();
4933
+ return;
4934
+ }
4935
+ observations.add(observation);
4936
+ },
4937
+ /** 某次流程自己认领的观察器收尾:与卸载兜底摘的是同一条,dispose 幂等。 */
4938
+ delete(observation) {
4939
+ observations.delete(observation);
4940
+ },
4941
+ close() {
4942
+ if (closed) return;
4943
+ closed = true;
4944
+ for (const observation of observations) observation.dispose();
4945
+ observations.clear();
4946
+ },
4947
+ };
4948
+ }
4949
+
4950
+ /**
4951
+ * 成功路径的收尾:先摘观察器,再按优先级还一次焦点。
4952
+ *
4953
+ * 前置条件缺一不可——作用域仍在页面上、意图没被移走、焦点仍停在原处(原按钮 / body /
4954
+ * 空)。任何读面缺失或 focus() 抛错都只让这次恢复静默跳过:删除已经写盘,焦点问题
4955
+ * 绝不允许改变删除结果。
4956
+ */
4957
+ function restoreRemoveFocus({ documentStub, trigger, scope, observation }) {
4958
+ // 整段包在 try 里:焦点读面/写面(activeElement、getAttribute、focus)在任何宿主
4959
+ // 实现上都可能抛错,而删除已经写盘——焦点问题绝不允许改变删除结果。
4960
+ try {
4961
+ if (documentStub === null || trigger === null || scope === null) return;
4962
+ if (observation?.isAbandoned?.() === true) return;
4963
+ // 区域必须仍挂在页面上:整块设置页被卸载或替换时不再恢复任何焦点。
4964
+ if (scope.isConnected !== true) return;
4965
+ // 收尾这一次读取失败 = 当前焦点归属**未知**:安全跳过,绝不猜测。
4966
+ const focusRead = readFocusedElement(documentStub);
4967
+ if (focusRead.ok !== true) return;
4968
+ const active = focusRead.element;
4969
+ const body = documentStub.body ?? null;
4970
+ // 只有焦点仍"停在原处"(原按钮 / body / 已知的空焦点)才恢复:用户已经点/切到
4971
+ // 别处(例如 DSH 输入框)时绝不抢回来。
4972
+ if (active !== null && active !== trigger && active !== body) return;
4973
+ const target = pickRemoveFocusTarget(scope);
4974
+ if (target === null) return;
4975
+ target.focus();
4976
+ } catch { /* 焦点不可用时保持删除结果不变。 */ }
4977
+ }
4978
+
4635
4979
  /**
4636
4980
  * 折叠的「模型配置」区域。
4637
4981
  *
@@ -4646,6 +4990,15 @@ window.__ModuleLoader__.load({
4646
4990
  const [adding, setAdding] = react.useState(false);
4647
4991
  const [addChoice, setAddChoice] = react.useState("");
4648
4992
  const [settingsError, setSettingsError] = react.useState(null);
4993
+ /**
4994
+ * 删除确认状态(DEG 自渲染的非阻塞弹窗,见 openRemoveConfirm / removeConfirmDialog)。
4995
+ *
4996
+ * 为什么不是原生 confirm:同步 `globalThis.confirm()` 会阻塞浏览器事件循环,弹窗存续
4997
+ * 期间页面脚本、遮罩与宿主(DSH 会话输入框)的输入处理全部停摆,用户看到的就是
4998
+ * "设置页与 DSH 会话输入框随后都不可用,重启才恢复"。DEG 因此自己渲染确认层,
4999
+ * 任何阶段都不再调用同步原生 confirm。
5000
+ */
5001
+ const [removeIntent, setRemoveIntent] = react.useState(null);
4649
5002
  // 宿主替身/host 对象自身不触发渲染,因此每次重新读取宿主视图时手动推进一个版本号,
4650
5003
  // 让 provider 卡片拿到最新的 user 层与 revision(而不是首次渲染时的那一份)。
4651
5004
  const [viewRevision, setViewRevision] = react.useState(0);
@@ -4761,7 +5114,13 @@ window.__ModuleLoader__.load({
4761
5114
  //
4762
5115
  // 并发用一个"尾随重跑"标记收口:正在加载时到来的请求不会并发跑两次,但一定会在
4763
5116
  // 当前这轮结束后再跑一轮,因此调用方(例如取消半完成创建的卡片)拿到的始终是新视图。
4764
- const loadStateRef = react.useRef({ running: false, again: false });
5117
+ //
5118
+ // 事务语义(删除后的焦点归还依赖它):`running` 期间到达的每一次调用都返回**同一个**
5119
+ // 覆盖「当前轮 + 所有 again 尾随重跑」的 Promise。删除流程 `await load()` 因此会一直
5120
+ // 等到最后一轮结算,焦点恢复不会落在尾随轮次的 React 更新之前(那会把焦点又打回根节点)。
5121
+ // 少返回一次这个 Promise(例如直接 `return Promise.resolve()`)就等于把"刷新完成"提前
5122
+ // 宣布,这正是本波要修掉的时序缺陷。
5123
+ const loadStateRef = react.useRef({ running: false, again: false, promise: null });
4765
5124
  const runLoad = react.useCallback(async () => {
4766
5125
  setDirectory((previous) => ({ ...previous, status: "loading", error: null }));
4767
5126
  setSettingsError(null);
@@ -4781,21 +5140,36 @@ window.__ModuleLoader__.load({
4781
5140
  }
4782
5141
  }, [config, host]);
4783
5142
 
4784
- const load = react.useCallback(async () => {
5143
+ const load = react.useCallback(() => {
4785
5144
  const state = loadStateRef.current;
4786
5145
  if (state.running) {
5146
+ // 加入同一个事务:只置尾随标记(多个并发调用合并成**一个**尾随轮次),并把当前
5147
+ // 事务的 Promise 原样交回调用方——它覆盖到最后一次尾随重跑为止。
4787
5148
  state.again = true;
4788
- return;
5149
+ return state.promise;
4789
5150
  }
4790
5151
  state.running = true;
4791
- try {
4792
- do {
5152
+ const transaction = (async () => {
5153
+ try {
5154
+ do {
5155
+ state.again = false;
5156
+ await runLoad();
5157
+ } while (state.again);
5158
+ } finally {
5159
+ // 任何出口(正常结束、runLoad 抛错)都必须同时清掉两个账本字段:
5160
+ // running 卡住会让后续调用永远只置一遍 again,promise 不清空则会让新调用拿到
5161
+ // 一个已经结算(甚至已经拒绝)的旧 Promise——两者都会变成静默的永久挂起。
5162
+ // 从"循环条件不成立"到这里的整段是同步的,因此不存在"刚好在收尾期间置 again
5163
+ // 却被丢弃"的窗口:那个窗口之外的新调用一定会开启一个新事务。
5164
+ state.running = false;
4793
5165
  state.again = false;
4794
- await runLoad();
4795
- } while (state.again);
4796
- } finally {
4797
- state.running = false;
4798
- }
5166
+ state.promise = null;
5167
+ }
5168
+ })();
5169
+ // 赋值发生在事务体的第一个 await 之前(事务体只能在 await 之后进入 finally),
5170
+ // 因此 running 为真时 promise 一定已就绪。
5171
+ state.promise = transaction;
5172
+ return transaction;
4799
5173
  }, [runLoad]);
4800
5174
 
4801
5175
  react.useEffect(() => {
@@ -4803,36 +5177,173 @@ window.__ModuleLoader__.load({
4803
5177
  void load();
4804
5178
  }, [open, load, hostInvalidation]);
4805
5179
 
5180
+ // 删除后的焦点归还:在途观察器由账本统一记账(见 createRemoveFocusObservationLedger)。
5181
+ //
5182
+ // 账本在**渲染期**建立(useRef 初始化 + 惰性赋值),不放在 passive effect 里:真实
5183
+ // React 的 passive effect 在提交之后才异步执行,删除流程可能在那之前就登记,那条登记
5184
+ // 会落进 null 而永远无人清理。卸载时统一 close()——删除流程里有多个 await,远端
5185
+ // Promise 可能长时间不结算,捕获监听绝不能留在页面上(常规路径仍由
5186
+ // confirmRemoveProvider 的 finally 自己收尾,这里是额外的一层兜底)。
5187
+ const removeFocusObservations = react.useRef(null);
5188
+ if (removeFocusObservations.current === null) {
5189
+ removeFocusObservations.current = createRemoveFocusObservationLedger();
5190
+ }
5191
+ // 确认弹窗的根节点:Tab 焦点环只在这个子树里取控件(绝不用 document 全局查询)。
5192
+ const removeDialogRef = react.useRef(null);
5193
+ react.useEffect(() => () => {
5194
+ removeFocusObservations.current?.close();
5195
+ }, []);
5196
+
5197
+ react.useEffect(() => {
5198
+ // 区域折叠(body 不再渲染)时清掉**待确认**状态:快照里挂着的是此刻已经不在页面上的
5199
+ // 删除按钮,重新展开时不得把它当成仍然有效的待确认操作弹出来。
5200
+ // 两个边界:
5201
+ // - 只在**折叠**方向清理,展开方向什么都不做(否则"折叠期间结算成失败"的状态会在
5202
+ // 重新展开的那一刻被自己抹掉);
5203
+ // - **running 绝不清**:异步删除(凭据 / 写入 / 刷新)仍在继续,清掉它只会让进行中、
5204
+ // 失败与重试的可见入口永久消失——用户可以折叠,但这次删除的结果必须找得回来。
5205
+ if (open) return;
5206
+ setRemoveIntent((previous) => (previous !== null && previous.phase === "confirming" ? null : previous));
5207
+ }, [open]);
5208
+
4806
5209
  const editableRows = directory.rows.filter((row) => config.providerEditable(row, host.namespaceView(row.settingsNs)));
4807
5210
  const configured = providerSections.configured.map((entry) => entry.row);
4808
5211
  const addable = providerSections.addable.map((entry) => entry.row);
4809
- const removeProvider = async (row) => {
4810
- const namespace = host.namespaceView(row.settingsNs);
5212
+ /**
5213
+ * 点击卡片「删除」:**只**捕获这次交互的上下文并打开 DEG 自渲染的确认弹窗。
5214
+ *
5215
+ * 这一阶段绝不能有任何远端副作用:确认前的凭据删除、settings 写入与目录刷新都必须为 0。
5216
+ * 也绝不能再调用同步原生 `confirm()`:它阻塞浏览器事件循环,弹窗存续期间页面脚本、
5217
+ * 遮罩与宿主(DSH 会话输入框)的输入处理全部停摆——这正是"删除后设置页与会话输入框
5218
+ * 都不可用、重启才恢复"的事故面。
5219
+ *
5220
+ * trigger / scope / ownsFocus 必须在**点击那一刻**捕获:删除成功的刷新会给卡片换 key,
5221
+ * 原按钮届时已从页面摘除,事后再算只能得到 null。
5222
+ */
5223
+ const openRemoveConfirm = (row, event) => {
4811
5224
  const label = typeof row.displayName === "string" && row.displayName.length > 0 ? row.displayName : row.provider;
4812
- if (typeof globalThis.confirm === "function" && !globalThis.confirm(`确定删除 provider「${label}」及其配置吗?`)) return;
4813
- const affected = config.rolesAffectedByChange(roleDefaults, { provider: row.provider, removeProvider: true });
4814
- if (affected.length > 0) {
4815
- setSettingsError(config.reasoningGuardMessage(["(整个 provider)"], affected, roleLabelOf));
4816
- return;
4817
- }
4818
- const credentialRef = config.credentialRefOf(row, namespace);
4819
- if (host.operations.credentialState(credentialRef)?.configured === true) {
4820
- const removed = await host.operations.removeKey(credentialRef);
4821
- if (removed !== null) {
4822
- setSettingsError(removed);
5225
+ // `currentTarget` 只在同步派发期间有效:在这里读一次并存进确认状态。
5226
+ const trigger = event?.currentTarget ?? null;
5227
+ const documentStub = removeFocusDocument();
5228
+ // ① 只有"点击删除按钮那一刻焦点确实在这个按钮上"才登记恢复意图:否则这次交互的
5229
+ // 焦点不属于 DEG 卡片(宿主替身没有真实 DOM 焦点),绝不猜测、绝不抢焦点。
5230
+ // 读面失败(ok=false)时焦点归属**未知**,同样不得 arm:一条无法证明归属的流程
5231
+ // 在收尾时把焦点搬走,恰恰是"插件越过自己边界动焦点"的那类事故。
5232
+ const triggerFocusRead = readFocusedElement(documentStub);
5233
+ const ownsFocus = trigger !== null
5234
+ && triggerFocusRead.ok === true
5235
+ && triggerFocusRead.element === trigger;
5236
+ // 作用域必须在点击时捕获(见 closestRemoveFocusScope 的说明)。
5237
+ const scope = ownsFocus ? closestRemoveFocusScope(trigger) : null;
5238
+ removeConfirmSeq += 1;
5239
+ setRemoveIntent({ token: removeConfirmSeq, row, label, trigger, scope, ownsFocus, phase: "confirming", error: null });
5240
+ };
5241
+
5242
+ /**
5243
+ * 取消确认:只关弹窗,并把焦点还给**仍可用**的已捕获删除按钮,或同一 DEG 区域内的
5244
+ * 安全回退目标。
5245
+ *
5246
+ * 取消路径不得读取当前 `document.activeElement` 去另找一个恢复目标:那是"把全局焦点
5247
+ * 当恢复目标"的错误做法,正好会把焦点交给宿主输入框。按钮与作用域都来自打开弹窗那一刻
5248
+ * 的快照,因此这条路径在结构上不可能触及 DEG 之外的控件。
5249
+ */
5250
+ const cancelRemoveConfirm = () => {
5251
+ const intent = removeIntent;
5252
+ // 进行中一律拒绝取消:此刻凭据与 settings 可能已经写盘,"取消"在语义上已经不存在,
5253
+ // 关掉弹窗只会把进行中 / 失败 / 重试的可见入口一起卸载,而异步删除仍在继续。
5254
+ // 遮罩、Escape 与「取消」按钮三条入口共用这一个守卫,任何一条都不得绕过它。
5255
+ if (intent !== null && intent.phase === "running") return;
5256
+ setRemoveIntent(null);
5257
+ if (intent === null || intent.ownsFocus !== true) return;
5258
+ try {
5259
+ // 删除按钮仍在页面上且可用 → 优先还给它(按 Escape / 取消后的直觉位置)。
5260
+ if (isUsableRemoveFocusTarget(intent.trigger)) {
5261
+ intent.trigger.focus();
4823
5262
  return;
4824
5263
  }
5264
+ // 按钮已不可用(断开 / 隐藏 / 禁用)→ 只在点击时捕获的 DEG 区域内找安全回退目标。
5265
+ if (intent.scope !== null && intent.scope.isConnected === true) {
5266
+ const target = pickRemoveFocusTarget(intent.scope);
5267
+ if (target !== null) target.focus();
5268
+ }
5269
+ } catch { /* 焦点不可用不影响"取消"语义:弹窗已经关掉。 */ }
5270
+ };
5271
+
5272
+ /**
5273
+ * 用户点击弹窗「删除」:**从这里**才开始真正的删除链路。
5274
+ *
5275
+ * 顺序与错误语义与既有实现完全一致:角色联动守卫 → 凭据删除 → settings unset →
5276
+ * 完整 load 事务(含并发合并进来的尾随轮次)→ 区域内焦点恢复策略。唯一变化是它由 DEG
5277
+ * 自己的弹窗按钮触发,因此不再有同步原生 confirm 阻塞事件循环。
5278
+ *
5279
+ * 失败(守卫拦截 / 凭据失败 / 写入失败 / 异常)保留弹窗:错误同时留在弹窗内(role=alert)
5280
+ * 与区域级错误里,用户可以直接取消或重试;失败路径不做任何焦点恢复,也绝不把删除
5281
+ * 结果伪装成成功。删除进行中(phase === "running")按钮禁用,防止重复提交。
5282
+ */
5283
+ const confirmRemoveProvider = async () => {
5284
+ const intent = removeIntent;
5285
+ if (intent === null || intent.phase !== "confirming") return;
5286
+ const { row, trigger, scope, ownsFocus } = intent;
5287
+ // 立刻转 running:此刻凭据与 settings 可能已经写盘,关掉弹窗只会掩盖真实结果,
5288
+ // 因此按钮禁用、取消/Escape 不再受理(见 removeConfirmDialog 的键位处理)。
5289
+ // 所有后续更新都按 token 配对:同一次确认换了对象也认得自己,后来者则不受影响。
5290
+ setRemoveIntent((previous) => (previous !== null && previous.token === intent.token
5291
+ ? { ...previous, phase: "running", error: null }
5292
+ : previous));
5293
+ const documentStub = removeFocusDocument();
5294
+ // ② 确认之后、任何 await 之前登记观察器:异步窗口里用户/宿主的任何操作都可能把
5295
+ // 意图移走,收尾时据 abandoned 决定是否还焦点。
5296
+ const observation = ownsFocus && scope !== null ? observeRemoveFocusIntent(documentStub) : null;
5297
+ if (observation !== null) removeFocusObservations.current?.add(observation);
5298
+ // 失败出口:区域级错误保持既有语义,同时在弹窗内可见(身份比较,绝不动后来者)。
5299
+ const fail = (message) => {
5300
+ setSettingsError(message);
5301
+ setRemoveIntent((previous) => (previous !== null && previous.token === intent.token
5302
+ ? { ...previous, phase: "confirming", error: message }
5303
+ : previous));
5304
+ };
5305
+ try {
5306
+ const affected = config.rolesAffectedByChange(roleDefaults, { provider: row.provider, removeProvider: true });
5307
+ if (affected.length > 0) {
5308
+ fail(config.reasoningGuardMessage(["(整个 provider)"], affected, roleLabelOf));
5309
+ return;
5310
+ }
5311
+ const namespace = host.namespaceView(row.settingsNs);
5312
+ const credentialRef = config.credentialRefOf(row, namespace);
5313
+ if (host.operations.credentialState(credentialRef)?.configured === true) {
5314
+ const removed = await host.operations.removeKey(credentialRef);
5315
+ if (removed !== null) {
5316
+ fail(removed);
5317
+ return;
5318
+ }
5319
+ }
5320
+ const written = await host.writeNamespace({
5321
+ ns: row.settingsNs,
5322
+ ops: [{ op: "unset", path: [...row.settingsPath] }],
5323
+ expectedRevision: namespace?.revision,
5324
+ });
5325
+ if (!written.ok) {
5326
+ fail(written.message);
5327
+ return;
5328
+ }
5329
+ // ③ + ④ 写入成功,且**整个加载事务**(当前轮 + 所有尾随重跑)已经结算:先摘观察器
5330
+ // (自己的 focus 不该被自己记成"意图移走"),再按优先级把焦点还回区域内
5331
+ // (目标不可用时静默跳过)。`await load()` 拿到的是共享事务的 Promise,
5332
+ // 不是"立刻结算的空壳"——见 loadStateRef 上方的时序说明。
5333
+ await load();
5334
+ observation?.dispose();
5335
+ // 成功:先关掉自己这一轮弹窗(按 token 配对:并发的后来者不受影响),再恢复焦点。
5336
+ setRemoveIntent((previous) => (previous !== null && previous.token === intent.token ? null : previous));
5337
+ restoreRemoveFocus({ documentStub, trigger, scope, observation });
5338
+ } catch (cause) {
5339
+ // 任何读面/写面抛错都必须可见,而不是静默变成一次"看起来成功"的删除。
5340
+ fail(cause instanceof Error ? cause.message : String(cause));
5341
+ } finally {
5342
+ // 所有出口(守卫拦截、凭据失败、写入失败、异常、成功)都在这里摘掉观察器;
5343
+ // 摘监听本身幂等,卸载兜底再摘一次也不会重复移除。
5344
+ observation?.dispose();
5345
+ removeFocusObservations.current?.delete(observation);
4825
5346
  }
4826
- const written = await host.writeNamespace({
4827
- ns: row.settingsNs,
4828
- ops: [{ op: "unset", path: [...row.settingsPath] }],
4829
- expectedRevision: namespace?.revision,
4830
- });
4831
- if (!written.ok) {
4832
- setSettingsError(written.message);
4833
- return;
4834
- }
4835
- await load();
4836
5347
  };
4837
5348
 
4838
5349
  const body = section.open ? react.createElement("div", { className: "deg_mc_body", id: section.disclosure.bodyId },
@@ -4852,7 +5363,9 @@ window.__ModuleLoader__.load({
4852
5363
  host,
4853
5364
  roleLabels: { defaults: roleDefaults, labelOf: roleLabelOf },
4854
5365
  onSaved: () => { void load(); },
4855
- onRemove: () => removeProvider(row),
5366
+ // 事件原样透传:openRemoveConfirm 只读取同步有效的 `currentTarget`(删除按钮本身),
5367
+ // 打开 DEG 自己的确认弹窗——确认之前不删凭据、不写 settings、不刷目录。
5368
+ onRemove: (event) => openRemoveConfirm(row, event),
4856
5369
  }))),
4857
5370
  react.createElement("div", { className: "deg_mc_add" },
4858
5371
  adding
@@ -4942,9 +5455,90 @@ window.__ModuleLoader__.load({
4942
5455
  }, directory.status === "loading" ? "读取中…" : "重新读取 provider 目录"))
4943
5456
  : null;
4944
5457
 
5458
+ /**
5459
+ * 删除确认弹窗:复用提示词弹窗的 `deg_prompt_dialog_backdrop` / `deg_prompt_dialog`
5460
+ * 样式与 `data-deg-dialog-action` / focusableDialogControls 的键位模式,但**非阻塞**:
5461
+ * 渲染在 React 树里,事件循环照常运转。
5462
+ *
5463
+ * 边界(每条都是修复的一部分):
5464
+ * - 只显示 provider 名称与「确认 / 取消」两个操作;
5465
+ * - 打开后焦点落在「取消」(破坏性动作绝不能是默认焦点);
5466
+ * - 取消、Escape、点击遮罩**本身**三条路径都只关弹窗:不删凭据、不写 settings、不刷目录;
5467
+ * - Tab / Shift+Tab 只在弹窗内**真正可聚焦**的控件之间循环,不漏到背景页面;
5468
+ * - 删除进行中(phase === "running")按钮禁用、Escape 与 Tab 都不再受理:此刻写盘可能
5469
+ * 已经发生,关掉弹窗只会掩盖真实结果;
5470
+ * - 失败时弹窗保留,错误在弹窗内以 role=alert 可见,用户可以取消或重试。
5471
+ */
5472
+ const removeConfirmDialog = removeIntent === null ? null : react.createElement("div", {
5473
+ className: "deg_prompt_dialog_backdrop",
5474
+ // 点遮罩本身只取消(弹窗内部的点击在下面 stopPropagation,不会误触发);
5475
+ // 删除进行中由 cancelRemoveConfirm 自己的守卫拒绝——这里绝不另写一套 phase 判断,
5476
+ // 三条关闭入口(遮罩 / Escape / 取消按钮)必须共用同一个守卫。
5477
+ onClick: () => cancelRemoveConfirm(),
5478
+ },
5479
+ react.createElement("div", {
5480
+ className: "deg_prompt_dialog",
5481
+ role: "dialog",
5482
+ "aria-modal": "true",
5483
+ "aria-label": "删除 provider 确认",
5484
+ ref: removeDialogRef,
5485
+ onClick: (event) => event?.stopPropagation?.(),
5486
+ onKeyDown: (event) => {
5487
+ // 删除进行中:不再受理取消与焦点环,避免用户以为"取消掉了"而实际已经写盘。
5488
+ if (removeIntent.phase === "running") return;
5489
+ if (event?.key === "Escape") {
5490
+ // 独占模态语义:阻止默认行为并停止冒泡,避免外层再处理同一个 Escape。
5491
+ event.preventDefault?.();
5492
+ event.stopPropagation?.();
5493
+ cancelRemoveConfirm();
5494
+ return;
5495
+ }
5496
+ if (event?.key !== "Tab") return;
5497
+ // 与候选弹窗同一套焦点环:只取真正可聚焦(启用且可见)的控件,disabled 的
5498
+ // 「删除 / 取消」不能被 preventDefault 之后 focus 一个被忽略的元素而卡死焦点。
5499
+ const controls = focusableDialogControls(removeDialogRef.current ?? event?.currentTarget ?? null);
5500
+ // 没有任何可聚焦控件时不拦截 Tab:宁可让浏览器默认行为走,也不把焦点锁死。
5501
+ if (controls.length === 0) return;
5502
+ const current = controls.indexOf(typeof document === "undefined" ? null : document.activeElement);
5503
+ const nextIndex = event.shiftKey
5504
+ ? (current <= 0 ? controls.length - 1 : current - 1)
5505
+ : (current >= controls.length - 1 ? 0 : current + 1);
5506
+ event.preventDefault?.();
5507
+ try { controls[nextIndex]?.focus?.(); } catch { /* 无 DOM 焦点时保持可点击按钮。 */ }
5508
+ },
5509
+ },
5510
+ react.createElement("h3", null, "删除 provider"),
5511
+ react.createElement("p", null, "确定删除 provider「" + removeIntent.label + "」及其配置吗?"),
5512
+ removeIntent.error === null ? null : react.createElement("p", {
5513
+ className: "deg_remove_confirm_error",
5514
+ role: "alert",
5515
+ "aria-live": "assertive",
5516
+ }, removeIntent.error),
5517
+ react.createElement("div", { className: "deg_prompt_dialog_actions" },
5518
+ react.createElement("button", {
5519
+ type: "button",
5520
+ className: "deg_btn",
5521
+ "data-deg-dialog-action": "cancel",
5522
+ "data-deg-remove-cancel": "true",
5523
+ // 打开弹窗后焦点落在「取消」:破坏性动作不抢默认焦点。
5524
+ autoFocus: true,
5525
+ disabled: removeIntent.phase === "running",
5526
+ onClick: () => cancelRemoveConfirm(),
5527
+ }, "取消"),
5528
+ react.createElement("button", {
5529
+ type: "button",
5530
+ className: "deg_btn deg_btn_primary",
5531
+ "data-deg-dialog-action": "confirm",
5532
+ "data-deg-remove-confirm": "true",
5533
+ disabled: removeIntent.phase === "running",
5534
+ onClick: () => { void confirmRemoveProvider(); },
5535
+ }, removeIntent.phase === "running" ? "删除中…" : "删除"))));
5536
+
4945
5537
  return react.createElement("div", { className: "deg_mc_section" },
4946
5538
  react.createElement(ModelConfigDisclosure, { section, onToggle: () => setOpen((value) => !value) }),
4947
- body);
5539
+ body,
5540
+ // 折叠时删除按钮已不在页面上,弹窗一并不渲染(确认状态由 open 的 effect 清掉)。
5541
+ open ? removeConfirmDialog : null);
4948
5542
  }
4949
5543
 
4950
5544
  /**
@@ -5177,13 +5771,27 @@ window.__ModuleLoader__.load({
5177
5771
 
5178
5772
  const groups = Array.isArray(directoryState.groups) ? directoryState.groups : [];
5179
5773
  const providerGroups = groups.filter((group) => group && typeof group.id === "string");
5774
+ /**
5775
+ * 目录可用性:`status === "ready"`,或**已有成功目录缓存**(groups 非空)。
5776
+ *
5777
+ * 角色的选择与保存只依赖目录**内容**,不依赖"此刻有没有在途刷新":
5778
+ * - `startRequest` 每次只把 status 推到 loading、**刻意保留**上一次已知目录(见
5779
+ * createSettingsModelCatalog 的注释),最新一次请求失败也只把 status 推到 error;
5780
+ * 这两种状态下下拉里的选项仍然完整可用,若按 status 硬门控就会出现"用户看得见选项
5781
+ * 却选不了、更保存不了"的死锁——"新增 provider 后立即删除"的刷新突发正是这个时序;
5782
+ * - 首次挂载还没有过任何成功目录(groups 为空)时仍然禁用:没有目录就没有合法选项;
5783
+ * - `connection/reset` 的 clear 语义会**立刻清空** groups(refresh(true)),清空期间
5784
+ * 同样必须禁用,直到新连接上的目录返回。缓存可用的判定因此只看"有没有目录",
5785
+ * 绝不能用"曾经 ready 过"这类会跨 reset 残留的记忆。
5786
+ */
5787
+ const catalogAvailable = directoryState.status === "ready" || groups.length > 0;
5180
5788
  // 统一选择器只反映当前展示角色的共同 provider;混合状态使用空值映射到“无”。
5181
5789
  const unifiedProvider = unifiedProviderOf(draft.defaults, roles, draft.provider);
5182
5790
 
5183
5791
  const save = async ({ retry = false } = {}) => {
5184
5792
  const promptOnlySave = selectedPromptRole !== null && promptTouchedRoles.length > 0;
5185
5793
  if (!hasUnsavedChanges || saving || settingsSnapshot.writable !== true
5186
- || (!promptOnlySave && directoryState.status !== "ready")) return false;
5794
+ || (!promptOnlySave && !catalogAvailable)) return false;
5187
5795
  const source = normalizeSettingsDraft(settingsSnapshot.value, roles);
5188
5796
  // 编辑器只修改提示词时必须以当前完整 snapshot 为候选基底:首次 CAS 会因旧 revision
5189
5797
  // 冲突而失败;明确重试时才以最新 snapshot + 用户实际提示词字段提交,绝不重放旧模型/通知。
@@ -5192,10 +5800,12 @@ window.__ModuleLoader__.load({
5192
5800
  const draftProviderIntent = promptOnlySave ? sourceProvider : (typeof draft.provider === "string" ? draft.provider : "");
5193
5801
  const draftProvider = unifiedProviderOf(normalizedDraft.defaults, roles, draftProviderIntent);
5194
5802
  const defaultsChanged = !settingsDraftEqual(normalizedDraft.defaults, source.defaults);
5195
- const catalogReady = directoryState.status === "ready";
5196
5803
  // provider 的模型变化由 defaults 差异或用户明确改变统一选择器决定;通知字段单独保存时
5197
5804
  // draftProviderIntent 与 sourceProvider 相同,因此不会把旧顶层 provider 强行写回行级配置。
5198
- const modelChanged = !promptOnlySave && catalogReady && (defaultsChanged || draftProviderIntent !== sourceProvider);
5805
+ // 目录门控与下拉同源(catalogAvailable):缓存在手时用户从缓存里选出的 provider+model
5806
+ // 是合法候选,必须能随这次保存一起提交,否则就成了"能选不能存";反之没有任何目录时
5807
+ // 这里仍然不写,绝不凭空写入一个无法校验的 provider。
5808
+ const modelChanged = !promptOnlySave && catalogAvailable && (defaultsChanged || draftProviderIntent !== sourceProvider);
5199
5809
 
5200
5810
  // 提示词是完整 settings 候选中的一个全局映射。只有被编辑过的角色才会从草稿
5201
5811
  // 覆盖 source;恢复默认通过删除对应键表达,避免把默认文本固化成冗余覆盖。
@@ -5261,6 +5871,11 @@ window.__ModuleLoader__.load({
5261
5871
  if (candidate.taskManagementEnabled !== source.taskManagementEnabled) {
5262
5872
  ops.push({ op: "set", path: ["taskManagementEnabled"], value: candidate.taskManagementEnabled });
5263
5873
  }
5874
+ // 自动打开与任务管理同一形状:新客户端的完整候选始终显式携带该字段,
5875
+ // Host 按「显式字段才写 patch」处理,旧客户端缺字段因此不可能覆盖它。
5876
+ if (candidate.autoOpenSubagentSidebar !== source.autoOpenSubagentSidebar) {
5877
+ ops.push({ op: "set", path: ["autoOpenSubagentSidebar"], value: candidate.autoOpenSubagentSidebar });
5878
+ }
5264
5879
  if (promptChanged) {
5265
5880
  ops.push({ op: "set", path: ["promptOverrides"], value: candidatePromptOverrides });
5266
5881
  }
@@ -5619,6 +6234,12 @@ window.__ModuleLoader__.load({
5619
6234
  react.createElement("span", { className: "deg_settings_table_head" }, "供应商"),
5620
6235
  react.createElement("span", { className: "deg_settings_table_head" }, "模型选择"),
5621
6236
  react.createElement("span", { className: "deg_settings_table_head" }, "思考深度"));
6237
+ // 行级三个下拉的**目录门控**统一走 catalogAvailable(ready 或已有缓存目录):
6238
+ // 刷新中的 loading、以及最新一次刷新失败后的 error,都只代表"内容可能不是最新",
6239
+ // 只要缓存目录还在,里面的 provider/model/思考深度就仍然是用户可选的合法值;
6240
+ // 而首次挂载(还没有任何成功目录)与 connection/reset 清空目录之后 groups 为空,
6241
+ // catalogAvailable 为 false,三个下拉和保存按钮一并禁用。
6242
+ // 业务禁用条件(saving / roleEnabled / 没有 provider、model、reasoning)保持不变。
5622
6243
  const rows = PROMPT_ROLE_IDS.map((role) => {
5623
6244
  const roleEnabled = roles.includes(role);
5624
6245
  const selection = draft.defaults[role] ?? null;
@@ -5722,7 +6343,7 @@ window.__ModuleLoader__.load({
5722
6343
  className: "deg_settings_provider_select",
5723
6344
  "aria-label": (SETTINGS_ROLE_LABELS[role] ?? role) + " 供应商",
5724
6345
  value: rowProvider,
5725
- disabled: saving || directoryState.status !== "ready" || !roleEnabled,
6346
+ disabled: saving || !catalogAvailable || !roleEnabled,
5726
6347
  onChange: updateProvider,
5727
6348
  }, providerOptions)),
5728
6349
  react.createElement("div", { className: "deg_settings_table_cell" },
@@ -5730,7 +6351,7 @@ window.__ModuleLoader__.load({
5730
6351
  className: "deg_settings_model_select",
5731
6352
  "aria-label": (SETTINGS_ROLE_LABELS[role] ?? role) + " 模型选择",
5732
6353
  value: selection?.model ?? "",
5733
- disabled: saving || directoryState.status !== "ready" || !roleEnabled || !rowProvider || rowProviderGroup === undefined,
6354
+ disabled: saving || !catalogAvailable || !roleEnabled || !rowProvider || rowProviderGroup === undefined,
5734
6355
  onChange: updateModel,
5735
6356
  }, modelOptions)),
5736
6357
  react.createElement("div", { className: "deg_settings_table_cell" },
@@ -5738,7 +6359,7 @@ window.__ModuleLoader__.load({
5738
6359
  className: "deg_settings_effort_select",
5739
6360
  "aria-label": (SETTINGS_ROLE_LABELS[role] ?? role) + " 思考深度",
5740
6361
  value: selection?.reasoningEffort ?? "",
5741
- disabled: saving || directoryState.status !== "ready" || !roleEnabled || reasoning === undefined || !selection?.model,
6362
+ disabled: saving || !catalogAvailable || !roleEnabled || reasoning === undefined || !selection?.model,
5742
6363
  onChange: updateReasoning,
5743
6364
  }, effortOptions)));
5744
6365
  });
@@ -5792,7 +6413,7 @@ window.__ModuleLoader__.load({
5792
6413
  react.createElement("select", {
5793
6414
  id: "deg-settings-provider",
5794
6415
  value: unifiedProvider,
5795
- disabled: saving || directoryState.status !== "ready",
6416
+ disabled: saving || !catalogAvailable,
5796
6417
  onChange: (event) => editDraft((previous) => {
5797
6418
  const provider = event.target.value;
5798
6419
  if (!provider) {
@@ -5828,6 +6449,17 @@ window.__ModuleLoader__.load({
5828
6449
  onChange: (event) => editDraft((previous) => ({ ...previous, taskManagementEnabled: event.target.checked })),
5829
6450
  }),
5830
6451
  react.createElement("span", { className: "deg_settings_label" }, "任务管理")),
6452
+ // 「Auto」= 自动打开运行中的子代理右侧栏会话:与任务管理同一行的紧凑开关。
6453
+ // 说明性文案只放在 title(悬浮提示)里,可见文字保持一个单词,不挤压既有控件。
6454
+ react.createElement("label", { className: "deg_settings_checkbox", title: "自动打开运行中的子代理侧栏会话" },
6455
+ react.createElement("input", {
6456
+ type: "checkbox",
6457
+ "aria-label": "Auto",
6458
+ checked: draft.autoOpenSubagentSidebar,
6459
+ disabled: saving,
6460
+ onChange: (event) => editDraft((previous) => ({ ...previous, autoOpenSubagentSidebar: event.target.checked === true })),
6461
+ }),
6462
+ react.createElement("span", { className: "deg_settings_label" }, "Auto")),
5831
6463
  react.createElement("label", { className: "deg_settings_control", htmlFor: "deg-settings-role-concurrency-limit" },
5832
6464
  react.createElement("span", { className: "deg_settings_label" }, "单个角色并发上限"),
5833
6465
  react.createElement("input", {
@@ -5856,7 +6488,7 @@ window.__ModuleLoader__.load({
5856
6488
  react.createElement("div", { className: "deg_settings_footer" },
5857
6489
  react.createElement("span", { className: "deg_settings_status", role: "status" }, saved ? "已保存" : hasUnsavedChanges ? "有未保存的修改" : ""),
5858
6490
  react.createElement("button", { type: "button", className: "deg_btn", disabled: !hasUnsavedChanges || saving, onClick: discard }, "放弃修改"),
5859
- react.createElement("button", { type: "button", className: "deg_btn", disabled: !hasUnsavedChanges || saving || directoryState.status !== "ready" || settingsSnapshot.writable !== true, onClick: save }, saving ? "保存中…" : "保存")));
6491
+ react.createElement("button", { type: "button", className: "deg_btn", disabled: !hasUnsavedChanges || saving || !catalogAvailable || settingsSnapshot.writable !== true, onClick: save }, saving ? "保存中…" : "保存")));
5860
6492
  }
5861
6493
  /**
5862
6494
  * 浏览器 FileReader 的等价字节编码:File.arrayBuffer() 取得原始字节,btoa()
@@ -6569,6 +7201,57 @@ window.__ModuleLoader__.load({
6569
7201
  slots: ctx.slots,
6570
7202
  // 浏览器 confirm 是同步 API,Panel 以 Promise/boolean 双兼容方式消费。
6571
7203
  confirm: (message) => typeof globalThis.confirm === "function" && globalThis.confirm(message) === true,
7204
+ /**
7205
+ * 「自动打开」的实时读面:面板每次同步都现读配置表单快照。
7206
+ *
7207
+ * 不在这里读取一次再当常量传下去:设置页保存后必须立刻生效,而重装整个侧边栏
7208
+ * (disposer 不可复用)只为跟随一个偏好是过度且会打断侧栏状态的做法。
7209
+ * 读取失败/字段缺失一律按关闭(历史行为)。
7210
+ */
7211
+ getAutoOpenSubagentSidebar: () => readAutoOpenSubagentSidebar(),
7212
+ /** 面板标题栏 Auto 开关的写面:必须显式提交布尔值,否则设置页会一直显示旧值。 */
7213
+ setAutoOpenSubagentSidebar: (next) => commitAutoOpenSubagentSidebar(next),
7214
+ };
7215
+ /**
7216
+ * 「自动打开运行中的子代理侧栏」读面:唯一来源是配置表单当前快照。
7217
+ *
7218
+ * 只有显式 true 才算开启——旧文档缺字段、非布尔值与读取异常全部按关闭处理,
7219
+ * 保证默认行为与历史逐字节一致(不做任何自动 open/close)。
7220
+ */
7221
+ const readAutoOpenSubagentSidebar = () => {
7222
+ try {
7223
+ const snapshot = typeof settingsForm?.getSnapshot === "function" ? settingsForm.getSnapshot() : void 0;
7224
+ return snapshot?.value?.autoOpenSubagentSidebar === true;
7225
+ } catch {
7226
+ return false;
7227
+ }
7228
+ };
7229
+ /**
7230
+ * 写面:把表单快照原样作为整体候选,只翻转 autoOpenSubagentSidebar 一个字段。
7231
+ *
7232
+ * 走的是设置页同一条 settings-commit(完整候选 + expectedRevision CAS),因此
7233
+ * 宿主的语义校验与并发栅栏照常生效;这里**不**重放设置页草稿,避免把未保存的
7234
+ * 编辑顺带提交。只读文档、快照不可用、校验失败或传输失败都返回 false,面板据此
7235
+ * 回退到权威读面(开关不会停在错误状态)。
7236
+ */
7237
+ const commitAutoOpenSubagentSidebar = async (next) => {
7238
+ try {
7239
+ const snapshot = typeof settingsForm?.getSnapshot === "function" ? settingsForm.getSnapshot() : void 0;
7240
+ if (snapshot?.writable !== true) return false;
7241
+ const value = snapshot.value;
7242
+ if (value === null || typeof value !== "object") return false;
7243
+ await commitSettingsCandidate(
7244
+ { ...value, autoOpenSubagentSidebar: next === true },
7245
+ snapshot.revision,
7246
+ globalThis.fetch,
7247
+ ctx?.connection,
7248
+ PROMPT_ROLE_IDS,
7249
+ );
7250
+ return true;
7251
+ } catch {
7252
+ // 失败原因(只读/目录校验/修订冲突)由设置页承担呈现;面板只回退开关状态。
7253
+ return false;
7254
+ }
6572
7255
  };
6573
7256
  const readTaskManagementEnabled = () => {
6574
7257
  try {