dsh-connect-workbuddy 4.2.0 → 4.3.1

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/lib/client.js CHANGED
@@ -309,6 +309,23 @@ window.__ModuleLoader__.load({
309
309
  * fallback and must not import Node-only host code.
310
310
  */
311
311
  const WORKBUDDY_SETTINGS_NS = "workbuddy";
312
+ /**
313
+ * Every namespace spelling the browser half follows when looking for the
314
+ * settings form the Host actually serves, in preference order.
315
+ *
316
+ * `describe()` keys a form by its Loader entry id, and the profile patch picks
317
+ * that id — so the plugin must ASK rather than assume. The observed spellings
318
+ * are the bare package name, the `include:`-wrapped id the live Desktop host
319
+ * uses, and the declared name as the last resort. `configForms.whileServed`
320
+ * follows all of them and re-runs when the mirror arrives, which is what keeps
321
+ * the binding correct on a host whose mirror populates after `apply` (the
322
+ * 4.3.0 readout defect, issue #46).
323
+ */
324
+ const WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES = [
325
+ "dsh-connect-workbuddy",
326
+ "include:dsh-connect-workbuddy",
327
+ WORKBUDDY_SETTINGS_NS
328
+ ];
312
329
  //#endregion
313
330
  //#region src/client/account-selection.ts
314
331
  /**
@@ -649,6 +666,40 @@ window.__ModuleLoader__.load({
649
666
  }
650
667
  }
651
668
  //#endregion
669
+ //#region src/client/BusyOverlay.tsx
670
+ /**
671
+ * The veil. Deliberately NOT a modal dialog: it carries no focusable control
672
+ * and no dismissal — there is nothing for the user to decide, only a step to
673
+ * wait through. `aria-busy` plus a polite live region state that to screen
674
+ * readers, which is the whole accessibility contract a wait screen needs.
675
+ */
676
+ function BusyOverlay({ message, hint, visible }) {
677
+ if (!visible) return null;
678
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
679
+ className: "dsm-workbuddy-veil",
680
+ role: "status",
681
+ "aria-busy": "true",
682
+ "aria-live": "polite",
683
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
684
+ className: "dsm-workbuddy-veil-card",
685
+ children: [
686
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
687
+ className: "dsm-workbuddy-veil-spinner",
688
+ "aria-hidden": "true"
689
+ }),
690
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
691
+ className: "dsm-workbuddy-veil-text",
692
+ children: message
693
+ }),
694
+ hint === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
695
+ className: "dsm-workbuddy-veil-hint",
696
+ children: hint
697
+ })
698
+ ]
699
+ })
700
+ });
701
+ }
702
+ //#endregion
652
703
  //#region src/client/ndjson.ts
653
704
  /**
654
705
  * Read a newline-delimited JSON body, one object at a time.
@@ -1151,6 +1202,24 @@ window.__ModuleLoader__.load({
1151
1202
  memberAccountIds: pool?.memberAccountIds ?? []
1152
1203
  };
1153
1204
  const active = draftRegion === region && draft !== null ? draft : saved;
1205
+ /**
1206
+ * Whether the pool is RUNNING right now, which is the SAVED value.
1207
+ *
1208
+ * `active` is the user's draft and only becomes reality at Save. Using it for
1209
+ * the parts that DESCRIBE the running pool made the card state a mode that had
1210
+ * not taken effect yet: flipping the switch off (unsaved) revealed the manual
1211
+ * picker while the pool was still ranking every request, so the picker showed
1212
+ * the rotated account as "in use" and a selection made there was overridden by
1213
+ * the very pool the user was looking at — the "shows A, bills B" trap this
1214
+ * section keeps guarding against. The mirror case was milder but the same
1215
+ * class of wrong: turning the pool ON (unsaved) hid the picker while the manual
1216
+ * account was still the one being billed.
1217
+ *
1218
+ * Everything that reports pool state reads this; only the switch itself (its
1219
+ * controlled value) and `dirty` read the draft, because those two ARE about
1220
+ * the pending edit.
1221
+ */
1222
+ const poolRunning = saved.enabled;
1154
1223
  const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1155
1224
  /**
1156
1225
  * Membership that actually resolves to a local sign-in — what a batch runs on.
@@ -1405,6 +1474,14 @@ window.__ModuleLoader__.load({
1405
1474
  /** The account whose per-row action is in flight; gates every row's buttons. */
1406
1475
  const [rowBusyAccountId, setRowBusyAccountId] = (0, react.useState)(void 0);
1407
1476
  /**
1477
+ * Which per-row action is running, for the veil's scenario line.
1478
+ *
1479
+ * A removal and a test both set `rowBusyAccountId`, but they mean different
1480
+ * things to the person watching ("this account is going away" versus "this
1481
+ * account is being measured") — and the veil exists precisely to say which.
1482
+ */
1483
+ const [rowBusyAction, setRowBusyAction] = (0, react.useState)(void 0);
1484
+ /**
1408
1485
  * Test ONE account from its row.
1409
1486
  *
1410
1487
  * Same Host rule as the batch (testOne reuses the batch's probe), the same
@@ -1413,6 +1490,7 @@ window.__ModuleLoader__.load({
1413
1490
  */
1414
1491
  const runRowTest = (0, react.useCallback)(async (accountId) => {
1415
1492
  setRowBusyAccountId(accountId);
1493
+ setRowBusyAction("test");
1416
1494
  setActionError(void 0);
1417
1495
  const url = withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, "test") + "&accountId=" + encodeURIComponent(accountId);
1418
1496
  try {
@@ -1447,7 +1525,10 @@ window.__ModuleLoader__.load({
1447
1525
  appendLog(t("row.poolLogBatchFailed", { message }), "error");
1448
1526
  }
1449
1527
  } finally {
1450
- if (mounted.current) setRowBusyAccountId(void 0);
1528
+ if (mounted.current) {
1529
+ setRowBusyAccountId(void 0);
1530
+ setRowBusyAction(void 0);
1531
+ }
1451
1532
  }
1452
1533
  }, [
1453
1534
  appendLog,
@@ -1456,12 +1537,17 @@ window.__ModuleLoader__.load({
1456
1537
  t
1457
1538
  ]);
1458
1539
  /**
1459
- * Remove ONE account's plugin-stored credential from its row. The Host only
1460
- * ever deletes the vault entry; a desktop sign-in reappears on the next
1461
- * rescan, and the message below says which of the two happened.
1540
+ * Remove ONE account from its row, via the Host.
1541
+ *
1542
+ * The Host dismisses the account (and deletes its vault entry when it has
1543
+ * one), so the row stays gone across rescans. The desktop app is not signed
1544
+ * out. The message below still distinguishes the two cases, because "there
1545
+ * was no plugin-stored copy" is worth saying: it tells the user the account
1546
+ * comes from the app itself rather than from an OAuth sign-in or an import.
1462
1547
  */
1463
1548
  const runRowRemove = (0, react.useCallback)(async (accountId, accountName) => {
1464
1549
  setRowBusyAccountId(accountId);
1550
+ setRowBusyAction("remove");
1465
1551
  setActionError(void 0);
1466
1552
  const url = withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, "remove") + "&accountId=" + encodeURIComponent(accountId);
1467
1553
  try {
@@ -1478,7 +1564,7 @@ window.__ModuleLoader__.load({
1478
1564
  }));
1479
1565
  if (!mounted.current) return;
1480
1566
  appendLog(body?.removed === true ? t("row.poolRowRemoved", { accountName: accountName === "" ? t("row.accountUnnamed") : accountName }) : t("row.poolRowRemoveDesktopOnly", { accountName: accountName === "" ? t("row.accountUnnamed") : accountName }), body?.removed === true ? "ok" : "info");
1481
- onRefresh?.();
1567
+ await onRefresh?.();
1482
1568
  } catch (error) {
1483
1569
  const message = error instanceof Error ? error.message : t("row.requestFailed");
1484
1570
  if (mounted.current) {
@@ -1486,7 +1572,10 @@ window.__ModuleLoader__.load({
1486
1572
  appendLog(t("row.poolLogBatchFailed", { message }), "error");
1487
1573
  }
1488
1574
  } finally {
1489
- if (mounted.current) setRowBusyAccountId(void 0);
1575
+ if (mounted.current) {
1576
+ setRowBusyAccountId(void 0);
1577
+ setRowBusyAction(void 0);
1578
+ }
1490
1579
  }
1491
1580
  }, [
1492
1581
  appendLog,
@@ -1517,7 +1606,15 @@ window.__ModuleLoader__.load({
1517
1606
  */
1518
1607
  const currentAccount = pool.accounts.find((account) => account.current);
1519
1608
  const currentName = currentAccount === void 0 ? void 0 : currentAccount.accountName === "" ? t("row.accountUnnamed") : currentAccount.accountName;
1520
- return (0, react.createElement)("section", { className: "dsm-workbuddy-pool" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !active.enabled && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
1609
+ return (0, react.createElement)("section", { className: "dsm-workbuddy-pool" }, rowBusyAction === "remove" ? (0, react.createElement)(BusyOverlay, {
1610
+ visible: true,
1611
+ message: t("row.poolRowRemoving"),
1612
+ hint: t("row.veilHintList")
1613
+ }) : null, saving ? (0, react.createElement)(BusyOverlay, {
1614
+ visible: true,
1615
+ message: t("row.poolSaving"),
1616
+ hint: t("row.veilHintList")
1617
+ }) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
1521
1618
  className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
1522
1619
  value: currentAccount?.accountId ?? "",
1523
1620
  disabled: selectingAccount === true,
@@ -1550,7 +1647,7 @@ window.__ModuleLoader__.load({
1550
1647
  className: "dsm-btn dsm-btn-outline",
1551
1648
  disabled: rescanning === true,
1552
1649
  onClick: onRescan
1553
- }, rescanning === true ? t("row.accountsScanning") : t("row.accountsRescan")))), !active.enabled ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolEnabledHint")) : null, active.enabled && usable.length === 0 ? (0, react.createElement)("p", {
1650
+ }, rescanning === true ? t("row.accountsScanning") : t("row.accountsRescan")))), !poolRunning ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolEnabledHint")) : null, poolRunning && usable.length === 0 ? (0, react.createElement)("p", {
1554
1651
  className: "dsm-workbuddy-pool-warn",
1555
1652
  role: "status"
1556
1653
  }, `${t("row.poolNoCandidate")} ${t("row.poolNoCandidateHint")}`) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members-head" }, (0, react.createElement)("div", null, (0, react.createElement)("h4", { className: "dsm-workbuddy-pool-members-title" }, t("row.poolMembersTitle")), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolMembersHint"))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members-actions" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, t("row.poolSelectedCount", {
