dsh-connect-workbuddy 4.2.0 → 4.3.0

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
@@ -649,6 +649,40 @@ window.__ModuleLoader__.load({
649
649
  }
650
650
  }
651
651
  //#endregion
652
+ //#region src/client/BusyOverlay.tsx
653
+ /**
654
+ * The veil. Deliberately NOT a modal dialog: it carries no focusable control
655
+ * and no dismissal — there is nothing for the user to decide, only a step to
656
+ * wait through. `aria-busy` plus a polite live region state that to screen
657
+ * readers, which is the whole accessibility contract a wait screen needs.
658
+ */
659
+ function BusyOverlay({ message, hint, visible }) {
660
+ if (!visible) return null;
661
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
662
+ className: "dsm-workbuddy-veil",
663
+ role: "status",
664
+ "aria-busy": "true",
665
+ "aria-live": "polite",
666
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
667
+ className: "dsm-workbuddy-veil-card",
668
+ children: [
669
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
670
+ className: "dsm-workbuddy-veil-spinner",
671
+ "aria-hidden": "true"
672
+ }),
673
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
674
+ className: "dsm-workbuddy-veil-text",
675
+ children: message
676
+ }),
677
+ hint === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
678
+ className: "dsm-workbuddy-veil-hint",
679
+ children: hint
680
+ })
681
+ ]
682
+ })
683
+ });
684
+ }
685
+ //#endregion
652
686
  //#region src/client/ndjson.ts
653
687
  /**
654
688
  * Read a newline-delimited JSON body, one object at a time.
@@ -1151,6 +1185,24 @@ window.__ModuleLoader__.load({
1151
1185
  memberAccountIds: pool?.memberAccountIds ?? []
1152
1186
  };
1153
1187
  const active = draftRegion === region && draft !== null ? draft : saved;
1188
+ /**
1189
+ * Whether the pool is RUNNING right now, which is the SAVED value.
1190
+ *
1191
+ * `active` is the user's draft and only becomes reality at Save. Using it for
1192
+ * the parts that DESCRIBE the running pool made the card state a mode that had
1193
+ * not taken effect yet: flipping the switch off (unsaved) revealed the manual
1194
+ * picker while the pool was still ranking every request, so the picker showed
1195
+ * the rotated account as "in use" and a selection made there was overridden by
1196
+ * the very pool the user was looking at — the "shows A, bills B" trap this
1197
+ * section keeps guarding against. The mirror case was milder but the same
1198
+ * class of wrong: turning the pool ON (unsaved) hid the picker while the manual
1199
+ * account was still the one being billed.
1200
+ *
1201
+ * Everything that reports pool state reads this; only the switch itself (its
1202
+ * controlled value) and `dirty` read the draft, because those two ARE about
1203
+ * the pending edit.
1204
+ */
1205
+ const poolRunning = saved.enabled;
1154
1206
  const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1155
1207
  /**
1156
1208
  * Membership that actually resolves to a local sign-in — what a batch runs on.
@@ -1405,6 +1457,14 @@ window.__ModuleLoader__.load({
1405
1457
  /** The account whose per-row action is in flight; gates every row's buttons. */
1406
1458
  const [rowBusyAccountId, setRowBusyAccountId] = (0, react.useState)(void 0);
1407
1459
  /**
1460
+ * Which per-row action is running, for the veil's scenario line.
1461
+ *
1462
+ * A removal and a test both set `rowBusyAccountId`, but they mean different
1463
+ * things to the person watching ("this account is going away" versus "this
1464
+ * account is being measured") — and the veil exists precisely to say which.
1465
+ */
1466
+ const [rowBusyAction, setRowBusyAction] = (0, react.useState)(void 0);
1467
+ /**
1408
1468
  * Test ONE account from its row.
1409
1469
  *
1410
1470
  * Same Host rule as the batch (testOne reuses the batch's probe), the same
@@ -1413,6 +1473,7 @@ window.__ModuleLoader__.load({
1413
1473
  */