@@ -1773,19 +1870,22 @@ window.__ModuleLoader__.load({
1773
1870
  //#endregion
1774
1871
  //#region src/client/OAuthSignIn.tsx
1775
1872
  /**
1776
- * The card's OAuth QR sign-in section.
1777
- *
1778
- * 参考:扫码登录的通行形态 —— 发起登录 → 打开授权页 →
1779
- * 轮询到完成为止 → 展示结果。本实现把轮询循环压成一个组件内 effect:
1780
- * 打开授权页的是用户点击的链接(浏览器标签页,非弹窗),组件只负责
1781
- * 按 `OAUTH_POLL_INTERVAL_MS` 节奏问 Host「完成了吗」,完成即停。
1782
- *
1783
- * 为什么不用 window.open:卡片运行在 Harness 的设置页 iframe/沙箱里,弹窗
1784
- * 会被拦截;把授权 URL 渲染成普通链接(target=_blank rel=noopener)是
1785
- * 沙箱内最可靠的方式,用户也可以复制链接到手机上继续(URL 即登录态)。
1786
- *
1787
- * 所有状态就地管理,零跨组件往返:取消 = 停止轮询 + 清理 loginId;
1788
- * Host 重启导致 loginId 失效会得到 done+error,与超时同一条文案路径。
1873
+ * The card's OAuth QR sign-in section: one resting bar row plus a dialog
1874
+ * opened by the sign-in button.
1875
+ *
1876
+ * 参考:workbuddy2api-panel 的「添加账号」弹窗交互形态 —— 静态入口一行,
1877
+ * 点开后弹窗内分步走:获取授权链接 → 自动打开浏览器 → 弹窗内显示链接
1878
+ * 与等待状态 → 完成/失败回填结果。本实现沿用该步骤划分;弹窗样式直接
1879
+ * 用本项目导出/导入弹框的类(dsm-workbuddy-transfer-dialog),不引入
1880
+ * 第二套视觉。
1881
+ * 改动:自动打开浏览器。弹窗拦截的规避方式是「用户手势内同步 window.open
1882
+ * 占位,拿到授权 URL 后再导航」——点击「获取授权链接」的那一刻浏览器
1883
+ * 仍认定这是用户激活的手势链,占位窗口不会被拦;若 Host 环境连占位都
1884
+ * 拦(返回 null),降级为弹窗内手动「打开登录页」链接,功能不损失。
1885
+ *
1886
+ * 轮询循环压成一个组件内 effect:按 `OAUTH_POLL_INTERVAL_MS` 节奏问 Host
1887
+ * 「完成了吗」,完成即停。所有状态就地管理,零跨组件往返;Host 重启
1888
+ * 导致 loginId 失效会得到 done+error,与超时同一条文案路径。
1789
1889
  *
1790
1890
  * @module dsh-connect-workbuddy/client/OAuthSignIn
1791
1891
  */
@@ -1793,13 +1893,24 @@ window.__ModuleLoader__.load({
1793
1893
  function elapsedSeconds(sinceMs, nowMs) {
1794
1894
  return Math.max(0, Math.floor((nowMs - sinceMs) / 1e3));
1795
1895
  }
1896
+ /** Copy the sign-in URL to the clipboard; true on success. */
1897
+ async function copyUrl(url) {
1898
+ try {
1899
+ await navigator.clipboard.writeText(url);
1900
+ return true;
1901
+ } catch {
1902
+ return false;
1903
+ }
1904
+ }
1796
1905
  /**
1797
1906
  * The section. Rendered on BOTH sign-in states: a signed-out machine gains
1798
1907
  * its first account here; a signed-in one can add another without leaving
1799
1908
  * the card.
1800
1909
  */
1801
- function OAuthSignIn({ t, region }) {
1910
+ function OAuthSignIn({ t, region, onAdded }) {
1802
1911
  const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
1912
+ const [dialogOpen, setDialogOpen] = (0, react.useState)(false);
1913
+ const [copied, setCopied] = (0, react.useState)(false);
1803
1914
  const [pollTick, setPollTick] = (0, react.useState)(0);
1804
1915
  const [nowMs, setNowMs] = (0, react.useState)(() => Date.now());
1805
1916
  const mounted = (0, react.useRef)(true);
@@ -1826,7 +1937,11 @@ window.__ModuleLoader__.load({
1826
1937
  return () => {
1827
1938
  clearInterval(timer);
1828
1939
  };
1829
- }, [waiting, phase]);
1940
+ }, [
1941
+ waiting,
1942
+ phase,
1943
+ t
1944
+ ]);
1830
1945
  (0, react.useEffect)(() => {
1831
1946
  if (phase.kind !== "waiting") return;
1832
1947
  const { loginId } = phase;
@@ -1862,11 +1977,20 @@ window.__ModuleLoader__.load({
1862
1977
  message: "error",
1863
1978
  error: body.error
1864
1979
  });
1865
- else setPhase({
1866
- kind: "finished",
1867
- message: "added",
1868
- accountName: body.accountName ?? ""
1869
- });
1980
+ else {
1981
+ const accountName = body.accountName ?? "";
1982
+ setPhase({
1983
+ kind: "syncing",
1984
+ accountName
1985
+ });
1986
+ await onAdded?.();
1987
+ if (cancelled || !mounted.current) return;
1988
+ setPhase({
1989
+ kind: "finished",
1990
+ message: "added",
1991
+ accountName
1992
+ });
1993
+ }
1870
1994
  } catch {
1871
1995
  setPollTick((tick) => tick + 1);
1872
1996
  }
@@ -1877,12 +2001,47 @@ window.__ModuleLoader__.load({
1877
2001
  clearTimeout(timer);
1878
2002
  };
1879
2003
  }, [
2004
+ onAdded,
1880
2005
  phase,
1881
2006
  pollTick,
1882
2007
  region
1883
2008
  ]);
1884
- const start = async () => {
2009
+ /**
2010
+ * Open a placeholder window WITHOUT `noopener`, and deliberately so.
2011
+ *
2012
+ * `noopener` is the correct feature for a link whose destination never needs
2013
+ * a handle — and that is exactly why it is wrong here. Per the spec, a
2014
+ * `window.open` call carrying `noopener` opens the tab but returns `null`
2015
+ * (MDN: "the new window will not have access to the originating window via
2016
+ * `Window.opener` and returns `null`"). The placeholder exists ONLY to be
2017
+ * navigated once the Host answers, so the handle IS the point: passing
2018
+ * `noopener` made `placeholder` null, the navigation was skipped, and every
2019
+ * sign-in left an empty `about:blank` tab behind instead of the sign-in page.
2020
+ *
2021
+ * The security property is kept, just established on the handle instead of
2022
+ * through the feature: `opener = null` is applied to the placeholder before
2023
+ * anything is loaded into it, so the destination still cannot reach back
2024
+ * through `window.opener`. `noreferrer` is dropped for the same reason —
2025
+ * the spec makes it imply `noopener`, so it would reintroduce the null.
2026
+ * Nothing is lost: the placeholder is same-origin and empty until navigated,
2027
+ * and the referrer would only ever have been the settings page.
2028
+ */
2029
+ function openPlaceholder() {
2030
+ const placeholder = window.open("", "_blank");
2031
+ if (placeholder !== null) placeholder.opener = null;
2032
+ return placeholder;
2033
+ }
2034
+ /**
2035
+ * Fetch the authorization URL AND open the browser in one gesture chain.
2036
+ * The placeholder window is opened synchronously — inside the click's user
2037
+ * activation, before any await — so popup blocking cannot intervene; the
2038
+ * URL is navigated once the Host answers. A blocked placeholder (null)
2039
+ * degrades to the manual link inside the dialog, losing nothing but the
2040
+ * convenience.
2041
+ */
2042
+ const start = async (placeholder) => {
1885
2043
  setPhase({ kind: "opening" });
2044
+ setCopied(false);
1886
2045
  try {
1887
2046
  const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_OAUTH_PATH, region, "start"), {
1888
2047
  method: "POST",
@@ -1890,8 +2049,12 @@ window.__ModuleLoader__.load({
1890
2049
  credentials: "same-origin"
1891
2050
  });
1892
2051
  const body = await response.json();
1893
- if (!mounted.current) return;
2052
+ if (!mounted.current) {
2053
+ placeholder?.close();
2054
+ return;
2055
+ }
1894
2056
  if (!response.ok || typeof body.loginId !== "string" || typeof body.verificationUri !== "string") {
2057
+ placeholder?.close();
1895
2058
  setPhase({
1896
2059
  kind: "finished",
1897
2060
  message: "error",
@@ -1899,6 +2062,7 @@ window.__ModuleLoader__.load({
1899
2062
  });
1900
2063
  return;
1901
2064
  }
2065
+ if (placeholder !== null) placeholder.location.href = body.verificationUri;
1902
2066
  setPhase({
1903
2067
  kind: "waiting",
1904
2068
  loginId: body.loginId,
@@ -1908,6 +2072,7 @@ window.__ModuleLoader__.load({
1908
2072
  });
1909
2073
  setNowMs(Date.now());
1910
2074
  } catch (error) {
2075
+ placeholder?.close();
1911
2076
  if (mounted.current) setPhase({
1912
2077
  kind: "finished",
1913
2078
  message: "error",
@@ -1915,72 +2080,133 @@ window.__ModuleLoader__.load({
1915
2080
  });
1916
2081
  }
1917
2082
  };
1918
- if (phase.kind === "finished") {
1919
- const text = phase.message === "added" ? t("row.oauthDone", { accountName: phase.accountName === "" || phase.accountName === void 0 ? t("row.accountUnnamed") : phase.accountName }) : phase.message === "cancelled" ? t("row.oauthCancelled") : t("row.oauthError", { message: phase.error ?? "" });
1920
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1921
- className: "dsm-workbuddy-bar",
1922
- "aria-label": t("row.oauthTitle"),
1923
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1924
- className: `dsm-workbuddy-bar-status ${phase.message === "added" ? "dsm-workbuddy-bar-ok" : "dsm-workbuddy-bar-error"}`,
1925
- children: text
1926
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1927
- type: "button",
1928
- className: "dsm-btn dsm-btn-outline",
1929
- onClick: () => {
1930
- setPhase({ kind: "idle" });
1931
- },
1932
- children: t("row.oauthStart")
1933
- })]
1934
- });
1935
- }
2083
+ /** Open the dialog and begin the login in the same click: the placeholder
2084
+ * window must be created synchronously to count as a user gesture. */
2085
+ const beginSignIn = () => {
2086
+ setDialogOpen(true);
2087
+ start(openPlaceholder());
2088
+ };
2089
+ /** Re-open the sign-in page from the waiting dialog (no new session).
2090
+ *
2091
+ * Here `noopener,noreferrer` IS right: this is a one-way navigation to a
2092
+ * third-party page and nothing needs a handle on it — the contrast with
2093
+ * {@link openPlaceholder} is the whole distinction. */
2094
+ const reopen = () => {
2095
+ if (phase.kind !== "waiting") return;
2096
+ window.open(phase.verificationUri, "_blank", "noopener,noreferrer");
2097
+ };
2098
+ /**
2099
+ * The three strings that NAME the sign-in method, per region.
2100
+ *
2101
+ * CN signs in by scanning a QR code with the WorkBuddy app; the international
2102
+ * build signs in on a web page. Every other line in this section (waiting,
2103
+ * cancel, done, copy) is about the flow, not the method, so it stays shared —
2104
+ * splitting it too would let the two regions drift apart for no gain.
2105
+ */
2106
+ const titleKey = region === "cn" ? "row.oauthTitle" : "row.oauthTitleGlobal";
2107
+ const startKey = region === "cn" ? "row.oauthStart" : "row.oauthStartGlobal";
2108
+ const finishText = phase.kind === "finished" ? phase.message === "added" ? t("row.oauthDone", { accountName: phase.accountName === "" || phase.accountName === void 0 ? t("row.accountUnnamed") : phase.accountName }) : phase.message === "cancelled" ? t("row.oauthCancelled") : t("row.oauthError", { message: phase.error ?? "" }) : "";
1936
2109
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1937
2110
  className: "dsm-workbuddy-bar",
1938
- "aria-label": t("row.oauthTitle"),
1939
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1940
- className: "dsm-workbuddy-bar-label",
1941
- children: t("row.oauthTitle")
1942
- }), phase.kind === "waiting" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1943
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1944
- className: "dsm-workbuddy-bar-status",
1945
- children: [
1946
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
1947
- href: phase.verificationUri,
1948
- target: "_blank",
1949
- rel: "noopener noreferrer",
1950
- children: t("row.oauthOpen")
1951
- }),
1952
- " — ",
1953
- t("row.oauthOpened")
1954
- ]
2111
+ "aria-label": t(titleKey),
2112
+ children: [
2113
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BusyOverlay, {
2114
+ visible: phase.kind === "syncing",
2115
+ message: t("row.oauthSyncing"),
2116
+ hint: t("row.veilHintList")
1955
2117
  }),
1956
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1957
- className: "dsm-workbuddy-bar-hint",
1958
- children: [
1959
- t("row.oauthWaiting", { seconds: elapsedSeconds(phase.startedAtMs, nowMs) }),
1960
- " ",
1961
- t("row.oauthExpires", { minutes: Math.max(1, Math.round((phase.expiresAtMs - nowMs) / 6e4)) })
1962
- ]
2118
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2119
+ className: "dsm-workbuddy-bar-label",
2120
+ children: t(titleKey)
1963
2121
  }),
1964
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1965
- type: "button",
1966
- className: "dsm-btn dsm-btn-outline",
1967
- onClick: () => {
1968
- setPhase({
1969
- kind: "finished",
1970
- message: "cancelled"
1971
- });
1972
- },
1973
- children: t("row.oauthCancel")
1974
- })
1975
- ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1976
- type: "button",
1977
- className: "dsm-btn dsm-btn-outline",
1978
- disabled: phase.kind === "opening",
1979
- onClick: () => {
1980
- start();
1981
- },
1982
- children: phase.kind === "opening" ? t("row.oauthStarting") : t("row.oauthStart")
1983
- })]
2122
+ phase.kind === "finished" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2123
+ className: `dsm-workbuddy-bar-status ${phase.message === "added" ? "dsm-workbuddy-bar-ok" : "dsm-workbuddy-bar-error"}`,
2124
+ children: finishText
2125
+ }) : phase.kind === "waiting" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2126
+ className: "dsm-workbuddy-bar-status",
2127
+ children: t("row.oauthOpened")
2128
+ }) : null,
2129
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2130
+ className: "dsm-workbuddy-bar-actions",
2131
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2132
+ type: "button",
2133
+ className: "dsm-btn dsm-btn-outline",
2134
+ disabled: phase.kind === "opening",
2135
+ onClick: beginSignIn,
2136
+ children: phase.kind === "opening" ? t("row.oauthStarting") : t(startKey)
2137
+ })
2138
+ }),
2139
+ dialogOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2140
+ className: "dsm-workbuddy-transfer-dialog",
2141
+ role: "dialog",
2142
+ "aria-label": t(titleKey),
2143
+ children: phase.kind === "opening" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.oauthStarting") }) : phase.kind === "waiting" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
2144
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.oauthOpenHint") }),
2145
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2146
+ className: "dsm-workbuddy-oauth-url",
2147
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2148
+ href: phase.verificationUri,
2149
+ target: "_blank",
2150
+ rel: "noopener noreferrer",
2151
+ children: phase.verificationUri
2152
+ })
2153
+ }),
2154
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2155
+ className: "dsm-workbuddy-hint",
2156
+ children: [
2157
+ t("row.oauthWaiting", { seconds: elapsedSeconds(phase.startedAtMs, nowMs) }),
2158
+ " ",
2159
+ t("row.oauthExpires", { minutes: Math.max(1, Math.round((phase.expiresAtMs - nowMs) / 6e4)) })
2160
+ ]
2161
+ }),
2162
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2163
+ className: "dsm-workbuddy-transfer-dialog-actions",
2164
+ children: [
2165
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2166
+ type: "button",
2167
+ className: "dsm-btn dsm-btn-outline",
2168
+ onClick: () => {
2169
+ copyUrl(phase.verificationUri).then((ok) => {
2170
+ if (mounted.current) setCopied(ok);
2171
+ });
2172
+ },
2173
+ children: copied ? t("row.oauthCopied") : t("row.oauthCopy")
2174
+ }),
2175
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2176
+ type: "button",
2177
+ className: "dsm-btn dsm-btn-outline",
2178
+ onClick: reopen,
2179
+ children: t("row.oauthReopen")
2180
+ }),
2181
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2182
+ type: "button",
2183
+ className: "dsm-btn dsm-btn-outline",
2184
+ onClick: () => {
2185
+ setPhase({
2186
+ kind: "finished",
2187
+ message: "cancelled"
2188
+ });
2189
+ },
2190
+ children: t("row.oauthCancel")
2191
+ })
2192
+ ]
2193
+ })
2194
+ ] }) : phase.kind === "finished" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2195
+ className: `${phase.message === "added" ? "dsm-workbuddy-bar-ok" : phase.message === "error" ? "dsm-workbuddy-bar-error" : ""}`,
2196
+ children: finishText
2197
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2198
+ className: "dsm-workbuddy-transfer-dialog-actions",
2199
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2200
+ type: "button",
2201
+ className: "dsm-btn dsm-btn-outline",
2202
+ onClick: () => {
2203
+ setDialogOpen(false);
2204
+ },
2205
+ children: t("row.oauthDialogClose")
2206
+ })
2207
+ })] }) : null
2208
+ }) : null
2209
+ ]
1984
2210
  });