1414
1474
  const runRowTest = (0, react.useCallback)(async (accountId) => {
1415
1475
  setRowBusyAccountId(accountId);
1476
+ setRowBusyAction("test");
1416
1477
  setActionError(void 0);
1417
1478
  const url = withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, "test") + "&accountId=" + encodeURIComponent(accountId);
1418
1479
  try {
@@ -1447,7 +1508,10 @@ window.__ModuleLoader__.load({
1447
1508
  appendLog(t("row.poolLogBatchFailed", { message }), "error");
1448
1509
  }
1449
1510
  } finally {
1450
- if (mounted.current) setRowBusyAccountId(void 0);
1511
+ if (mounted.current) {
1512
+ setRowBusyAccountId(void 0);
1513
+ setRowBusyAction(void 0);
1514
+ }
1451
1515
  }
1452
1516
  }, [
1453
1517
  appendLog,
@@ -1456,12 +1520,17 @@ window.__ModuleLoader__.load({
1456
1520
  t
1457
1521
  ]);
1458
1522
  /**
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.
1523
+ * Remove ONE account from its row, via the Host.
1524
+ *
1525
+ * The Host dismisses the account (and deletes its vault entry when it has
1526
+ * one), so the row stays gone across rescans. The desktop app is not signed
1527
+ * out. The message below still distinguishes the two cases, because "there
1528
+ * was no plugin-stored copy" is worth saying: it tells the user the account
1529
+ * comes from the app itself rather than from an OAuth sign-in or an import.
1462
1530
  */
1463
1531
  const runRowRemove = (0, react.useCallback)(async (accountId, accountName) => {
1464
1532
  setRowBusyAccountId(accountId);
1533
+ setRowBusyAction("remove");
1465
1534
  setActionError(void 0);
1466
1535
  const url = withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, "remove") + "&accountId=" + encodeURIComponent(accountId);
1467
1536
  try {
@@ -1478,7 +1547,7 @@ window.__ModuleLoader__.load({
1478
1547
  }));
1479
1548
  if (!mounted.current) return;
1480
1549
  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?.();
1550
+ await onRefresh?.();
1482
1551
  } catch (error) {
1483
1552
  const message = error instanceof Error ? error.message : t("row.requestFailed");
1484
1553
  if (mounted.current) {
@@ -1486,7 +1555,10 @@ window.__ModuleLoader__.load({
1486
1555
  appendLog(t("row.poolLogBatchFailed", { message }), "error");
1487
1556
  }
1488
1557
  } finally {
1489
- if (mounted.current) setRowBusyAccountId(void 0);
1558
+ if (mounted.current) {
1559
+ setRowBusyAccountId(void 0);
1560
+ setRowBusyAction(void 0);
1561
+ }
1490
1562
  }
1491
1563
  }, [
1492
1564
  appendLog,
@@ -1517,7 +1589,15 @@ window.__ModuleLoader__.load({
1517
1589
  */
1518
1590
  const currentAccount = pool.accounts.find((account) => account.current);
1519
1591
  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", {
1592
+ return (0, react.createElement)("section", { className: "dsm-workbuddy-pool" }, rowBusyAction === "remove" ? (0, react.createElement)(BusyOverlay, {
1593
+ visible: true,
1594
+ message: t("row.poolRowRemoving"),
1595
+ hint: t("row.veilHintList")
1596
+ }) : null, saving ? (0, react.createElement)(BusyOverlay, {
1597
+ visible: true,
1598
+ message: t("row.poolSaving"),
1599
+ hint: t("row.veilHintList")
1600
+ }) : 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
1601
  className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
1522
1602
  value: currentAccount?.accountId ?? "",
1523
1603
  disabled: selectingAccount === true,
@@ -1550,7 +1630,7 @@ window.__ModuleLoader__.load({
1550
1630
  className: "dsm-btn dsm-btn-outline",
1551
1631
  disabled: rescanning === true,
1552
1632
  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", {
1633
+ }, 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
1634
  className: "dsm-workbuddy-pool-warn",
1555
1635
  role: "status"
1556
1636
  }, `${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 +1853,22 @@ window.__ModuleLoader__.load({
1773
1853
  //#endregion
1774
1854
  //#region src/client/OAuthSignIn.tsx
1775
1855
  /**
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,与超时同一条文案路径。
1856
+ * The card's OAuth QR sign-in section: one resting bar row plus a dialog
1857
+ * opened by the sign-in button.
1858
+ *
1859
+ * 参考:workbuddy2api-panel 的「添加账号」弹窗交互形态 —— 静态入口一行,
1860
+ * 点开后弹窗内分步走:获取授权链接 → 自动打开浏览器 → 弹窗内显示链接
1861
+ * 与等待状态 → 完成/失败回填结果。本实现沿用该步骤划分;弹窗样式直接
1862
+ * 用本项目导出/导入弹框的类(dsm-workbuddy-transfer-dialog),不引入
1863
+ * 第二套视觉。
1864
+ * 改动:自动打开浏览器。弹窗拦截的规避方式是「用户手势内同步 window.open
1865
+ * 占位,拿到授权 URL 后再导航」——点击「获取授权链接」的那一刻浏览器
1866
+ * 仍认定这是用户激活的手势链,占位窗口不会被拦;若 Host 环境连占位都
1867
+ * 拦(返回 null),降级为弹窗内手动「打开登录页」链接,功能不损失。
1868
+ *
1869
+ * 轮询循环压成一个组件内 effect:按 `OAUTH_POLL_INTERVAL_MS` 节奏问 Host
1870
+ * 「完成了吗」,完成即停。所有状态就地管理,零跨组件往返;Host 重启
1871
+ * 导致 loginId 失效会得到 done+error,与超时同一条文案路径。
1789
1872
  *
1790
1873
  * @module dsh-connect-workbuddy/client/OAuthSignIn
1791
1874
  */
@@ -1793,13 +1876,24 @@ window.__ModuleLoader__.load({
1793
1876
  function elapsedSeconds(sinceMs, nowMs) {
1794
1877
  return Math.max(0, Math.floor((nowMs - sinceMs) / 1e3));
1795
1878
  }
1879
+ /** Copy the sign-in URL to the clipboard; true on success. */
1880
+ async function copyUrl(url) {
1881
+ try {
1882
+ await navigator.clipboard.writeText(url);
1883
+ return true;
1884
+ } catch {
1885
+ return false;
1886
+ }
1887
+ }
1796
1888
  /**
1797
1889
  * The section. Rendered on BOTH sign-in states: a signed-out machine gains
1798
1890
  * its first account here; a signed-in one can add another without leaving
1799
1891
  * the card.
1800
1892
  */
1801
- function OAuthSignIn({ t, region }) {
1893
+ function OAuthSignIn({ t, region, onAdded }) {
1802
1894
  const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
1895
+ const [dialogOpen, setDialogOpen] = (0, react.useState)(false);
1896
+ const [copied, setCopied] = (0, react.useState)(false);
1803
1897
  const [pollTick, setPollTick] = (0, react.useState)(0);
1804
1898
  const [nowMs, setNowMs] = (0, react.useState)(() => Date.now());
1805
1899
  const mounted = (0, react.useRef)(true);
@@ -1826,7 +1920,11 @@ window.__ModuleLoader__.load({
1826
1920
  return () => {
1827
1921
  clearInterval(timer);
1828
1922
  };
1829
- }, [waiting, phase]);
1923
+ }, [
1924
+ waiting,
1925
+ phase,
1926
+ t
1927
+ ]);
1830
1928
  (0, react.useEffect)(() => {
1831
1929
  if (phase.kind !== "waiting") return;
1832
1930
  const { loginId } = phase;
@@ -1862,11 +1960,20 @@ window.__ModuleLoader__.load({
1862
1960
  message: "error",
1863
1961
  error: body.error
1864
1962
  });
1865
- else setPhase({
1866
- kind: "finished",
1867
- message: "added",
1868
- accountName: body.accountName ?? ""
1869
- });
1963
+ else {
1964
+ const accountName = body.accountName ?? "";
1965
+ setPhase({
1966
+ kind: "syncing",
1967
+ accountName
1968
+ });
1969
+ await onAdded?.();
1970
+ if (cancelled || !mounted.current) return;
1971
+ setPhase({
1972
+ kind: "finished",
1973
+ message: "added",
1974
+ accountName
1975
+ });
1976
+ }
1870
1977
  } catch {
1871
1978
  setPollTick((tick) => tick + 1);
1872
1979
  }
@@ -1877,12 +1984,47 @@ window.__ModuleLoader__.load({
1877
1984
  clearTimeout(timer);
1878
1985
  };
1879
1986
  }, [
1987
+ onAdded,
1880
1988
  phase,
1881
1989
  pollTick,
1882
1990
  region
1883
1991
  ]);
1884
- const start = async () => {
1992
+ /**
1993
+ * Open a placeholder window WITHOUT `noopener`, and deliberately so.
1994
+ *
1995
+ * `noopener` is the correct feature for a link whose destination never needs
1996
+ * a handle — and that is exactly why it is wrong here. Per the spec, a
1997
+ * `window.open` call carrying `noopener` opens the tab but returns `null`
1998
+ * (MDN: "the new window will not have access to the originating window via
1999
+ * `Window.opener` and returns `null`"). The placeholder exists ONLY to be
2000
+ * navigated once the Host answers, so the handle IS the point: passing
2001
+ * `noopener` made `placeholder` null, the navigation was skipped, and every
2002
+ * sign-in left an empty `about:blank` tab behind instead of the sign-in page.
2003
+ *
2004
+ * The security property is kept, just established on the handle instead of
2005
+ * through the feature: `opener = null` is applied to the placeholder before
2006
+ * anything is loaded into it, so the destination still cannot reach back
2007
+ * through `window.opener`. `noreferrer` is dropped for the same reason —
2008
+ * the spec makes it imply `noopener`, so it would reintroduce the null.
2009
+ * Nothing is lost: the placeholder is same-origin and empty until navigated,
2010
+ * and the referrer would only ever have been the settings page.
2011
+ */
2012
+ function openPlaceholder() {
2013
+ const placeholder = window.open("", "_blank");
2014
+ if (placeholder !== null) placeholder.opener = null;
2015
+ return placeholder;
2016
+ }
2017
+ /**
2018
+ * Fetch the authorization URL AND open the browser in one gesture chain.
2019
+ * The placeholder window is opened synchronously — inside the click's user
2020
+ * activation, before any await — so popup blocking cannot intervene; the
2021
+ * URL is navigated once the Host answers. A blocked placeholder (null)
2022
+ * degrades to the manual link inside the dialog, losing nothing but the
2023
+ * convenience.
2024
+ */
2025
+ const start = async (placeholder) => {
1885
2026
  setPhase({ kind: "opening" });
2027
+ setCopied(false);
1886
2028
  try {
1887
2029
  const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_OAUTH_PATH, region, "start"), {
1888
2030
  method: "POST",
@@ -1890,8 +2032,12 @@ window.__ModuleLoader__.load({
1890
2032
  credentials: "same-origin"
1891
2033
  });
1892
2034
  const body = await response.json();
1893
- if (!mounted.current) return;
2035
+ if (!mounted.current) {
2036
+ placeholder?.close();
2037
+ return;
2038
+ }
1894
2039
  if (!response.ok || typeof body.loginId !== "string" || typeof body.verificationUri !== "string") {
2040
+ placeholder?.close();
1895
2041
  setPhase({
1896
2042
  kind: "finished",
1897
2043
  message: "error",
@@ -1899,6 +2045,7 @@ window.__ModuleLoader__.load({
1899
2045
  });
1900
2046
  return;
1901
2047
  }
2048
+ if (placeholder !== null) placeholder.location.href = body.verificationUri;
1902
2049
  setPhase({
1903
2050
  kind: "waiting",
1904
2051
  loginId: body.loginId,
@@ -1908,6 +2055,7 @@ window.__ModuleLoader__.load({
1908
2055
  });
1909
2056
  setNowMs(Date.now());
1910
2057
  } catch (error) {
2058
+ placeholder?.close();
1911
2059
  if (mounted.current) setPhase({
1912
2060
  kind: "finished",
1913
2061
  message: "error",
@@ -1915,72 +2063,133 @@ window.__ModuleLoader__.load({
1915
2063
  });
1916
2064
  }
1917
2065
  };
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
- }
2066
+ /** Open the dialog and begin the login in the same click: the placeholder
2067
+ * window must be created synchronously to count as a user gesture. */
2068
+ const beginSignIn = () => {
2069
+ setDialogOpen(true);
2070
+ start(openPlaceholder());
2071
+ };
2072
+ /** Re-open the sign-in page from the waiting dialog (no new session).
2073
+ *
2074
+ * Here `noopener,noreferrer` IS right: this is a one-way navigation to a
2075
+ * third-party page and nothing needs a handle on it — the contrast with
2076
+ * {@link openPlaceholder} is the whole distinction. */
2077
+ const reopen = () => {
2078
+ if (phase.kind !== "waiting") return;
2079
+ window.open(phase.verificationUri, "_blank", "noopener,noreferrer");
2080
+ };
2081
+ /**
2082
+ * The three strings that NAME the sign-in method, per region.
2083
+ *
2084
+ * CN signs in by scanning a QR code with the WorkBuddy app; the international
2085
+ * build signs in on a web page. Every other line in this section (waiting,
2086
+ * cancel, done, copy) is about the flow, not the method, so it stays shared —
2087
+ * splitting it too would let the two regions drift apart for no gain.
2088
+ */
2089
+ const titleKey = region === "cn" ? "row.oauthTitle" : "row.oauthTitleGlobal";
2090
+ const startKey = region === "cn" ? "row.oauthStart" : "row.oauthStartGlobal";
2091
+ 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
2092
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1937
2093
  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
- ]
2094
+ "aria-label": t(titleKey),
2095
+ children: [
2096
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BusyOverlay, {
2097
+ visible: phase.kind === "syncing",
2098
+ message: t("row.oauthSyncing"),
2099
+ hint: t("row.veilHintList")
1955
2100
  }),
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
- ]
2101
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2102
+ className: "dsm-workbuddy-bar-label",
2103
+ children: t(titleKey)
1963
2104
  }),
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
- })]
2105
+ phase.kind === "finished" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2106
+ className: `dsm-workbuddy-bar-status ${phase.message === "added" ? "dsm-workbuddy-bar-ok" : "dsm-workbuddy-bar-error"}`,
2107
+ children: finishText
2108
+ }) : phase.kind === "waiting" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2109
+ className: "dsm-workbuddy-bar-status",
2110
+ children: t("row.oauthOpened")
2111
+ }) : null,
2112
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2113
+ className: "dsm-workbuddy-bar-actions",
2114
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2115
+ type: "button",
2116
+ className: "dsm-btn dsm-btn-outline",
2117
+ disabled: phase.kind === "opening",
2118
+ onClick: beginSignIn,
2119
+ children: phase.kind === "opening" ? t("row.oauthStarting") : t(startKey)
2120
+ })
2121
+ }),
2122
+ dialogOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2123
+ className: "dsm-workbuddy-transfer-dialog",
2124
+ role: "dialog",
2125
+ "aria-label": t(titleKey),
2126
+ 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: [
2127
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.oauthOpenHint") }),
2128
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2129
+ className: "dsm-workbuddy-oauth-url",
2130
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2131
+ href: phase.verificationUri,
2132
+ target: "_blank",
2133
+ rel: "noopener noreferrer",
2134
+ children: phase.verificationUri
2135
+ })
2136
+ }),
2137
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2138
+ className: "dsm-workbuddy-hint",
2139
+ children: [
2140
+ t("row.oauthWaiting", { seconds: elapsedSeconds(phase.startedAtMs, nowMs) }),
2141
+ " ",
2142
+ t("row.oauthExpires", { minutes: Math.max(1, Math.round((phase.expiresAtMs - nowMs) / 6e4)) })
2143
+ ]
2144
+ }),
2145
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2146
+ className: "dsm-workbuddy-transfer-dialog-actions",
2147
+ children: [
2148
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2149
+ type: "button",
2150
+ className: "dsm-btn dsm-btn-outline",
2151
+ onClick: () => {
2152
+ copyUrl(phase.verificationUri).then((ok) => {
2153
+ if (mounted.current) setCopied(ok);
2154
+ });
2155
+ },
2156
+ children: copied ? t("row.oauthCopied") : t("row.oauthCopy")
2157
+ }),
2158
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2159
+ type: "button",
2160
+ className: "dsm-btn dsm-btn-outline",
2161
+ onClick: reopen,
2162
+ children: t("row.oauthReopen")
2163
+ }),
2164
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2165
+ type: "button",
2166
+ className: "dsm-btn dsm-btn-outline",
2167
+ onClick: () => {
2168
+ setPhase({
2169
+ kind: "finished",
2170
+ message: "cancelled"
2171
+ });
2172
+ },
2173
+ children: t("row.oauthCancel")
2174
+ })
2175
+ ]
2176
+ })
2177
+ ] }) : phase.kind === "finished" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2178
+ className: `${phase.message === "added" ? "dsm-workbuddy-bar-ok" : phase.message === "error" ? "dsm-workbuddy-bar-error" : ""}`,
2179
+ children: finishText
2180
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2181
+ className: "dsm-workbuddy-transfer-dialog-actions",
2182
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2183
+ type: "button",
2184
+ className: "dsm-btn dsm-btn-outline",
2185
+ onClick: () => {
2186
+ setDialogOpen(false);
2187
+ },
2188
+ children: t("row.oauthDialogClose")
2189
+ })
2190
+ })] }) : null
2191
+ }) : null
2192
+ ]
1984
2193
  });
1985
2194
  }
1986
2195
  //#endregion
@@ -2022,13 +2231,24 @@ window.__ModuleLoader__.load({
2022
2231
  /** The section. Rendered on both sign-in states — a signed-out machine can
2023
2232
  * still import a file exported elsewhere; export without accounts answers
2024
2233
  * the error line. */
2025
- function AccountTransfer({ t, region, accounts }) {
2234
+ function AccountTransfer({ t, region, accounts, onChanged }) {
2026
2235
  const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
2027
2236
  const [dialog, setDialog] = (0, react.useState)(void 0);
2028
2237
  const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
2029
2238
  const [preview, setPreview] = (0, react.useState)(void 0);
2030
2239
  const [importSelected, setImportSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
2031
2240
  const fileInput = (0, react.useRef)(null);
2241
+ /**
2242
+ * The text of the file the user just picked.
2243
+ *
2244
+ * Held here rather than re-read from the input at import time: the picker
2245
+ * is a bare system dialog that closes immediately (see the import button),
2246
+ * so between preview and confirm there is no dialog on screen holding the
2247
+ * File. Re-reading `fileInput.current.files` would also break the moment
2248
+ * anything clears the input — and it must be cleared after every pick, or
2249
+ * choosing the SAME file twice fires no change event.
2250
+ */
2251
+ const importText = (0, react.useRef)("");
2032
2252
  const mounted = (0, react.useRef)(true);
2033
2253
  (0, react.useEffect)(() => {
2034
2254
  mounted.current = true;
@@ -2084,6 +2304,7 @@ window.__ModuleLoader__.load({
2084
2304
  setPhase({ kind: "importing" });
2085
2305
  try {
2086
2306
  const text = await file.text();
2307
+ importText.current = text;
2087
2308
  const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "preview"), {
2088
2309
  method: "POST",
2089
2310
  headers: {
@@ -2113,10 +2334,10 @@ window.__ModuleLoader__.load({
2113
2334
  }
2114
2335
  };
2115
2336
  const runImport = async () => {
2116
- if (preview === void 0 || importSelected.size === 0 || fileInput.current?.files?.[0] === void 0) return;
2337
+ if (preview === void 0 || importSelected.size === 0 || importText.current === "") return;
2117
2338
  setPhase({ kind: "importing" });
2118
2339
  try {
2119
- const text = await fileInput.current.files[0].text();
2340
+ const text = importText.current;
2120
2341
  const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "import"), {
2121
2342
  method: "POST",
2122
2343
  headers: {
@@ -2139,12 +2360,19 @@ window.__ModuleLoader__.load({
2139
2360
  return;
2140
2361
  }
2141
2362
  closeDialog();
2363
+ const message = t("row.transferImported", {
2364
+ imported: body.imported,
2365
+ skipped: body.skipped ?? 0
2366
+ });
2367
+ setPhase({
2368
+ kind: "refreshing",
2369
+ message
2370
+ });
2371
+ await onChanged?.();
2372
+ if (!mounted.current) return;
2142
2373
  setPhase({
2143
2374
  kind: "done",
2144
- message: t("row.transferImported", {
2145
- imported: body.imported,
2146
- skipped: body.skipped ?? 0
2147
- })
2375
+ message
2148
2376
  });
2149
2377
  } catch (error) {
2150
2378
  if (mounted.current) setPhase({
@@ -2153,11 +2381,18 @@ window.__ModuleLoader__.load({
2153
2381
  });
2154
2382
  }
2155
2383
  };
2156
- const busy = phase.kind === "exporting" || phase.kind === "importing";
2384
+ const busy = phase.kind === "exporting" || phase.kind === "importing" || phase.kind === "refreshing";
2385
+ /** The veil's scenario line: what is happening, in the user's terms. */
2386
+ const veilMessage = phase.kind === "exporting" ? t("row.transferExporting") : phase.kind === "importing" || phase.kind === "refreshing" ? t("row.transferImporting") : "";
2157
2387
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2158
2388
  className: "dsm-workbuddy-bar",
2159
2389
  "aria-label": t("row.transferTitle"),
2160
2390
  children: [
2391
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BusyOverlay, {
2392
+ visible: busy,
2393
+ message: veilMessage,
2394
+ ...busy ? { hint: t("row.veilHintList") } : {}
2395
+ }),
2161
2396
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2162
2397
  className: "dsm-workbuddy-bar-label",
2163
2398
  children: t("row.transferTitle")
@@ -2185,13 +2420,27 @@ window.__ModuleLoader__.load({
2185
2420
  className: "dsm-btn dsm-btn-outline",
2186
2421
  disabled: busy,
2187
2422
  onClick: () => {
2188
- setPreview(void 0);
2189
- setImportSelected(/* @__PURE__ */ new Set());
2190
- setDialog("import");
2423
+ fileInput.current?.click();
2191
2424
  },
2192
2425
  children: t("row.transferImport")
2193
2426
  })]
2194
2427
  }),
2428
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2429
+ ref: fileInput,
2430
+ type: "file",
2431
+ accept: "application/json,.json",
2432
+ className: "dsm-workbuddy-transfer-file",
2433
+ onChange: (event) => {
2434
+ const file = event.target.files?.[0];
2435
+ event.target.value = "";
2436
+ if (file !== void 0) {
2437
+ setPreview(void 0);
2438
+ setImportSelected(/* @__PURE__ */ new Set());
2439
+ setDialog("import");
2440
+ readImportFile(file);
2441
+ }
2442
+ }
2443
+ }),
2195
2444
  dialog === "export" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2196
2445
  className: "dsm-workbuddy-transfer-dialog",
2197
2446
  role: "dialog",
@@ -2238,62 +2487,49 @@ window.__ModuleLoader__.load({
2238
2487
  })
2239
2488
  ]
2240
2489
  }) : null,
2241
- dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2490
+ dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2242
2491
  className: "dsm-workbuddy-transfer-dialog",
2243
2492
  role: "dialog",
2244
2493
  "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
- }
2494
+ 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: [
2495
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferPreviewTotal", { total: preview.total }) }),
2496
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2497
+ className: "dsm-workbuddy-transfer-list",
2498
+ children: preview.accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
2499
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2500
+ type: "checkbox",
2501
+ disabled: !entry.hasToken,
2502
+ checked: importSelected.has(entry.index),
2503
+ onChange: (event) => {
2504
+ const next = new Set(importSelected);
2505
+ if (event.target.checked) next.add(entry.index);
2506
+ else next.delete(entry.index);
2507
+ setImportSelected(next);
2508
+ }
2509
+ }),
2510
+ " ",
2511
+ entry.nickname !== "" ? entry.nickname : entry.email !== "" ? entry.email : entry.uid !== "" ? entry.uid : t("row.transferEntryUnknown"),
2512
+ entry.hasToken ? "" : ` — ${t("row.transferNoToken")}`
2513
+ ] }) }, entry.index))
2256
2514
  }),
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
- ]
2515
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2516
+ className: "dsm-workbuddy-transfer-dialog-actions",
2517
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2518
+ type: "button",
2519
+ className: "dsm-btn dsm-btn-outline",
2520
+ onClick: closeDialog,
2521
+ children: t("row.transferCancel")
2522
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2523
+ type: "button",
2524
+ className: "dsm-btn dsm-btn-outline",
2525
+ disabled: importSelected.size === 0 || phase.kind === "importing",
2526
+ onClick: () => {
2527
+ runImport();
2528
+ },
2529
+ children: phase.kind === "importing" ? t("row.transferImporting") : t("row.transferImportConfirm", { count: importSelected.size })
2530
+ })]
2531
+ })
2532
+ ] })
2297
2533
  }) : null
2298
2534
  ]
2299
2535
  });
@@ -2645,6 +2881,35 @@ window.__ModuleLoader__.load({
2645
2881
  .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
2882
  .dsm-workbuddy-transfer-list input[type="checkbox"]{flex:none}
2647
2883
  .dsm-workbuddy-transfer input[type="file"]{font-size:12px;color:var(--dsw-alias-label-primary,#e8e9ed);margin-bottom:8px}
2884
+ /* The import picker's input is driven by the 「import」 button, so it must not
2885
+ render as a visible field: hide it from layout AND from assistive tech (the
2886
+ button carries the accessible name). Not display:none alone — that also
2887
+ removes it from the tab order in some engines, which is fine here, but the
2888
+ clip-based form is the one that reliably keeps programmatic .click() working
2889
+ across browsers. */
2890
+ .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}
2891
+ /* The sign-in URL inside the OAuth dialog: long https links must wrap rather
2892
+ than push the dialog wide — break-all, since URLs have no soft wrap points. */
2893
+ .dsm-workbuddy-oauth-url{word-break:break-all;overflow-wrap:anywhere}
2894
+ .dsm-workbuddy-oauth-url a{color:var(--dsw-alias-brand-primary,#5686fe);text-decoration:none}
2895
+ .dsm-workbuddy-oauth-url a:hover{text-decoration:underline}
2896
+ /* The shared busy veil: a fixed overlay for operations whose result only shows
2897
+ up after the list re-read (remove / import / sign-in).
2898
+ It is NOT a modal: no control, no dismissal — only "this is running" and
2899
+ "here is why nothing moved yet". Fixed positioning so it covers the card
2900
+ regardless of which section started the operation, and a semi-transparent
2901
+ backdrop so the table stays visible underneath — the user is watching a
2902
+ specific row change, and hiding it would defeat the point. */
2903
+ .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}
2904
+ .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)}
2905
+ .dsm-workbuddy-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#e8e9ed);text-align:center}
2906
+ .dsm-workbuddy-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999);text-align:center}
2907
+ /* The spinner is the one thing that proves the UI is alive when the row under
2908
+ it has not changed yet — which is precisely the confusion the veil exists to
2909
+ remove. Reduced-motion users get the static ring instead of a rotation. */
2910
+ .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}
2911
+ @keyframes dsm-workbuddy-veil-spin{to{transform:rotate(360deg)}}
2912
+ @media (prefers-reduced-motion: reduce){.dsm-workbuddy-veil-spinner{animation:none}}
2648
2913
  `;
2649
2914
  /**
2650
2915
  * Composer credit readout styles, injected by the readout component itself.
@@ -3274,8 +3539,18 @@ window.__ModuleLoader__.load({
3274
3539
  accounts: [],
3275
3540
  selectionExplicit: false
3276
3541
  };
3542
+ /**
3543
+ * Whether the ACTIVE region knows of any account at all.
3544
+ *
3545
+ * This is the poll's precondition in place of `status === 'signed-in'`. The
3546
+ * signed-in flag means "a credential resolves", which is false for a region
3547
+ * whose accounts exist but none is usable yet — exactly the state an import
3548
+ * or a fresh OAuth sign-in creates. Polling on it meant those regions never
3549
+ * refreshed, so the list stayed empty until the card was reopened.
3550
+ */
3551
+ const hasAccounts = status.status !== "error" && status.accounts.length > 0;
3277
3552
  (0, react.useEffect)(() => {
3278
- if (!open || status.status !== "signed-in") return;
3553
+ if (!open || !hasAccounts) return;
3279
3554
  const controller = new AbortController();
3280
3555
  const timer = window.setInterval(() => {
3281
3556
  refreshUsage(activeRegion, controller.signal);
@@ -3305,6 +3580,7 @@ window.__ModuleLoader__.load({
3305
3580
  */
3306
3581
  const rescanAccounts = async () => {
3307
3582
  setBusy(true);
3583
+ setAccountError(void 0);
3308
3584
  try {
3309
3585
  const response = await fetch(withWorkBuddyRegion(WORKBUDDY_ACCOUNTS_REFRESH_PATH, activeRegion), {
3310
3586
  method: "POST",
@@ -3313,7 +3589,10 @@ window.__ModuleLoader__.load({
3313
3589
  });
3314
3590
  const body = await response.json();
3315
3591
  if (!response.ok || !Array.isArray(body.accounts)) throw new Error(`HTTP ${response.status}`);
3592
+ if (body.dismissalsCleared === false) setAccountError(t("row.rescanHidesKept"));
3316
3593
  await refreshUsage(activeRegion);
3594
+ } catch (error) {
3595
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
3317
3596
  } finally {
3318
3597
  if (mounted.current) setBusy(false);
3319
3598
  }
@@ -3672,10 +3951,12 @@ window.__ModuleLoader__.load({
3672
3951
  */
3673
3952
  const accountTools = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OAuthSignIn, {
3674
3953
  t,
3675
- region: activeRegion
3954
+ region: activeRegion,
3955
+ onAdded: () => refreshUsage(activeRegion)
3676
3956
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountTransfer, {
3677
3957
  t,
3678
3958
  region: activeRegion,
3959
+ onChanged: () => refreshUsage(activeRegion),
3679
3960
  accounts: status.status === "error" ? [] : status.accounts.map((account) => ({
3680
3961
  id: account.id,
3681
3962
  accountName: account.accountName
@@ -3781,6 +4062,11 @@ window.__ModuleLoader__.load({
3781
4062
  className: "dsm-workbuddy-usage-hint",
3782
4063
  children: t("row.reloginHint")
3783
4064
  }) : null,
4065
+ accountError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4066
+ className: "dsm-workbuddy-account-error",
4067
+ role: "alert",
4068
+ children: accountError
4069
+ }),
3784
4070
  selectionLost ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3785
4071
  className: "dsm-workbuddy-usage-hint",
3786
4072
  children: t("row.selectionLostHint")
@@ -3831,9 +4117,7 @@ window.__ModuleLoader__.load({
3831
4117
  siblingBusy: saving || togglingRegion !== void 0,
3832
4118
  onBusyChange: setPoolBusy,
3833
4119
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
3834
- onRefresh: () => {
3835
- refreshUsage(activeRegion);
3836
- }
4120
+ onRefresh: () => refreshUsage(activeRegion)
3837
4121
  }),
3838
4122
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
3839
4123
  className: "dsm-workbuddy-models",
@@ -4681,6 +4965,7 @@ window.__ModuleLoader__.load({
4681
4965
  "row.sourceDsh": "plugin copy",
4682
4966
  "row.searchedMore": "Show the {count} path(s) that were not found",
4683
4967
  "row.reloginHint": "If errors occur, sign back in to the WorkBuddy app.",
4968
+ "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
4969
  "row.credentialRejectedTitle": "WorkBuddy refused this account's token",
4685
4970
  "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
4971
  "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 +5036,6 @@ window.__ModuleLoader__.load({
4751
5036
  "row.oauthStart": "Sign in with QR code",
4752
5037
  "row.oauthStarting": "Opening…",
4753
5038
  "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
5039
  "row.oauthOpened": "Opened. Waiting for you to finish…",
4756
5040
  "row.oauthWaiting": "Waiting for the sign-in to finish… ({seconds}s)",
4757
5041
  "row.oauthExpires": "The sign-in window expires in about {minutes} min.",
@@ -4760,7 +5044,14 @@ window.__ModuleLoader__.load({
4760
5044
  "row.oauthTimedOut": "The sign-in timed out — start again when ready.",
4761
5045
  "row.oauthDone": "Added “{accountName}”. Pick it above to use it.",
4762
5046
  "row.oauthError": "Sign-in failed: {message}",
4763
- "row.oauthUnavailable": "QR sign-in is not offered by this build of the plugin.",
5047
+ "row.oauthCopy": "Copy link",
5048
+ "row.oauthCopied": "Link copied",
5049
+ "row.oauthReopen": "Open page again",
5050
+ "row.oauthDialogClose": "Close",
5051
+ "row.oauthTitleGlobal": "Add an account by web sign-in",
5052
+ "row.oauthStartGlobal": "Sign in on the web",
5053
+ "row.veilHintList": "The list refreshes as soon as this finishes.",
5054
+ "row.oauthSyncing": "Adding the account…",
4764
5055
  "row.transferTitle": "Export / import accounts",
4765
5056
  "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
5057
  "row.transferExport": "Export accounts",
@@ -4770,7 +5061,7 @@ window.__ModuleLoader__.load({
4770
5061
  "row.transferExported": "Exported {count} account(s) to {fileName}.",
4771
5062
  "row.transferImport": "Import accounts",
4772
5063
  "row.transferImporting": "Importing…",
4773
- "row.transferImportPick": "Choose a JSON file exported from this or a compatible tool:",
5064
+ "row.transferPreviewing": "Reading the file…",
4774
5065
  "row.transferPreviewTotal": "{total} account(s) in the file — untick what should stay out. Accounts already stored here are overwritten by the import.",
4775
5066
  "row.transferImportConfirm": "Import {count} account(s)",
4776
5067
  "row.transferImported": "Imported {imported} account(s); {skipped} skipped (no token, or belongs to the other region).",
@@ -4878,10 +5169,11 @@ window.__ModuleLoader__.load({
4878
5169
  "row.poolUnsavedMembers": "Selection changed — save to apply",
4879
5170
  "row.poolRowTest": "Test",
4880
5171
  "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.",
5172
+ "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
5173
  "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.",
5174
+ "row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
5175
+ "row.poolRowRemoving": "Removing the account…",
5176
+ "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
5177
  "row.showCreditsCn": "Show WB CN credits in the composer",
4886
5178
  "row.showCreditsGlobal": "Show WB AI credits in the composer",
4887
5179
  "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 +5225,7 @@ window.__ModuleLoader__.load({
4933
5225
  "row.sourceDsh": "插件副本",
4934
5226
  "row.searchedMore": "显示另外 {count} 条「不存在」的路径",
4935
5227
  "row.reloginHint": "出现错误,重新登录 WorkBuddy APP 即可。",
5228
+ "row.rescanHidesKept": "已删除的账号仍是隐藏状态:插件没能更新自己的名单文件。Windows 上通常是文件被占用——请关闭可能正在占用 DSH 配置目录的杀毒/同步工具后重试。",
4936
5229
  "row.credentialRejectedTitle": "WorkBuddy 拒绝了这个账号的 token",
4937
5230
  "row.credentialRejectedIntro": "「{accountName}」的 token 已不被上游接受,所以积分与签到都用不了。重新登录只在「确实是 token 的问题」时才有用——当前实际适用的是下面这一条:",
4938
5231
  "row.credentialRejectedSwitch": "本机另一个账号「{accountName}」刚刚实测可用,切换到它即可恢复,不必重新登录。",
@@ -5003,7 +5296,6 @@ window.__ModuleLoader__.load({
5003
5296
  "row.oauthStart": "扫码登录",
5004
5297
  "row.oauthStarting": "正在打开…",
5005
5298
  "row.oauthOpenHint": "打开登录页并完成确认——完成后账号会自动出现在这里:",
5006
- "row.oauthOpen": "打开登录页",
5007
5299
  "row.oauthOpened": "已打开,等待你完成登录…",
5008
5300
  "row.oauthWaiting": "正在等待登录完成…({seconds} 秒)",
5009
5301
  "row.oauthExpires": "登录窗口约 {minutes} 分钟后过期。",
@@ -5012,7 +5304,14 @@ window.__ModuleLoader__.load({
5012
5304
  "row.oauthTimedOut": "登录超时——请重新开始。",
5013
5305
  "row.oauthDone": "已添加「{accountName}」。在上方选中它即可使用。",
5014
5306
  "row.oauthError": "登录失败:{message}",
5015
- "row.oauthUnavailable": "当前版本的插件不支持扫码登录。",
5307
+ "row.oauthCopy": "复制链接",
5308
+ "row.oauthCopied": "已复制链接",
5309
+ "row.oauthReopen": "重新打开页面",
5310
+ "row.oauthDialogClose": "关闭",
5311
+ "row.oauthTitleGlobal": "网页登录添加账号",
5312
+ "row.oauthStartGlobal": "网页登录",
5313
+ "row.veilHintList": "完成后列表会立即刷新。",
5314
+ "row.oauthSyncing": "正在添加账号…",
5016
5315
  "row.transferTitle": "导出 / 导入账号",
5017
5316
  "row.transferHint": "用 JSON 文件在电脑之间迁移账号。文件里是账号凭据——请妥善保管。该格式与同类 WorkBuddy 凭据管理工具通用。",
5018
5317
  "row.transferExport": "导出账号",
@@ -5022,7 +5321,7 @@ window.__ModuleLoader__.load({
5022
5321
  "row.transferExported": "已导出 {count} 个账号到 {fileName}。",
5023
5322
  "row.transferImport": "导入账号",
5024
5323
  "row.transferImporting": "正在导入…",
5025
- "row.transferImportPick": "选择本插件或同类工具导出的 JSON 文件:",
5324
+ "row.transferPreviewing": "正在读取文件…",
5026
5325
  "row.transferPreviewTotal": "文件中共 {total} 个账号——不需要的请取消勾选。导入会覆盖这里已存储的同名账号。",
5027
5326
  "row.transferImportConfirm": "导入 {count} 个账号",
5028
5327
  "row.transferImported": "已导入 {imported} 个账号;跳过 {skipped} 个(缺 token,或属于另一个区域)。",
@@ -5130,10 +5429,11 @@ window.__ModuleLoader__.load({
5130
5429
  "row.poolUnsavedMembers": "勾选已改动 —— 保存后生效",
5131
5430
  "row.poolRowTest": "测试",
5132
5431
  "row.poolRowRemove": "删除",
5133
- "row.poolRowRemoveHint": "删除插件保存的这个账号凭据。桌面 App 的登录不会被删除——下次扫描它还会出现;只有插件自己的副本会被移除。",
5432
+ "row.poolRowRemoveHint": "把这个账号从列表中移除。桌面 App 不会被退出登录——它保留自己的登录状态。移除只是暂时隐藏,点「重新检测账号」即可找回。",
5134
5433
  "row.poolRowRemoveAria": "删除 {accountName} 的已存凭据",
5135
- "row.poolRowRemoved": "{accountName}:已从插件存储中移除。",
5136
- "row.poolRowRemoveDesktopOnly": "{accountName}:没有插件存储的凭据可删——这个账号来自桌面 App 自己的登录。",
5434
+ "row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
5435
+ "row.poolRowRemoving": "正在删除账号…",
5436
+ "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
5137
5437
  "row.showCreditsCn": "在对话框显示 WB CN 积分",
5138
5438
  "row.showCreditsGlobal": "在对话框显示 WB AI 积分",
5139
5439
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",