1985
2211
  }
1986
2212
  //#endregion
@@ -2022,13 +2248,24 @@ window.__ModuleLoader__.load({
2022
2248
  /** The section. Rendered on both sign-in states — a signed-out machine can
2023
2249
  * still import a file exported elsewhere; export without accounts answers
2024
2250
  * the error line. */
2025
- function AccountTransfer({ t, region, accounts }) {
2251
+ function AccountTransfer({ t, region, accounts, onChanged }) {
2026
2252
  const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
2027
2253
  const [dialog, setDialog] = (0, react.useState)(void 0);
2028
2254
  const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
2029
2255
  const [preview, setPreview] = (0, react.useState)(void 0);
2030
2256
  const [importSelected, setImportSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
2031
2257
  const fileInput = (0, react.useRef)(null);
2258
+ /**
2259
+ * The text of the file the user just picked.
2260
+ *
2261
+ * Held here rather than re-read from the input at import time: the picker
2262
+ * is a bare system dialog that closes immediately (see the import button),
2263
+ * so between preview and confirm there is no dialog on screen holding the
2264
+ * File. Re-reading `fileInput.current.files` would also break the moment
2265
+ * anything clears the input — and it must be cleared after every pick, or
2266
+ * choosing the SAME file twice fires no change event.
2267
+ */
2268
+ const importText = (0, react.useRef)("");
2032
2269
  const mounted = (0, react.useRef)(true);
2033
2270
  (0, react.useEffect)(() => {
2034
2271
  mounted.current = true;
@@ -2084,6 +2321,7 @@ window.__ModuleLoader__.load({
2084
2321
  setPhase({ kind: "importing" });
2085
2322
  try {
2086
2323
  const text = await file.text();
2324
+ importText.current = text;
2087
2325
  const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "preview"), {
2088
2326
  method: "POST",
2089
2327
  headers: {
@@ -2113,10 +2351,10 @@ window.__ModuleLoader__.load({
2113
2351
  }
2114
2352
  };
2115
2353
  const runImport = async () => {
2116
- if (preview === void 0 || importSelected.size === 0 || fileInput.current?.files?.[0] === void 0) return;
2354
+ if (preview === void 0 || importSelected.size === 0 || importText.current === "") return;
2117
2355
  setPhase({ kind: "importing" });
2118
2356
  try {
2119
- const text = await fileInput.current.files[0].text();
2357
+ const text = importText.current;
2120
2358
  const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "import"), {
2121
2359
  method: "POST",
2122
2360
  headers: {
@@ -2139,12 +2377,19 @@ window.__ModuleLoader__.load({
2139
2377
  return;
2140
2378
  }
2141
2379
  closeDialog();
2380
+ const message = t("row.transferImported", {
2381
+ imported: body.imported,
2382
+ skipped: body.skipped ?? 0
2383
+ });
2384
+ setPhase({
2385
+ kind: "refreshing",
2386
+ message
2387
+ });
2388
+ await onChanged?.();
2389
+ if (!mounted.current) return;
2142
2390
  setPhase({
2143
2391
  kind: "done",
2144
- message: t("row.transferImported", {
2145
- imported: body.imported,
2146
- skipped: body.skipped ?? 0
2147
- })
2392
+ message
2148
2393
  });
2149
2394
  } catch (error) {
2150
2395
  if (mounted.current) setPhase({
@@ -2153,11 +2398,18 @@ window.__ModuleLoader__.load({
2153
2398
  });
2154
2399
  }
2155
2400
  };
2156
- const busy = phase.kind === "exporting" || phase.kind === "importing";
2401
+ const busy = phase.kind === "exporting" || phase.kind === "importing" || phase.kind === "refreshing";
2402
+ /** The veil's scenario line: what is happening, in the user's terms. */
2403
+ const veilMessage = phase.kind === "exporting" ? t("row.transferExporting") : phase.kind === "importing" || phase.kind === "refreshing" ? t("row.transferImporting") : "";
2157
2404
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2158
2405
  className: "dsm-workbuddy-bar",
2159
2406
  "aria-label": t("row.transferTitle"),
2160
2407
  children: [
2408
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BusyOverlay, {
2409
+ visible: busy,
2410
+ message: veilMessage,
2411
+ ...busy ? { hint: t("row.veilHintList") } : {}
2412
+ }),
2161
2413
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2162
2414
  className: "dsm-workbuddy-bar-label",
2163
2415
  children: t("row.transferTitle")
@@ -2185,13 +2437,27 @@ window.__ModuleLoader__.load({
2185
2437
  className: "dsm-btn dsm-btn-outline",
2186
2438
  disabled: busy,
2187
2439
  onClick: () => {
2188
- setPreview(void 0);
2189
- setImportSelected(/* @__PURE__ */ new Set());
2190
- setDialog("import");
2440
+ fileInput.current?.click();
2191
2441
  },
2192
2442
  children: t("row.transferImport")
2193
2443
  })]
2194
2444
  }),
2445
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2446
+ ref: fileInput,
2447
+ type: "file",
2448
+ accept: "application/json,.json",
2449
+ className: "dsm-workbuddy-transfer-file",
2450
+ onChange: (event) => {
2451
+ const file = event.target.files?.[0];
2452
+ event.target.value = "";
2453
+ if (file !== void 0) {
2454
+ setPreview(void 0);
2455
+ setImportSelected(/* @__PURE__ */ new Set());
2456
+ setDialog("import");
2457
+ readImportFile(file);
2458
+ }
2459
+ }
2460
+ }),
2195
2461
  dialog === "export" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2196
2462
  className: "dsm-workbuddy-transfer-dialog",
2197
2463
  role: "dialog",
@@ -2238,62 +2504,49 @@ window.__ModuleLoader__.load({
2238
2504
  })
2239
2505
  ]
2240
2506
  }) : null,
2241
- dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2507
+ dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2242
2508
  className: "dsm-workbuddy-transfer-dialog",
2243
2509
  role: "dialog",
2244
2510
  "aria-label": t("row.transferImport"),
2245
- children: [
2246
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferImportPick") }),
2247
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2248
- ref: fileInput,
2249
- type: "file",
2250
- accept: "application/json,.json",
2251
- disabled: busy,
2252
- onChange: (event) => {
2253
- const file = event.target.files?.[0];
2254
- if (file !== void 0) readImportFile(file);
2255
- }
2511
+ children: preview === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferPreviewing") }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
2512
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferPreviewTotal", { total: preview.total }) }),
2513
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2514
+ className: "dsm-workbuddy-transfer-list",
2515
+ children: preview.accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
2516
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2517
+ type: "checkbox",
2518
+ disabled: !entry.hasToken,
2519
+ checked: importSelected.has(entry.index),
2520
+ onChange: (event) => {
2521
+ const next = new Set(importSelected);
2522
+ if (event.target.checked) next.add(entry.index);
2523
+ else next.delete(entry.index);
2524
+ setImportSelected(next);
2525
+ }
2526
+ }),
2527
+ " ",
2528
+ entry.nickname !== "" ? entry.nickname : entry.email !== "" ? entry.email : entry.uid !== "" ? entry.uid : t("row.transferEntryUnknown"),
2529
+ entry.hasToken ? "" : ` — ${t("row.transferNoToken")}`
2530
+ ] }) }, entry.index))
2256
2531
  }),
2257
- preview === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
2258
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferPreviewTotal", { total: preview.total }) }),
2259
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2260
- className: "dsm-workbuddy-transfer-list",
2261
- children: preview.accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
2262
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2263
- type: "checkbox",
2264
- disabled: !entry.hasToken,
2265
- checked: importSelected.has(entry.index),
2266
- onChange: (event) => {
2267
- const next = new Set(importSelected);
2268
- if (event.target.checked) next.add(entry.index);
2269
- else next.delete(entry.index);
2270
- setImportSelected(next);
2271
- }
2272
- }),
2273
- " ",
2274
- entry.nickname !== "" ? entry.nickname : entry.email !== "" ? entry.email : entry.uid !== "" ? entry.uid : t("row.transferEntryUnknown"),
2275
- entry.hasToken ? "" : ` — ${t("row.transferNoToken")}`
2276
- ] }) }, entry.index))
2277
- }),
2278
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2279
- className: "dsm-workbuddy-transfer-dialog-actions",
2280
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2281
- type: "button",
2282
- className: "dsm-btn dsm-btn-outline",
2283
- onClick: closeDialog,
2284
- children: t("row.transferCancel")
2285
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2286
- type: "button",
2287
- className: "dsm-btn dsm-btn-outline",
2288
- disabled: importSelected.size === 0 || phase.kind === "importing",
2289
- onClick: () => {
2290
- runImport();
2291
- },
2292
- children: phase.kind === "importing" ? t("row.transferImporting") : t("row.transferImportConfirm", { count: importSelected.size })
2293
- })]
2294
- })
2295
- ] })
2296
- ]
2532
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2533
+ className: "dsm-workbuddy-transfer-dialog-actions",
2534
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2535
+ type: "button",
2536
+ className: "dsm-btn dsm-btn-outline",
2537
+ onClick: closeDialog,
2538
+ children: t("row.transferCancel")
2539
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2540
+ type: "button",
2541
+ className: "dsm-btn dsm-btn-outline",
2542
+ disabled: importSelected.size === 0 || phase.kind === "importing",
2543
+ onClick: () => {
2544
+ runImport();
2545
+ },
2546
+ children: phase.kind === "importing" ? t("row.transferImporting") : t("row.transferImportConfirm", { count: importSelected.size })
2547
+ })]
2548
+ })
2549
+ ] })
2297
2550
  }) : null
2298
2551
  ]
2299
2552
  });
@@ -2645,6 +2898,35 @@ window.__ModuleLoader__.load({
2645
2898
  .dsm-workbuddy-transfer-list label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-primary,#e8e9ed);cursor:pointer;padding:2px 0}
2646
2899
  .dsm-workbuddy-transfer-list input[type="checkbox"]{flex:none}
2647
2900
  .dsm-workbuddy-transfer input[type="file"]{font-size:12px;color:var(--dsw-alias-label-primary,#e8e9ed);margin-bottom:8px}
2901
+ /* The import picker's input is driven by the 「import」 button, so it must not
2902
+ render as a visible field: hide it from layout AND from assistive tech (the
2903
+ button carries the accessible name). Not display:none alone — that also
2904
+ removes it from the tab order in some engines, which is fine here, but the
2905
+ clip-based form is the one that reliably keeps programmatic .click() working
2906
+ across browsers. */
2907
+ .dsm-workbuddy-transfer-file{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
2908
+ /* The sign-in URL inside the OAuth dialog: long https links must wrap rather
2909
+ than push the dialog wide — break-all, since URLs have no soft wrap points. */
2910
+ .dsm-workbuddy-oauth-url{word-break:break-all;overflow-wrap:anywhere}
2911
+ .dsm-workbuddy-oauth-url a{color:var(--dsw-alias-brand-primary,#5686fe);text-decoration:none}
2912
+ .dsm-workbuddy-oauth-url a:hover{text-decoration:underline}
2913
+ /* The shared busy veil: a fixed overlay for operations whose result only shows
2914
+ up after the list re-read (remove / import / sign-in).
2915
+ It is NOT a modal: no control, no dismissal — only "this is running" and
2916
+ "here is why nothing moved yet". Fixed positioning so it covers the card
2917
+ regardless of which section started the operation, and a semi-transparent
2918
+ backdrop so the table stays visible underneath — the user is watching a
2919
+ specific row change, and hiding it would defeat the point. */
2920
+ .dsm-workbuddy-veil{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);cursor:progress}
2921
+ .dsm-workbuddy-veil-card{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:min(340px,80vw);padding:16px 20px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;background:var(--dsw-alias-bg-primary,#1b1c20);box-shadow:0 8px 28px rgba(0,0,0,.35)}
2922
+ .dsm-workbuddy-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#e8e9ed);text-align:center}
2923
+ .dsm-workbuddy-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999);text-align:center}
2924
+ /* The spinner is the one thing that proves the UI is alive when the row under
2925
+ it has not changed yet — which is precisely the confusion the veil exists to
2926
+ remove. Reduced-motion users get the static ring instead of a rotation. */
2927
+ .dsm-workbuddy-veil-spinner{width:18px;height:18px;border:2px solid var(--dsw-alias-border-l2,#3a3d45);border-top-color:var(--dsw-alias-brand-primary,#5686fe);border-radius:50%;animation:dsm-workbuddy-veil-spin .7s linear infinite}
2928
+ @keyframes dsm-workbuddy-veil-spin{to{transform:rotate(360deg)}}
2929
+ @media (prefers-reduced-motion: reduce){.dsm-workbuddy-veil-spinner{animation:none}}
2648
2930
  `;
2649
2931
  /**
2650
2932
  * Composer credit readout styles, injected by the readout component itself.
@@ -3274,8 +3556,18 @@ window.__ModuleLoader__.load({
3274
3556
  accounts: [],
3275
3557
  selectionExplicit: false
3276
3558
  };
3559
+ /**
3560
+ * Whether the ACTIVE region knows of any account at all.
3561
+ *
3562
+ * This is the poll's precondition in place of `status === 'signed-in'`. The
3563
+ * signed-in flag means "a credential resolves", which is false for a region
3564
+ * whose accounts exist but none is usable yet — exactly the state an import
3565
+ * or a fresh OAuth sign-in creates. Polling on it meant those regions never
3566
+ * refreshed, so the list stayed empty until the card was reopened.
3567
+ */
3568
+ const hasAccounts = status.status !== "error" && status.accounts.length > 0;
3277
3569
  (0, react.useEffect)(() => {
3278
- if (!open || status.status !== "signed-in") return;
3570
+ if (!open || !hasAccounts) return;
3279
3571
  const controller = new AbortController();
3280
3572
  const timer = window.setInterval(() => {
3281
3573
  refreshUsage(activeRegion, controller.signal);
@@ -3305,6 +3597,7 @@ window.__ModuleLoader__.load({
3305
3597
  */
3306
3598
  const rescanAccounts = async () => {
3307
3599
  setBusy(true);
3600
+ setAccountError(void 0);
3308
3601
  try {
3309
3602
  const response = await fetch(withWorkBuddyRegion(WORKBUDDY_ACCOUNTS_REFRESH_PATH, activeRegion), {
3310
3603
  method: "POST",
@@ -3313,7 +3606,10 @@ window.__ModuleLoader__.load({
3313
3606
  });
3314
3607
  const body = await response.json();
3315
3608
  if (!response.ok || !Array.isArray(body.accounts)) throw new Error(`HTTP ${response.status}`);
3609
+ if (body.dismissalsCleared === false) setAccountError(t("row.rescanHidesKept"));
3316
3610
  await refreshUsage(activeRegion);
3611
+ } catch (error) {
3612
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
3317
3613
  } finally {
3318
3614
  if (mounted.current) setBusy(false);
3319
3615
  }
@@ -3672,10 +3968,12 @@ window.__ModuleLoader__.load({
3672
3968
  */
3673
3969
  const accountTools = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OAuthSignIn, {
3674
3970
  t,
3675
- region: activeRegion
3971
+ region: activeRegion,
3972
+ onAdded: () => refreshUsage(activeRegion)
3676
3973
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountTransfer, {
3677
3974
  t,
3678
3975
  region: activeRegion,
3976
+ onChanged: () => refreshUsage(activeRegion),
3679
3977
  accounts: status.status === "error" ? [] : status.accounts.map((account) => ({
3680
3978
  id: account.id,
3681
3979
  accountName: account.accountName
@@ -3781,6 +4079,11 @@ window.__ModuleLoader__.load({
3781
4079
  className: "dsm-workbuddy-usage-hint",
3782
4080
  children: t("row.reloginHint")
3783
4081
  }) : null,
4082
+ accountError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4083
+ className: "dsm-workbuddy-account-error",
4084
+ role: "alert",
4085
+ children: accountError
4086
+ }),
3784
4087
  selectionLost ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3785
4088
  className: "dsm-workbuddy-usage-hint",
3786
4089
  children: t("row.selectionLostHint")
@@ -3831,9 +4134,7 @@ window.__ModuleLoader__.load({
3831
4134
  siblingBusy: saving || togglingRegion !== void 0,
3832
4135
  onBusyChange: setPoolBusy,
3833
4136
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
3834
- onRefresh: () => {
3835
- refreshUsage(activeRegion);
3836
- }
4137
+ onRefresh: () => refreshUsage(activeRegion)
3837
4138
  }),
3838
4139
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
3839
4140
  className: "dsm-workbuddy-models",
@@ -4279,6 +4580,17 @@ window.__ModuleLoader__.load({
4279
4580
  if (usage.status !== "signed-in") return void 0;
4280
4581
  return usage.credits?.total;
4281
4582
  }
4583
+ /**
4584
+ * The Host's per-region readout switch off a usage document, when it carries one.
4585
+ *
4586
+ * The `error` variant has no data at all, so it can only ever answer `undefined`
4587
+ * — "the Host did not say", which leaves the settings scope in charge.
4588
+ */
4589
+ function hostCreditsShownOf(usage) {
4590
+ if (usage === void 0 || usage.status === "error") return void 0;
4591
+ const value = usage.showCreditsInMainUi;
4592
+ return typeof value === "boolean" ? value : void 0;
4593
+ }
4282
4594
  /** Group the digits so a four-figure balance stays readable in a narrow row. */
4283
4595
  function formatCredits(value) {
4284
4596
  return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
@@ -4365,8 +4677,9 @@ window.__ModuleLoader__.load({
4365
4677
  if (Array.isArray(data.accounts)) setAccountCredits(data.accounts);
4366
4678
  } catch {}
4367
4679
  }, [activeRegion]);
4680
+ const hostHidden = hostCreditsShownOf(usage) === false;
4368
4681
  (0, react.useEffect)(() => {
4369
- if (owned === void 0) return void 0;
4682
+ if (owned === void 0 || hostHidden) return void 0;
4370
4683
  let cancelled = false;
4371
4684
  let timer;
4372
4685
  const controller = new AbortController();
@@ -4381,7 +4694,11 @@ window.__ModuleLoader__.load({
4381
4694
  if (timer !== void 0) window.clearInterval(timer);
4382
4695
  controller.abort();
4383
4696
  };
4384
- }, [owned, fetchUsage]);
4697
+ }, [
4698
+ owned,
4699
+ hostHidden,
4700
+ fetchUsage
4701
+ ]);
4385
4702
  (0, react.useEffect)(() => {
4386
4703
  if (!open || activeRegion === void 0) return void 0;
4387
4704
  const controller = new AbortController();
@@ -4430,6 +4747,22 @@ window.__ModuleLoader__.load({
4430
4747
  }
4431
4748
  };
4432
4749
  if (owned === void 0) return null;
4750
+ /**
4751
+ * The Host's own answer for this region, when the document carries one.
4752
+ *
4753
+ * The Host is the AUTHORITY on whether this readout is wanted, exactly as the
4754
+ * card's `creditsShownOn` treats it — and here it is also the fix for a defect
4755
+ * shipped in 4.3.0 (issue #46): the gate's decision rests on the settings
4756
+ * scope, which on a host whose describe mirror lands late was bound to a
4757
+ * namespace nothing serves. Every read of that scope is then `undefined`, and
4758
+ * the opt-out rule (`!== false`) reads "shown" forever — so unticking the
4759
+ * checkbox wrote correctly and changed nothing on screen.
4760
+ *
4761
+ * Checked AFTER the fetch rather than before it because the answer only
4762
+ * arrives with the document. The cost is one usage read for a readout the user
4763
+ * may have hidden, against a persistent wrong number on screen.
4764
+ */
4765
+ if (hostCreditsShownOf(usage) === false) return null;
4433
4766
  const signedIn = usage?.status === "signed-in" ? usage : void 0;
4434
4767
  const total = signedIn === void 0 ? void 0 : creditsOf(signedIn);
4435
4768
  const signedOut = usage !== void 0 && usage.status === "signed-out";
@@ -4616,11 +4949,22 @@ window.__ModuleLoader__.load({
4616
4949
  * The region's own switches are applied HERE rather than inside the component,
4617
4950
  * so a region that is switched off (or whose provider is off) starts no fetch
4618
4951
  * loop at all.
4952
+ *
4953
+ * `hostValue` is the Host's own `showCreditsInMainUi` answer for this region,
4954
+ * when a usage document carrying it is already in hand. It OUTRANKS the settings
4955
+ * scope, exactly as the card's `creditsShownOn` does and for the same measured
4956
+ * reason: the browser's settings mirror is documented as stale for this
4957
+ * namespace on the affected deployments, and — the shipped 4.3.0 defect (issue
4958
+ * #46) — the scope can be bound to a namespace nothing serves, where every read
4959
+ * is `undefined` and the opt-out rule then reads "shown" forever. The Host is
4960
+ * the authority; the scope is the fallback for a Host line that predates the
4961
+ * field.
4619
4962
  */
4620
- function composerCreditsRegionOf(projection, scope) {
4963
+ function composerCreditsRegionOf(projection, scope, hostValue) {
4621
4964
  const provider = selectedProviderOf(projection);
4622
4965
  const region = provider === void 0 ? void 0 : WORKBUDDY_COMPOSER_PROVIDERS[provider];
4623
4966
  if (region === void 0) return void 0;
4967
+ if (typeof hostValue === "boolean") return hostValue ? region : void 0;
4624
4968
  const value = scope === void 0 ? void 0 : unwrapVolatileDeep(scope.getSnapshot().value);
4625
4969
  if (!regionCreditsShownOf(value, region)) return void 0;
4626
4970
  if (!regionEnabledOf(value, region)) return void 0;
@@ -4681,6 +5025,7 @@ window.__ModuleLoader__.load({
4681
5025
  "row.sourceDsh": "plugin copy",
4682
5026
  "row.searchedMore": "Show the {count} path(s) that were not found",
4683
5027
  "row.reloginHint": "If errors occur, sign back in to the WorkBuddy app.",
5028
+ "row.rescanHidesKept": "The accounts you removed are still hidden: the plugin could not update its own list file. On Windows this is usually a file lock — close antivirus or sync tools that may be holding files in the DSH profile, then try again.",
4684
5029
  "row.credentialRejectedTitle": "WorkBuddy refused this account's token",
4685
5030
  "row.credentialRejectedIntro": "The token for \"{accountName}\" is no longer accepted by the upstream, so credits and check-in are unavailable. Signing in again only helps if the problem is the token itself — here is what actually applies:",
4686
5031
  "row.credentialRejectedSwitch": "Another sign-in on this machine answered the upstream: \"{accountName}\". Switching to it fixes this — no need to sign in again.",
@@ -4751,7 +5096,6 @@ window.__ModuleLoader__.load({
4751
5096
  "row.oauthStart": "Sign in with QR code",
4752
5097
  "row.oauthStarting": "Opening…",
4753
5098
  "row.oauthOpenHint": "Open the sign-in page and confirm — the account is added here automatically when you finish:",
4754
- "row.oauthOpen": "Open sign-in page",
4755
5099
  "row.oauthOpened": "Opened. Waiting for you to finish…",
4756
5100
  "row.oauthWaiting": "Waiting for the sign-in to finish… ({seconds}s)",
4757
5101
  "row.oauthExpires": "The sign-in window expires in about {minutes} min.",
@@ -4760,7 +5104,14 @@ window.__ModuleLoader__.load({
4760
5104
  "row.oauthTimedOut": "The sign-in timed out — start again when ready.",
4761
5105
  "row.oauthDone": "Added “{accountName}”. Pick it above to use it.",
4762
5106
  "row.oauthError": "Sign-in failed: {message}",
4763
- "row.oauthUnavailable": "QR sign-in is not offered by this build of the plugin.",
5107
+ "row.oauthCopy": "Copy link",
5108
+ "row.oauthCopied": "Link copied",
5109
+ "row.oauthReopen": "Open page again",
5110
+ "row.oauthDialogClose": "Close",
5111
+ "row.oauthTitleGlobal": "Add an account by web sign-in",
5112
+ "row.oauthStartGlobal": "Sign in on the web",
5113
+ "row.veilHintList": "The list refreshes as soon as this finishes.",
5114
+ "row.oauthSyncing": "Adding the account…",
4764
5115
  "row.transferTitle": "Export / import accounts",
4765
5116
  "row.transferHint": "Move accounts between computers as a JSON file. The file carries the credentials — keep it private. The format is shared with other WorkBuddy credential managers.",
4766
5117
  "row.transferExport": "Export accounts",
@@ -4770,7 +5121,7 @@ window.__ModuleLoader__.load({
4770
5121
  "row.transferExported": "Exported {count} account(s) to {fileName}.",
4771
5122
  "row.transferImport": "Import accounts",
4772
5123
  "row.transferImporting": "Importing…",
4773
- "row.transferImportPick": "Choose a JSON file exported from this or a compatible tool:",
5124
+ "row.transferPreviewing": "Reading the file…",
4774
5125
  "row.transferPreviewTotal": "{total} account(s) in the file — untick what should stay out. Accounts already stored here are overwritten by the import.",
4775
5126
  "row.transferImportConfirm": "Import {count} account(s)",
4776
5127
  "row.transferImported": "Imported {imported} account(s); {skipped} skipped (no token, or belongs to the other region).",
@@ -4878,10 +5229,11 @@ window.__ModuleLoader__.load({
4878
5229
  "row.poolUnsavedMembers": "Selection changed — save to apply",
4879
5230
  "row.poolRowTest": "Test",
4880
5231
  "row.poolRowRemove": "Remove",
4881
- "row.poolRowRemoveHint": "Forget this account's plugin-stored credential. A desktop-app sign-in reappears on the next scan — only the plugin's own copy is deleted.",
5232
+ "row.poolRowRemoveHint": "Remove this account from the list. The WorkBuddy desktop app is not signed out — it keeps its own sign-in. Removing only hides the account here; 「Detect accounts again」 brings it back.",
4882
5233
  "row.poolRowRemoveAria": "Remove the stored credential of {accountName}",
4883
- "row.poolRowRemoved": "{accountName}: removed from the plugin's storage.",
4884
- "row.poolRowRemoveDesktopOnly": "{accountName}: nothing plugin-stored to remove — this account comes from the desktop app's own sign-in.",
5234
+ "row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
5235
+ "row.poolRowRemoving": "Removing the account…",
5236
+ "row.poolRowRemoveDesktopOnly": "{accountName}: hidden from the list — this account comes from the desktop app's own sign-in, which is left untouched. 「Detect accounts again」 re-adds it.",
4885
5237
  "row.showCreditsCn": "Show WB CN credits in the composer",
4886
5238
  "row.showCreditsGlobal": "Show WB AI credits in the composer",
4887
5239
  "row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
@@ -4933,6 +5285,7 @@ window.__ModuleLoader__.load({
4933
5285
  "row.sourceDsh": "插件副本",
4934
5286
  "row.searchedMore": "显示另外 {count} 条「不存在」的路径",
4935
5287
  "row.reloginHint": "出现错误,重新登录 WorkBuddy APP 即可。",
5288
+ "row.rescanHidesKept": "已删除的账号仍是隐藏状态:插件没能更新自己的名单文件。Windows 上通常是文件被占用——请关闭可能正在占用 DSH 配置目录的杀毒/同步工具后重试。",
4936
5289
  "row.credentialRejectedTitle": "WorkBuddy 拒绝了这个账号的 token",
4937
5290
  "row.credentialRejectedIntro": "「{accountName}」的 token 已不被上游接受,所以积分与签到都用不了。重新登录只在「确实是 token 的问题」时才有用——当前实际适用的是下面这一条:",
4938
5291
  "row.credentialRejectedSwitch": "本机另一个账号「{accountName}」刚刚实测可用,切换到它即可恢复,不必重新登录。",
@@ -5003,7 +5356,6 @@ window.__ModuleLoader__.load({
5003
5356
  "row.oauthStart": "扫码登录",
5004
5357
  "row.oauthStarting": "正在打开…",
5005
5358
  "row.oauthOpenHint": "打开登录页并完成确认——完成后账号会自动出现在这里:",
5006
- "row.oauthOpen": "打开登录页",
5007
5359
  "row.oauthOpened": "已打开,等待你完成登录…",
5008
5360
  "row.oauthWaiting": "正在等待登录完成…({seconds} 秒)",
5009
5361
  "row.oauthExpires": "登录窗口约 {minutes} 分钟后过期。",
@@ -5012,7 +5364,14 @@ window.__ModuleLoader__.load({
5012
5364
  "row.oauthTimedOut": "登录超时——请重新开始。",
5013
5365
  "row.oauthDone": "已添加「{accountName}」。在上方选中它即可使用。",
5014
5366
  "row.oauthError": "登录失败:{message}",
5015
- "row.oauthUnavailable": "当前版本的插件不支持扫码登录。",
5367
+ "row.oauthCopy": "复制链接",
5368
+ "row.oauthCopied": "已复制链接",
5369
+ "row.oauthReopen": "重新打开页面",
5370
+ "row.oauthDialogClose": "关闭",
5371
+ "row.oauthTitleGlobal": "网页登录添加账号",
5372
+ "row.oauthStartGlobal": "网页登录",
5373
+ "row.veilHintList": "完成后列表会立即刷新。",
5374
+ "row.oauthSyncing": "正在添加账号…",
5016
5375
  "row.transferTitle": "导出 / 导入账号",
5017
5376
  "row.transferHint": "用 JSON 文件在电脑之间迁移账号。文件里是账号凭据——请妥善保管。该格式与同类 WorkBuddy 凭据管理工具通用。",
5018
5377
  "row.transferExport": "导出账号",
@@ -5022,7 +5381,7 @@ window.__ModuleLoader__.load({
5022
5381
  "row.transferExported": "已导出 {count} 个账号到 {fileName}。",
5023
5382
  "row.transferImport": "导入账号",
5024
5383
  "row.transferImporting": "正在导入…",
5025
- "row.transferImportPick": "选择本插件或同类工具导出的 JSON 文件:",
5384
+ "row.transferPreviewing": "正在读取文件…",
5026
5385
  "row.transferPreviewTotal": "文件中共 {total} 个账号——不需要的请取消勾选。导入会覆盖这里已存储的同名账号。",
5027
5386
  "row.transferImportConfirm": "导入 {count} 个账号",
5028
5387
  "row.transferImported": "已导入 {imported} 个账号;跳过 {skipped} 个(缺 token,或属于另一个区域)。",
@@ -5130,10 +5489,11 @@ window.__ModuleLoader__.load({
5130
5489
  "row.poolUnsavedMembers": "勾选已改动 —— 保存后生效",
5131
5490
  "row.poolRowTest": "测试",
5132
5491
  "row.poolRowRemove": "删除",
5133
- "row.poolRowRemoveHint": "删除插件保存的这个账号凭据。桌面 App 的登录不会被删除——下次扫描它还会出现;只有插件自己的副本会被移除。",
5492
+ "row.poolRowRemoveHint": "把这个账号从列表中移除。桌面 App 不会被退出登录——它保留自己的登录状态。移除只是暂时隐藏,点「重新检测账号」即可找回。",
5134
5493
  "row.poolRowRemoveAria": "删除 {accountName} 的已存凭据",
5135
- "row.poolRowRemoved": "{accountName}:已从插件存储中移除。",
5136
- "row.poolRowRemoveDesktopOnly": "{accountName}:没有插件存储的凭据可删——这个账号来自桌面 App 自己的登录。",
5494
+ "row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
5495
+ "row.poolRowRemoving": "正在删除账号…",
5496
+ "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
5137
5497
  "row.showCreditsCn": "在对话框显示 WB CN 积分",
5138
5498
  "row.showCreditsGlobal": "在对话框显示 WB AI 积分",
5139
5499
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
@@ -5210,17 +5570,10 @@ window.__ModuleLoader__.load({
5210
5570
  const softGet = (name) => ctx.get(name);
5211
5571
  let settingsScope;
5212
5572
  const forms = softGet("configForms");
5213
- if (forms !== void 0) {
5214
- let ns = WORKBUDDY_SETTINGS_NS;
5215
- try {
5216
- const served = (forms.describe().getSnapshot().view?.namespaces ?? []).find((entry) => entry.ns === "workbuddy" || /workbuddy/i.test(entry.ns));
5217
- if (served !== void 0) ns = served.ns;
5218
- } catch {}
5219
- settingsScope = forms.get(ns);
5220
- }
5573
+ /** Register one card slot; returns its disposer, or undefined if it failed. */
5221
5574
  const registerCard = (slotName, key) => {
5222
5575
  try {
5223
- ctx.slots.inject(slotName, () => ctx.slots.register({
5576
+ return ctx.slots.inject(slotName, () => ctx.slots.register({
5224
5577
  name: slotName,
5225
5578
  key,
5226
5579
  priority: 30,
@@ -5231,10 +5584,9 @@ window.__ModuleLoader__.load({
5231
5584
  }, WorkBuddyCard));
5232
5585
  } catch (error) {
5233
5586
  console.error(`[dsh-connect-workbuddy] card slot "${slotName}" failed to register (host provider unaffected):`, error);
5587
+ return;
5234
5588
  }
5235
5589
  };
5236
- registerCard("plugins.bundle.config", "dsh-connect-workbuddy");
5237
- registerCard("plugins.row.config", "dsh-connect-workbuddy#dsh-connect-workbuddy");
5238
5590
  /**
5239
5591
  * Credit readout in the composer tool row (`conversation.input.left`, a
5240
5592
  * session-scope list slot).
@@ -5255,7 +5607,7 @@ window.__ModuleLoader__.load({
5255
5607
  */
5256
5608
  const registerComposerCredits = () => {
5257
5609
  try {
5258
- ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
5610
+ return ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
5259
5611
  name: "conversation.input.left",
5260
5612
  id: "dsh-connect-workbuddy-composer-credits",
5261
5613
  order: 4,
@@ -5267,9 +5619,84 @@ window.__ModuleLoader__.load({
5267
5619
  }, ComposerCreditsGate));
5268
5620
  } catch (error) {
5269
5621
  console.error("[dsh-connect-workbuddy] composer credits slot failed to register (host provider unaffected):", error);
5622
+ return;
5270
5623
  }
5271
5624
  };
5272
- registerComposerCredits();
5625
+ /**
5626
+ * Every slot this plugin contributes, and how to (re)create them.
5627
+ *
5628
+ * Kept as one list because the binding of `settingsScope` and the slot
5629
+ * registrations must move TOGETHER: see {@link bindSettingsScope} for why a
5630
+ * late-arriving namespace has to produce fresh registrations rather than a
5631
+ * mutated closure.
5632
+ */
5633
+ /**
5634
+ * Disposers of the slots registered for the CURRENT namespace binding.
5635
+ *
5636
+ * Declared before {@link bindSettingsScope} because that function closes
5637
+ * over it and reassigns it; the binding is only armed at the end of `apply`,
5638
+ * so this is initialised by then either way — hoisting it keeps that
5639
+ * ordering a non-requirement rather than a trap.
5640
+ */
5641
+ let liveDisposers = [];
5642
+ const registerAllSlots = () => [
5643
+ registerCard("plugins.bundle.config", "dsh-connect-workbuddy"),
5644
+ registerCard("plugins.row.config", "dsh-connect-workbuddy#dsh-connect-workbuddy"),
5645
+ registerComposerCredits()
5646
+ ].filter((disposer) => disposer !== void 0);
5647
+ /**
5648
+ * Bind `settingsScope` to whichever namespace the Host really serves, and
5649
+ * RE-REGISTER every slot when that answer changes.
5650
+ *
5651
+ * Why this is not a one-shot read at startup — the shipped 4.3.0 defect
5652
+ * (issue #46): the mirror is filled by an async `remote.settings.describe()`,
5653
+ * so at `apply` time `view` is usually still absent. The old code read that
5654
+ * absence as "the declared name must be right", bound
5655
+ * `forms.get('workbuddy')`, and never looked again. `ConfigFormController.
5656
+ * derive()` matches the namespace EXACTLY, so the guessed form stayed
5657
+ * `unavailable` forever; the composer gate — reading only that scope —
5658
+ * judged the opt-out switch `undefined !== false` and kept rendering the
5659
+ * readout. Unticking the box wrote `false` correctly and changed nothing on
5660
+ * screen.
5661
+ *
5662
+ * `whileServed` is the Host's own idiom for this (`mirror.subscribe` plus an
5663
+ * immediate sync). It is strictly better than re-reading the snapshot by
5664
+ * hand because it ALSO withdraws the binding if the Host stops serving our
5665
+ * namespace, and it hands `register` the set of namespaces actually served
5666
+ * — so the choice is made from live data instead of a captured guess.
5667
+ *
5668
+ * `register` runs on the unserved→served EDGE, not on every mirror update
5669
+ * (see the service's `sync`), so the disposal below is not a churn loop: it
5670
+ * clears whatever a previous edge left registered. That replacement is NOT
5671
+ * optional, because the renderer caches each entry's inject result by object
5672
+ * identity (`rootInjectCache` / `cachedRootInject` in
5673
+ * `dsh-client-ui-renderer`: `if (!props) { props = runInject(...) }`), so an
5674
+ * entry registered before the mirror landed is never re-evaluated. Late
5675
+ * binding can therefore only work if the OLD registrations are disposed and
5676
+ * NEW ones created — which is what this callback does.
5677
+ */
5678
+ const bindSettingsScope = () => {
5679
+ if (forms === void 0 || typeof forms.whileServed !== "function") {
5680
+ settingsScope = forms?.get(WORKBUDDY_SETTINGS_NS);
5681
+ liveDisposers = registerAllSlots();
5682
+ return;
5683
+ }
5684
+ ctx.effect(() => forms.whileServed(WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES, (served) => {
5685
+ const ns = WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES.find((candidate) => served.has(candidate)) ?? [...served].find((entry) => /workbuddy/i.test(entry));
5686
+ settingsScope = ns === void 0 ? void 0 : forms.get(ns);
5687
+ for (const dispose of liveDisposers) try {
5688
+ dispose();
5689
+ } catch {}
5690
+ liveDisposers = registerAllSlots();
5691
+ return () => {
5692
+ for (const dispose of liveDisposers) try {
5693
+ dispose();
5694
+ } catch {}
5695
+ liveDisposers = [];
5696
+ };
5697
+ }), "dsh-connect-workbuddy: settings namespace binding");
5698
+ };
5699
+ bindSettingsScope();
5273
5700
  } catch (error) {
5274
5701
  console.error("[dsh-connect-workbuddy] client card failed to load (host provider unaffected):", error);
5275
5702
  }