dsh-connect-workbuddy 4.0.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
@@ -70,12 +70,47 @@ window.__ModuleLoader__.load({
70
70
  * splitting them would duplicate the guards and invite them to drift apart.
71
71
  */
72
72
  const WORKBUDDY_POOL_PATH = "/plugins/dsh-connect-workbuddy/pool";
73
+ /**
74
+ * Plugin-owned OAuth sign-in endpoint: one session step per call.
75
+ *
76
+ * `?action=start` asks the upstream for a login state and returns the URL to
77
+ * open; `?action=poll` (+ `loginId` in the body) reports whether the user
78
+ * finished, and on success persists the account into the vault. POST +
79
+ * loopback-only like the card's other mutations: the poll carries the login
80
+ * binding, and the Host-side session state must not be settable by another
81
+ * origin.
82
+ */
83
+ const WORKBUDDY_OAUTH_PATH = "/plugins/dsh-connect-workbuddy/oauth";
73
84
  /** Query parameter selecting which pool batch action a request means. */
74
85
  const WORKBUDDY_POOL_ACTION_PARAM = "action";
86
+ /** How long the card waits between polls while the user is scanning. */
87
+ const OAUTH_POLL_INTERVAL_MS = 2e3;
88
+ /**
89
+ * Plugin-owned account transfer endpoint: the batch export / import of
90
+ * credentials between machines and between tools of the SAME format family.
91
+ *
92
+ * `?action=export` POSTs account ids, answers a JSON ARRAY of credential
93
+ * records; `?action=preview` POSTs file text, answers a token-free listing;
94
+ * `?action=import` POSTs file text plus selected indexes, merges into the
95
+ * vault. POST + loopback-only like every other mutation: the file content is
96
+ * arbitrary user data and export carries token material.
97
+ */
98
+ const WORKBUDDY_TRANSFER_PATH = "/plugins/dsh-connect-workbuddy/transfer";
75
99
  /** Address one region's pool endpoint with an action. */
76
100
  function withWorkBuddyRegionAndAction(path, region, action) {
77
101
  return `${withWorkBuddyRegion(path, region)}&${WORKBUDDY_POOL_ACTION_PARAM}=${action}`;
78
102
  }
103
+ /**
104
+ * Address one region's endpoint for ANY route that dispatches on `action`.
105
+ *
106
+ * The OAuth and transfer routes reject a request with no action (400), so
107
+ * their callers must build the URL here rather than with
108
+ * {@link withWorkBuddyRegion} — a request missing the parameter fails the same
109
+ * way whether the omission was a mistake or not.
110
+ */
111
+ function withWorkBuddyRouteAction(path, region, action) {
112
+ return `${withWorkBuddyRegion(path, region)}&${WORKBUDDY_POOL_ACTION_PARAM}=${action}`;
113
+ }
79
114
  /** Query parameter naming the region a card request addresses. */
80
115
  const WORKBUDDY_REGION_PARAM = "region";
81
116
  /** Every region, in card tab order. */
@@ -614,6 +649,40 @@ window.__ModuleLoader__.load({
614
649
  }
615
650
  }
616
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
617
686
  //#region src/client/ndjson.ts
618
687
  /**
619
688
  * Read a newline-delimited JSON body, one object at a time.
@@ -1091,7 +1160,7 @@ window.__ModuleLoader__.load({
1091
1160
  * section implying the feature exists.
1092
1161
  */
1093
1162
  function AccountPool(props) {
1094
- const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount } = props;
1163
+ const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools } = props;
1095
1164
  const [draft, setDraft] = (0, react.useState)(null);
1096
1165
  const [saving, setSaving] = (0, react.useState)(false);
1097
1166
  const [saveError, setSaveError] = (0, react.useState)(void 0);
@@ -1116,6 +1185,24 @@ window.__ModuleLoader__.load({
1116
1185
  memberAccountIds: pool?.memberAccountIds ?? []
1117
1186
  };
1118
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;
1119
1206
  const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1120
1207
  /**
1121
1208
  * Membership that actually resolves to a local sign-in — what a batch runs on.
@@ -1367,6 +1454,118 @@ window.__ModuleLoader__.load({
1367
1454
  region,
1368
1455
  t
1369
1456
  ]);
1457
+ /** The account whose per-row action is in flight; gates every row's buttons. */
1458
+ const [rowBusyAccountId, setRowBusyAccountId] = (0, react.useState)(void 0);
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
+ /**
1468
+ * Test ONE account from its row.
1469
+ *
1470
+ * Same Host rule as the batch (testOne reuses the batch's probe), the same
1471
+ * log vocabulary, and the same refresh-after — a row result must read exactly
1472
+ * like the batch result it replaces.
1473
+ */
1474
+ const runRowTest = (0, react.useCallback)(async (accountId) => {
1475
+ setRowBusyAccountId(accountId);
1476
+ setRowBusyAction("test");
1477
+ setActionError(void 0);
1478
+ const url = withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, "test") + "&accountId=" + encodeURIComponent(accountId);
1479
+ try {
1480
+ const response = await fetch(url, {
1481
+ method: "POST",
1482
+ headers: { accept: "application/json" },
1483
+ credentials: "same-origin"
1484
+ });
1485
+ const body = await response.json().catch(() => void 0);
1486
+ if (!response.ok) throw new Error(poolFailureText(t, {
1487
+ status: response.status,
1488
+ reason: body?.reason,
1489
+ error: body?.error
1490
+ }));
1491
+ if (!mounted.current) return;
1492
+ const row = body?.rows?.[0];
1493
+ if (row === void 0) {
1494
+ setActionError(t("row.poolLogTestRowMalformed", { accountName: t("row.accountUnnamed") }));
1495
+ return;
1496
+ }
1497
+ const outcome = row.result?.outcome;
1498
+ if (outcome === void 0) appendLog(t("row.poolLogTestRowMalformed", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "warn");
1499
+ else appendLog(t("row.poolLogTestRow", {
1500
+ accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
1501
+ outcome: outcomeText(t, outcome)
1502
+ }), outcomeOk(outcome) ? "ok" : "warn");
1503
+ onRefresh?.();
1504
+ } catch (error) {
1505
+ const message = error instanceof Error ? error.message : t("row.requestFailed");
1506
+ if (mounted.current) {
1507
+ setActionError(message);
1508
+ appendLog(t("row.poolLogBatchFailed", { message }), "error");
1509
+ }
1510
+ } finally {
1511
+ if (mounted.current) {
1512
+ setRowBusyAccountId(void 0);
1513
+ setRowBusyAction(void 0);
1514
+ }
1515
+ }
1516
+ }, [
1517
+ appendLog,
1518
+ onRefresh,
1519
+ region,
1520
+ t
1521
+ ]);
1522
+ /**
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.
1530
+ */
1531
+ const runRowRemove = (0, react.useCallback)(async (accountId, accountName) => {
1532
+ setRowBusyAccountId(accountId);
1533
+ setRowBusyAction("remove");
1534
+ setActionError(void 0);
1535
+ const url = withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, "remove") + "&accountId=" + encodeURIComponent(accountId);
1536
+ try {
1537
+ const response = await fetch(url, {
1538
+ method: "POST",
1539
+ headers: { accept: "application/json" },
1540
+ credentials: "same-origin"
1541
+ });
1542
+ const body = await response.json().catch(() => void 0);
1543
+ if (!response.ok) throw new Error(poolFailureText(t, {
1544
+ status: response.status,
1545
+ reason: body?.reason,
1546
+ error: body?.error
1547
+ }));
1548
+ if (!mounted.current) return;
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");
1550
+ await onRefresh?.();
1551
+ } catch (error) {
1552
+ const message = error instanceof Error ? error.message : t("row.requestFailed");
1553
+ if (mounted.current) {
1554
+ setActionError(message);
1555
+ appendLog(t("row.poolLogBatchFailed", { message }), "error");
1556
+ }
1557
+ } finally {
1558
+ if (mounted.current) {
1559
+ setRowBusyAccountId(void 0);
1560
+ setRowBusyAction(void 0);
1561
+ }
1562
+ }
1563
+ }, [
1564
+ appendLog,
1565
+ onRefresh,
1566
+ region,
1567
+ t
1568
+ ]);
1370
1569
  if (pool === void 0) return null;
1371
1570
  /**
1372
1571
  * Where the target model came from, and which model it is.
@@ -1390,7 +1589,15 @@ window.__ModuleLoader__.load({
1390
1589
  */
1391
1590
  const currentAccount = pool.accounts.find((account) => account.current);
1392
1591
  const currentName = currentAccount === void 0 ? void 0 : currentAccount.accountName === "" ? t("row.accountUnnamed") : currentAccount.accountName;
1393
- 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", {
1394
1601
  className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
1395
1602
  value: currentAccount?.accountId ?? "",
1396
1603
  disabled: selectingAccount === true,
@@ -1423,7 +1630,7 @@ window.__ModuleLoader__.load({
1423
1630
  className: "dsm-btn dsm-btn-outline",
1424
1631
  disabled: rescanning === true,
1425
1632
  onClick: onRescan
1426
- }, 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", {
1427
1634
  className: "dsm-workbuddy-pool-warn",
1428
1635
  role: "status"
1429
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", {
@@ -1439,13 +1646,22 @@ window.__ModuleLoader__.load({
1439
1646
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1440
1647
  disabled: effectiveMembers.length === 0 || !canEditPool,
1441
1648
  onClick: () => selectAll(false)
1442
- }, t("row.poolSelectNone")))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-table" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null), ...pool.accounts.map((account) => renderAccountRow({
1649
+ }, t("row.poolSelectNone")))), (0, react.createElement)("div", { className: `dsm-workbuddy-pool-table${pool.checkinSupported ? " dsm-workbuddy-pool-table-checkin" : ""}` }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null, (0, react.createElement)("span", null)), ...pool.accounts.map((account) => renderAccountRow({
1443
1650
  t,
1444
1651
  account,
1445
1652
  checked: active.memberAccountIds.includes(account.accountId),
1446
1653
  canEdit: canEditPool,
1447
1654
  checkinSupported: pool.checkinSupported,
1448
- onToggle: (next) => toggleMember(account.accountId, next)
1655
+ onToggle: (next) => toggleMember(account.accountId, next),
1656
+ busy: rowBusyAccountId !== void 0,
1657
+ testDisabled: pool.targetModelSource === "none" || pool.targetModelSource === "stale",
1658
+ targetHint: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
1659
+ onTest: () => {
1660
+ runRowTest(account.accountId);
1661
+ },
1662
+ onRemove: () => {
1663
+ runRowRemove(account.accountId, account.accountName);
1664
+ }
1449
1665
  }))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
1450
1666
  className: "dsm-workbuddy-pool-warn",
1451
1667
  role: "status"
@@ -1483,7 +1699,8 @@ window.__ModuleLoader__.load({
1483
1699
  },
1484
1700
  onDiscard: discard,
1485
1701
  appendLog,
1486
- targetProvenance
1702
+ targetProvenance,
1703
+ ...accountTools === void 0 ? {} : { accountTools }
1487
1704
  }), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolRegionNote")), renderLog({
1488
1705
  t,
1489
1706
  log,
@@ -1496,7 +1713,7 @@ window.__ModuleLoader__.load({
1496
1713
  }
1497
1714
  /** One account's row. */
1498
1715
  function renderAccountRow(input) {
1499
- const { t, account, checked, canEdit, checkinSupported, onToggle } = input;
1716
+ const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove } = input;
1500
1717
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
1501
1718
  const excluded = exclusionText(t, account.excludedBy);
1502
1719
  const probe = account.probe;
@@ -1537,11 +1754,24 @@ window.__ModuleLoader__.load({
1537
1754
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
1538
1755
  className: "dsm-workbuddy-pool-unknown",
1539
1756
  title: t("row.poolNeverTested")
1540
- }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null);
1757
+ }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-row-actions" }, (0, react.createElement)("button", {
1758
+ type: "button",
1759
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1760
+ disabled: busy || testDisabled,
1761
+ title: testDisabled ? targetHint : void 0,
1762
+ onClick: onTest
1763
+ }, t("row.poolRowTest")), (0, react.createElement)("button", {
1764
+ type: "button",
1765
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1766
+ disabled: busy,
1767
+ title: t("row.poolRowRemoveHint"),
1768
+ "aria-label": t("row.poolRowRemoveAria", { accountName: name }),
1769
+ onClick: onRemove
1770
+ }, t("row.poolRowRemove"))));
1541
1771
  }
1542
1772
  /** The preference editor plus its save/discard actions. */
1543
1773
  function renderSettings(input) {
1544
- const { t, pool, active, dirty, saving, siblingBusy, saveError, settingsScope, onEdit, onSave, onDiscard, targetProvenance } = input;
1774
+ const { t, pool, active, dirty, saving, siblingBusy, saveError, settingsScope, onEdit, onSave, onDiscard, targetProvenance, accountTools } = input;
1545
1775
  const canEdit = settingsScope !== void 0;
1546
1776
  const toggle = (label, hint, checked, onChange, disabled = false) => (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, label), (0, react.createElement)("span", null, hint)), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("label", { className: "sw" }, (0, react.createElement)("input", {
1547
1777
  type: "checkbox",
@@ -1549,7 +1779,7 @@ window.__ModuleLoader__.load({
1549
1779
  disabled: disabled || !canEdit,
1550
1780
  onChange: (event) => onChange(event.currentTarget.checked)
1551
1781
  }), (0, react.createElement)("span", { className: "sw-track" }))));
1552
- return (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings-wrap" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolSettingsTitle")), dirty ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolDirty")) : null), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolSettingsHint")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings" }, toggle(t("row.poolEnabled"), t("row.poolEnabledHint"), active.enabled, (next) => onEdit((current) => ({
1782
+ return (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings-wrap" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolSettingsTitle")), dirty ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolDirty")) : null), accountTools === void 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-tools" }, accountTools), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolSettingsHint")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings" }, toggle(t("row.poolEnabled"), t("row.poolEnabledHint"), active.enabled, (next) => onEdit((current) => ({
1553
1783
  ...current,
1554
1784
  enabled: next
1555
1785
  }))), (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.poolTargetLabel")), (0, react.createElement)("span", null, targetProvenance === null ? null : `${targetProvenance} `, t("row.poolTargetHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
@@ -1621,6 +1851,690 @@ window.__ModuleLoader__.load({
1621
1851
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-log-time" }, formatClock$1(entry.atMs)), (0, react.createElement)("span", { className: `dsm-workbuddy-pool-log-text dsm-workbuddy-pool-log-${entry.tone}` }, entry.text)))));
1622
1852
  }
1623
1853
  //#endregion
1854
+ //#region src/client/OAuthSignIn.tsx
1855
+ /**
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,与超时同一条文案路径。
1872
+ *
1873
+ * @module dsh-connect-workbuddy/client/OAuthSignIn
1874
+ */
1875
+ /** Seconds since the login started, for the waiting line. */
1876
+ function elapsedSeconds(sinceMs, nowMs) {
1877
+ return Math.max(0, Math.floor((nowMs - sinceMs) / 1e3));
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
+ }
1888
+ /**
1889
+ * The section. Rendered on BOTH sign-in states: a signed-out machine gains
1890
+ * its first account here; a signed-in one can add another without leaving
1891
+ * the card.
1892
+ */
1893
+ function OAuthSignIn({ t, region, onAdded }) {
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);
1897
+ const [pollTick, setPollTick] = (0, react.useState)(0);
1898
+ const [nowMs, setNowMs] = (0, react.useState)(() => Date.now());
1899
+ const mounted = (0, react.useRef)(true);
1900
+ (0, react.useEffect)(() => {
1901
+ mounted.current = true;
1902
+ return () => {
1903
+ mounted.current = false;
1904
+ };
1905
+ }, []);
1906
+ const waiting = phase.kind === "waiting";
1907
+ (0, react.useEffect)(() => {
1908
+ if (!waiting || phase.kind !== "waiting") return;
1909
+ const expiresAtMs = phase.expiresAtMs;
1910
+ const timer = setInterval(() => {
1911
+ if (!mounted.current) return;
1912
+ const now = Date.now();
1913
+ setNowMs(now);
1914
+ if (now > expiresAtMs) setPhase({
1915
+ kind: "finished",
1916
+ message: "error",
1917
+ error: t("row.oauthTimedOut")
1918
+ });
1919
+ }, 1e3);
1920
+ return () => {
1921
+ clearInterval(timer);
1922
+ };
1923
+ }, [
1924
+ waiting,
1925
+ phase,
1926
+ t
1927
+ ]);
1928
+ (0, react.useEffect)(() => {
1929
+ if (phase.kind !== "waiting") return;
1930
+ const { loginId } = phase;
1931
+ let cancelled = false;
1932
+ const timer = setTimeout(() => {
1933
+ (async () => {
1934
+ try {
1935
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_OAUTH_PATH, region, "poll"), {
1936
+ method: "POST",
1937
+ headers: {
1938
+ "content-type": "application/json",
1939
+ accept: "application/json"
1940
+ },
1941
+ credentials: "same-origin",
1942
+ body: JSON.stringify({ loginId })
1943
+ });
1944
+ const body = await response.json();
1945
+ if (cancelled || !mounted.current) return;
1946
+ if (!response.ok || !body || typeof body !== "object") {
1947
+ setPhase({
1948
+ kind: "finished",
1949
+ message: "error",
1950
+ error: `HTTP ${response.status}`
1951
+ });
1952
+ return;
1953
+ }
1954
+ if (!body.done) {
1955
+ setPollTick((tick) => tick + 1);
1956
+ return;
1957
+ }
1958
+ if (typeof body.error === "string" && body.error !== "") setPhase({
1959
+ kind: "finished",
1960
+ message: "error",
1961
+ error: body.error
1962
+ });
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
+ }
1977
+ } catch {
1978
+ setPollTick((tick) => tick + 1);
1979
+ }
1980
+ })();
1981
+ }, OAUTH_POLL_INTERVAL_MS);
1982
+ return () => {
1983
+ cancelled = true;
1984
+ clearTimeout(timer);
1985
+ };
1986
+ }, [
1987
+ onAdded,
1988
+ phase,
1989
+ pollTick,
1990
+ region
1991
+ ]);
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) => {
2026
+ setPhase({ kind: "opening" });
2027
+ setCopied(false);
2028
+ try {
2029
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_OAUTH_PATH, region, "start"), {
2030
+ method: "POST",
2031
+ headers: { accept: "application/json" },
2032
+ credentials: "same-origin"
2033
+ });
2034
+ const body = await response.json();
2035
+ if (!mounted.current) {
2036
+ placeholder?.close();
2037
+ return;
2038
+ }
2039
+ if (!response.ok || typeof body.loginId !== "string" || typeof body.verificationUri !== "string") {
2040
+ placeholder?.close();
2041
+ setPhase({
2042
+ kind: "finished",
2043
+ message: "error",
2044
+ error: body.error ?? `HTTP ${response.status}`
2045
+ });
2046
+ return;
2047
+ }
2048
+ if (placeholder !== null) placeholder.location.href = body.verificationUri;
2049
+ setPhase({
2050
+ kind: "waiting",
2051
+ loginId: body.loginId,
2052
+ verificationUri: body.verificationUri,
2053
+ startedAtMs: Date.now(),
2054
+ expiresAtMs: Date.now() + (typeof body.expiresIn === "number" && body.expiresIn > 0 ? body.expiresIn : 600) * 1e3
2055
+ });
2056
+ setNowMs(Date.now());
2057
+ } catch (error) {
2058
+ placeholder?.close();
2059
+ if (mounted.current) setPhase({
2060
+ kind: "finished",
2061
+ message: "error",
2062
+ error: error instanceof Error ? error.message : String(error)
2063
+ });
2064
+ }
2065
+ };
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 ?? "" }) : "";
2092
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2093
+ className: "dsm-workbuddy-bar",
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")
2100
+ }),
2101
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2102
+ className: "dsm-workbuddy-bar-label",
2103
+ children: t(titleKey)
2104
+ }),
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
+ ]
2193
+ });
2194
+ }
2195
+ //#endregion
2196
+ //#region src/client/AccountTransfer.tsx
2197
+ /**
2198
+ * The card's account-transfer section: batch export / import of the region's
2199
+ * stored credentials, in the file format shared with sibling credential
2200
+ * managers for the same upstream.
2201
+ *
2202
+ * 参考:扫码登录小节(OAuthSignIn)的组件形态 —— 单个 section、就地管理
2203
+ * 全部状态(busy / 结果行 / 错误)、零跨组件往返。导出弹框的交互(多选
2204
+ * 账号 → 下载 JSON)与导入弹框(选文件 → 脱敏预览 → 勾选 → 导入)沿用
2205
+ * 通行实现的步骤划分;预览只含展示字段与 hasToken 布尔量,token 明文
2206
+ * 永不进入浏览器。
2207
+ *
2208
+ * 导出用 <a download> Blob:卡片运行在设置页 iframe 里,无宿主保存对话框
2209
+ * 可用;a[download] 同源下载是沙箱内最可靠的方式。
2210
+ *
2211
+ * @module dsh-connect-workbuddy/client/AccountTransfer
2212
+ */
2213
+ /** Build the default export file name: workbuddy-accounts-YYYY-MM-DD.json. */
2214
+ function exportFileName() {
2215
+ const pad = (value) => String(value).padStart(2, "0");
2216
+ const d = /* @__PURE__ */ new Date();
2217
+ return `workbuddy-accounts-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}.json`;
2218
+ }
2219
+ /** Download a JSON array as a local file (no host save dialog inside the iframe). */
2220
+ function downloadJson(fileName, data) {
2221
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
2222
+ const url = URL.createObjectURL(blob);
2223
+ const anchor = document.createElement("a");
2224
+ anchor.href = url;
2225
+ anchor.download = fileName;
2226
+ document.body.appendChild(anchor);
2227
+ anchor.click();
2228
+ anchor.remove();
2229
+ URL.revokeObjectURL(url);
2230
+ }
2231
+ /** The section. Rendered on both sign-in states — a signed-out machine can
2232
+ * still import a file exported elsewhere; export without accounts answers
2233
+ * the error line. */
2234
+ function AccountTransfer({ t, region, accounts, onChanged }) {
2235
+ const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
2236
+ const [dialog, setDialog] = (0, react.useState)(void 0);
2237
+ const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
2238
+ const [preview, setPreview] = (0, react.useState)(void 0);
2239
+ const [importSelected, setImportSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
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)("");
2252
+ const mounted = (0, react.useRef)(true);
2253
+ (0, react.useEffect)(() => {
2254
+ mounted.current = true;
2255
+ return () => {
2256
+ mounted.current = false;
2257
+ };
2258
+ }, []);
2259
+ const closeDialog = () => {
2260
+ setDialog(void 0);
2261
+ setSelected(/* @__PURE__ */ new Set());
2262
+ setPreview(void 0);
2263
+ setImportSelected(/* @__PURE__ */ new Set());
2264
+ };
2265
+ const runExport = async () => {
2266
+ if (selected.size === 0) return;
2267
+ setPhase({ kind: "exporting" });
2268
+ try {
2269
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "export"), {
2270
+ method: "POST",
2271
+ headers: {
2272
+ "content-type": "application/json",
2273
+ accept: "application/json"
2274
+ },
2275
+ credentials: "same-origin",
2276
+ body: JSON.stringify({ accountIds: [...selected] })
2277
+ });
2278
+ const body = await response.json();
2279
+ if (!mounted.current) return;
2280
+ if (!response.ok || !Array.isArray(body.accounts)) {
2281
+ setPhase({
2282
+ kind: "error",
2283
+ message: body.error ?? `HTTP ${response.status}`
2284
+ });
2285
+ return;
2286
+ }
2287
+ downloadJson(exportFileName(), body.accounts);
2288
+ closeDialog();
2289
+ setPhase({
2290
+ kind: "done",
2291
+ message: t("row.transferExported", {
2292
+ count: body.accounts.length,
2293
+ fileName: exportFileName()
2294
+ })
2295
+ });
2296
+ } catch (error) {
2297
+ if (mounted.current) setPhase({
2298
+ kind: "error",
2299
+ message: error instanceof Error ? error.message : String(error)
2300
+ });
2301
+ }
2302
+ };
2303
+ const readImportFile = async (file) => {
2304
+ setPhase({ kind: "importing" });
2305
+ try {
2306
+ const text = await file.text();
2307
+ importText.current = text;
2308
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "preview"), {
2309
+ method: "POST",
2310
+ headers: {
2311
+ "content-type": "application/json",
2312
+ accept: "application/json"
2313
+ },
2314
+ credentials: "same-origin",
2315
+ body: JSON.stringify({ text })
2316
+ });
2317
+ const body = await response.json();
2318
+ if (!mounted.current) return;
2319
+ if (!response.ok || !Array.isArray(body.accounts)) {
2320
+ setPhase({
2321
+ kind: "error",
2322
+ message: body.error ?? `HTTP ${response.status}`
2323
+ });
2324
+ return;
2325
+ }
2326
+ setPreview(body);
2327
+ setImportSelected(new Set(body.accounts.filter((entry) => entry.hasToken).map((entry) => entry.index)));
2328
+ setPhase({ kind: "idle" });
2329
+ } catch (error) {
2330
+ if (mounted.current) setPhase({
2331
+ kind: "error",
2332
+ message: error instanceof Error ? error.message : String(error)
2333
+ });
2334
+ }
2335
+ };
2336
+ const runImport = async () => {
2337
+ if (preview === void 0 || importSelected.size === 0 || importText.current === "") return;
2338
+ setPhase({ kind: "importing" });
2339
+ try {
2340
+ const text = importText.current;
2341
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "import"), {
2342
+ method: "POST",
2343
+ headers: {
2344
+ "content-type": "application/json",
2345
+ accept: "application/json"
2346
+ },
2347
+ credentials: "same-origin",
2348
+ body: JSON.stringify({
2349
+ text,
2350
+ indexes: [...importSelected]
2351
+ })
2352
+ });
2353
+ const body = await response.json();
2354
+ if (!mounted.current) return;
2355
+ if (!response.ok || typeof body.imported !== "number") {
2356
+ setPhase({
2357
+ kind: "error",
2358
+ message: body.error ?? `HTTP ${response.status}`
2359
+ });
2360
+ return;
2361
+ }
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;
2373
+ setPhase({
2374
+ kind: "done",
2375
+ message
2376
+ });
2377
+ } catch (error) {
2378
+ if (mounted.current) setPhase({
2379
+ kind: "error",
2380
+ message: error instanceof Error ? error.message : String(error)
2381
+ });
2382
+ }
2383
+ };
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") : "";
2387
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2388
+ className: "dsm-workbuddy-bar",
2389
+ "aria-label": t("row.transferTitle"),
2390
+ children: [
2391
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BusyOverlay, {
2392
+ visible: busy,
2393
+ message: veilMessage,
2394
+ ...busy ? { hint: t("row.veilHintList") } : {}
2395
+ }),
2396
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2397
+ className: "dsm-workbuddy-bar-label",
2398
+ children: t("row.transferTitle")
2399
+ }),
2400
+ phase.kind === "done" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2401
+ className: "dsm-workbuddy-bar-status dsm-workbuddy-bar-ok",
2402
+ children: phase.message
2403
+ }) : phase.kind === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2404
+ className: "dsm-workbuddy-bar-status dsm-workbuddy-bar-error",
2405
+ children: t("row.transferError", { message: phase.message })
2406
+ }) : null,
2407
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2408
+ className: "dsm-workbuddy-bar-actions",
2409
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2410
+ type: "button",
2411
+ className: "dsm-btn dsm-btn-outline",
2412
+ disabled: busy || accounts.length === 0,
2413
+ onClick: () => {
2414
+ setSelected(/* @__PURE__ */ new Set());
2415
+ setDialog("export");
2416
+ },
2417
+ children: t("row.transferExport")
2418
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2419
+ type: "button",
2420
+ className: "dsm-btn dsm-btn-outline",
2421
+ disabled: busy,
2422
+ onClick: () => {
2423
+ fileInput.current?.click();
2424
+ },
2425
+ children: t("row.transferImport")
2426
+ })]
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
+ }),
2444
+ dialog === "export" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2445
+ className: "dsm-workbuddy-transfer-dialog",
2446
+ role: "dialog",
2447
+ "aria-label": t("row.transferExport"),
2448
+ children: [
2449
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferExportPick", { total: accounts.length }) }),
2450
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2451
+ className: "dsm-workbuddy-hint",
2452
+ children: t("row.transferHint")
2453
+ }),
2454
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2455
+ className: "dsm-workbuddy-transfer-list",
2456
+ children: accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
2457
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2458
+ type: "checkbox",
2459
+ checked: selected.has(account.id),
2460
+ onChange: (event) => {
2461
+ const next = new Set(selected);
2462
+ if (event.target.checked) next.add(account.id);
2463
+ else next.delete(account.id);
2464
+ setSelected(next);
2465
+ }
2466
+ }),
2467
+ " ",
2468
+ account.accountName === "" ? t("row.accountUnnamed") : account.accountName
2469
+ ] }) }, account.id))
2470
+ }),
2471
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2472
+ className: "dsm-workbuddy-transfer-dialog-actions",
2473
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2474
+ type: "button",
2475
+ className: "dsm-btn dsm-btn-outline",
2476
+ onClick: closeDialog,
2477
+ children: t("row.transferCancel")
2478
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2479
+ type: "button",
2480
+ className: "dsm-btn dsm-btn-outline",
2481
+ disabled: selected.size === 0 || phase.kind === "exporting",
2482
+ onClick: () => {
2483
+ runExport();
2484
+ },
2485
+ children: phase.kind === "exporting" ? t("row.transferExporting") : t("row.transferExportConfirm", { count: selected.size })
2486
+ })]
2487
+ })
2488
+ ]
2489
+ }) : null,
2490
+ dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2491
+ className: "dsm-workbuddy-transfer-dialog",
2492
+ role: "dialog",
2493
+ "aria-label": t("row.transferImport"),
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))
2514
+ }),
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
+ ] })
2533
+ }) : null
2534
+ ]
2535
+ });
2536
+ }
2537
+ //#endregion
1624
2538
  //#region src/native-modality.ts
1625
2539
  /**
1626
2540
  * Reviewed vendor classifications, keyed by EXACT model id (never a prefix).
@@ -1845,7 +2759,9 @@ window.__ModuleLoader__.load({
1845
2759
  .dsm-workbuddy-pool-warn{margin:0;padding:9px 11px;border-radius:9px;font-size:12px;line-height:17px;color:var(--dsw-alias-state-warn-primary,#f59e0b);background:rgba(245,158,11,.09);border:1px solid rgba(245,158,11,.3)}
1846
2760
  .dsm-workbuddy-pool-error{margin:0;font-size:12px;line-height:17px;color:var(--dsw-alias-state-error-primary,#ef4444);white-space:pre-line}
1847
2761
  .dsm-workbuddy-pool-table{border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
1848
- .dsm-workbuddy-pool-row{display:grid;grid-template-columns:1.6fr 1fr 1.3fr .9fr;gap:10px;align-items:center;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:13px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
2762
+ .dsm-workbuddy-pool-row{display:grid;grid-template-columns:1.6fr 1fr 1.3fr .9fr .8fr;gap:10px;align-items:center;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:13px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
2763
+ /* No check-in column (the international region): the freed track goes to the actions. */
2764
+ .dsm-workbuddy-pool-table:not(.dsm-workbuddy-pool-table-checkin) .dsm-workbuddy-pool-row{grid-template-columns:1.6fr 1fr 1.3fr 1.7fr}
1849
2765
  .dsm-workbuddy-pool-row:first-child{border-top:0}
1850
2766
  .dsm-workbuddy-pool-row-head{padding:8px 14px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-weight:600;letter-spacing:.02em;text-transform:uppercase}
1851
2767
  .dsm-workbuddy-pool-row-current{background:rgba(86,134,254,.07)}
@@ -1889,6 +2805,12 @@ window.__ModuleLoader__.load({
1889
2805
  .dsm-workbuddy-pool-settings-wrap{display:flex;flex-direction:column;gap:10px}
1890
2806
  .dsm-workbuddy-pool-dirty{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
1891
2807
  .dsm-workbuddy-pool-settings{display:flex;flex-direction:column;gap:1px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
2808
+ /* QR sign-in + export/import, hosted inside the pool settings block.
2809
+ The block's own bordered rows belong to the SAVED preferences, so these
2810
+ get their own container: same rhythm (12px 14px) so the two read as one
2811
+ group, but a dashed edge marking them as immediate actions rather than
2812
+ drafts the Save button below would commit. */
2813
+ .dsm-workbuddy-pool-tools{display:flex;flex-direction:column;gap:2px;border:1px dashed var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;padding:4px 10px}
1892
2814
  .dsm-workbuddy-pool-set{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:12px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31)}
1893
2815
  .dsm-workbuddy-pool-set:first-child{border-top:0}
1894
2816
  .dsm-workbuddy-pool-set-copy{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
@@ -1918,6 +2840,9 @@ window.__ModuleLoader__.load({
1918
2840
  .dsm-workbuddy-pool-log-error{color:var(--dsw-alias-state-error-primary,#ef4444)}
1919
2841
  .dsm-workbuddy-pool-log-info{color:var(--dsw-alias-label-secondary,#c6c9d0)}
1920
2842
  .dsm-workbuddy-pool-log-empty{padding:14px;font-size:12px;color:var(--dsw-alias-label-tertiary,#999)}
2843
+ /* Per-row actions (test / remove), right side of each pool row. */
2844
+ .dsm-workbuddy-pool-row-actions{display:flex;gap:6px;align-items:center;justify-content:flex-end;flex:none}
2845
+ .dsm-workbuddy-pool-row-actions .dsm-workbuddy-pool-small-btn{padding:2px 10px;font-size:12px}
1921
2846
  @media (max-width:760px){.dsm-workbuddy-pool-row{grid-template-columns:1fr 1fr;row-gap:6px}.dsm-workbuddy-pool-select{max-width:180px}}
1922
2847
  /* Pool switch, namespaced under .dsm-workbuddy-pool so it cannot collide with host styles. */
1923
2848
  .dsm-workbuddy-pool .sw{position:relative;display:inline-block;width:38px;height:22px;flex:none;cursor:pointer}
@@ -1928,6 +2853,63 @@ window.__ModuleLoader__.load({
1928
2853
  .dsm-workbuddy-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
1929
2854
  .dsm-workbuddy-pool .sw input:disabled+.sw-track{opacity:.4}
1930
2855
  .dsm-workbuddy-pool .sw input:disabled{cursor:default}
2856
+ /* Account action bars: QR sign-in and export/import.
2857
+ One row each — label, live status, actions — instead of a padded block
2858
+ per feature. The bar is the resting height; only an open dialog (below)
2859
+ grows vertically, so the card does not scroll past a wall of chrome to
2860
+ reach the buttons. Status text shares the row rather than claiming its own,
2861
+ and is what truncates first when the card is narrow. */
2862
+ .dsm-workbuddy-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0;padding:0}
2863
+ .dsm-workbuddy-bar-label{flex:none;margin:0;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#c6c9d0)}
2864
+ .dsm-workbuddy-bar-status{flex:1 1 auto;min-width:0;margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e8e9ed);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2865
+ .dsm-workbuddy-bar-hint{color:var(--dsw-alias-label-tertiary,#999)}
2866
+ .dsm-workbuddy-bar-actions{display:flex;gap:8px;flex:none;margin-left:auto}
2867
+ .dsm-workbuddy-bar a{color:var(--dsw-alias-brand-primary,#5686fe);text-decoration:none}
2868
+ .dsm-workbuddy-bar a:hover{text-decoration:underline}
2869
+ .dsm-workbuddy-bar-ok{color:var(--dsw-alias-state-success-primary,#22a06b)}
2870
+ .dsm-workbuddy-bar-error{color:var(--dsw-alias-state-error-primary,#ef4444)}
2871
+ /* Secondary text inside the transfer DIALOG (not a bar): the privacy note
2872
+ shown when credentials leave the machine. Kept independent of the bar
2873
+ classes so the dialog does not depend on the bar's layout to be readable. */
2874
+ .dsm-workbuddy-hint{color:var(--dsw-alias-label-tertiary,#999)}
2875
+ /* The transfer dialog is deliberately a block: it holds a scrollable account
2876
+ list and per-entry checkboxes, which cannot live in a one-row bar. */
2877
+ .dsm-workbuddy-transfer-dialog{margin-top:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;background:var(--dsw-alias-bg-secondary,transparent)}
2878
+ .dsm-workbuddy-transfer-dialog p{margin:0 0 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e8e9ed)}
2879
+ .dsm-workbuddy-transfer-dialog-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
2880
+ .dsm-workbuddy-transfer-list{list-style:none;margin:0 0 8px;padding:0;max-height:180px;overflow-y:auto}
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}
2882
+ .dsm-workbuddy-transfer-list input[type="checkbox"]{flex:none}
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}}
1931
2913
  `;
1932
2914
  /**
1933
2915
  * Composer credit readout styles, injected by the readout component itself.
@@ -2557,8 +3539,18 @@ window.__ModuleLoader__.load({
2557
3539
  accounts: [],
2558
3540
  selectionExplicit: false
2559
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;
2560
3552
  (0, react.useEffect)(() => {
2561
- if (!open || status.status !== "signed-in") return;
3553
+ if (!open || !hasAccounts) return;
2562
3554
  const controller = new AbortController();
2563
3555
  const timer = window.setInterval(() => {
2564
3556
  refreshUsage(activeRegion, controller.signal);
@@ -2588,6 +3580,7 @@ window.__ModuleLoader__.load({
2588
3580
  */
2589
3581
  const rescanAccounts = async () => {
2590
3582
  setBusy(true);
3583
+ setAccountError(void 0);
2591
3584
  try {
2592
3585
  const response = await fetch(withWorkBuddyRegion(WORKBUDDY_ACCOUNTS_REFRESH_PATH, activeRegion), {
2593
3586
  method: "POST",
@@ -2596,7 +3589,10 @@ window.__ModuleLoader__.load({
2596
3589
  });
2597
3590
  const body = await response.json();
2598
3591
  if (!response.ok || !Array.isArray(body.accounts)) throw new Error(`HTTP ${response.status}`);
3592
+ if (body.dismissalsCleared === false) setAccountError(t("row.rescanHidesKept"));
2599
3593
  await refreshUsage(activeRegion);
3594
+ } catch (error) {
3595
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
2600
3596
  } finally {
2601
3597
  if (mounted.current) setBusy(false);
2602
3598
  }
@@ -2945,6 +3941,27 @@ window.__ModuleLoader__.load({
2945
3941
  searched
2946
3942
  });
2947
3943
  status.status === "error" || status.selectionExplicit;
3944
+ /**
3945
+ * Account-adding and account-migration controls, defined ONCE and rendered
3946
+ * in both sign-in states: inside the pool settings block when an account
3947
+ * exists, and in the signed-out branch when it does not (the pool section is
3948
+ * not rendered there at all). Two JSX copies would be free to drift, and the
3949
+ * two states are the same feature — a user who learns these controls in one
3950
+ * must find them in the same order in the other.
3951
+ */
3952
+ const accountTools = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OAuthSignIn, {
3953
+ t,
3954
+ region: activeRegion,
3955
+ onAdded: () => refreshUsage(activeRegion)
3956
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountTransfer, {
3957
+ t,
3958
+ region: activeRegion,
3959
+ onChanged: () => refreshUsage(activeRegion),
3960
+ accounts: status.status === "error" ? [] : status.accounts.map((account) => ({
3961
+ id: account.id,
3962
+ accountName: account.accountName
3963
+ }))
3964
+ })] });
2948
3965
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
2949
3966
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
2950
3967
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -3045,6 +4062,11 @@ window.__ModuleLoader__.load({
3045
4062
  className: "dsm-workbuddy-usage-hint",
3046
4063
  children: t("row.reloginHint")
3047
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
+ }),
3048
4070
  selectionLost ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3049
4071
  className: "dsm-workbuddy-usage-hint",
3050
4072
  children: t("row.selectionLostHint")
@@ -3091,12 +4113,11 @@ window.__ModuleLoader__.load({
3091
4113
  switchAccount(accountId);
3092
4114
  },
3093
4115
  selectingAccount: switchingAccount,
4116
+ accountTools,
3094
4117
  siblingBusy: saving || togglingRegion !== void 0,
3095
4118
  onBusyChange: setPoolBusy,
3096
4119
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
3097
- onRefresh: () => {
3098
- refreshUsage(activeRegion);
3099
- }
4120
+ onRefresh: () => refreshUsage(activeRegion)
3100
4121
  }),
3101
4122
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
3102
4123
  className: "dsm-workbuddy-models",
@@ -3329,6 +4350,10 @@ window.__ModuleLoader__.load({
3329
4350
  children: busy ? t("row.accountsScanning") : t("row.accountsRescan")
3330
4351
  })
3331
4352
  }),
4353
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4354
+ className: "dsm-workbuddy-pool-tools",
4355
+ children: accountTools
4356
+ }),
3332
4357
  searched.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SearchedPaths, {
3333
4358
  items: searched,
3334
4359
  t
@@ -3940,6 +4965,7 @@ window.__ModuleLoader__.load({
3940
4965
  "row.sourceDsh": "plugin copy",
3941
4966
  "row.searchedMore": "Show the {count} path(s) that were not found",
3942
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.",
3943
4969
  "row.credentialRejectedTitle": "WorkBuddy refused this account's token",
3944
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:",
3945
4971
  "row.credentialRejectedSwitch": "Another sign-in on this machine answered the upstream: \"{accountName}\". Switching to it fixes this — no need to sign in again.",
@@ -4006,6 +5032,43 @@ window.__ModuleLoader__.load({
4006
5032
  "row.probeFailedStatus": "✗ Test failed (HTTP {status})",
4007
5033
  "row.probeError": "Could not run the test: {message}",
4008
5034
  "row.cheer": "Star on GitHub",
5035
+ "row.oauthTitle": "Add an account by QR sign-in",
5036
+ "row.oauthStart": "Sign in with QR code",
5037
+ "row.oauthStarting": "Opening…",
5038
+ "row.oauthOpenHint": "Open the sign-in page and confirm — the account is added here automatically when you finish:",
5039
+ "row.oauthOpened": "Opened. Waiting for you to finish…",
5040
+ "row.oauthWaiting": "Waiting for the sign-in to finish… ({seconds}s)",
5041
+ "row.oauthExpires": "The sign-in window expires in about {minutes} min.",
5042
+ "row.oauthCancel": "Cancel",
5043
+ "row.oauthCancelled": "Sign-in cancelled.",
5044
+ "row.oauthTimedOut": "The sign-in timed out — start again when ready.",
5045
+ "row.oauthDone": "Added “{accountName}”. Pick it above to use it.",
5046
+ "row.oauthError": "Sign-in failed: {message}",
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…",
5055
+ "row.transferTitle": "Export / import accounts",
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.",
5057
+ "row.transferExport": "Export accounts",
5058
+ "row.transferExporting": "Exporting…",
5059
+ "row.transferExportPick": "Choose the accounts to export ({total} available):",
5060
+ "row.transferExportConfirm": "Export {count} account(s)",
5061
+ "row.transferExported": "Exported {count} account(s) to {fileName}.",
5062
+ "row.transferImport": "Import accounts",
5063
+ "row.transferImporting": "Importing…",
5064
+ "row.transferPreviewing": "Reading the file…",
5065
+ "row.transferPreviewTotal": "{total} account(s) in the file — untick what should stay out. Accounts already stored here are overwritten by the import.",
5066
+ "row.transferImportConfirm": "Import {count} account(s)",
5067
+ "row.transferImported": "Imported {imported} account(s); {skipped} skipped (no token, or belongs to the other region).",
5068
+ "row.transferEntryUnknown": "unknown account",
5069
+ "row.transferNoToken": "no token in this record",
5070
+ "row.transferCancel": "Cancel",
5071
+ "row.transferError": "Transfer failed: {message}",
4009
5072
  "row.poolTitle": "Account pool",
4010
5073
  "row.poolNoCandidate": "No account in this pool can be used right now.",
4011
5074
  "row.poolNoCandidateHint": "Members are rate-limited, out of credits, rejected, or unreachable. Rate limits, drained quotas and transient failures return to the pool on their own — at the time the upstream states, or after a 30-minute fallback. Only a rejected credential needs a new sign-in.",
@@ -4104,6 +5167,13 @@ window.__ModuleLoader__.load({
4104
5167
  "row.poolCurrentHeader": "In use: {account}",
4105
5168
  "row.poolNotMember": "Not in pool",
4106
5169
  "row.poolUnsavedMembers": "Selection changed — save to apply",
5170
+ "row.poolRowTest": "Test",
5171
+ "row.poolRowRemove": "Remove",
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.",
5173
+ "row.poolRowRemoveAria": "Remove the stored credential of {accountName}",
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.",
4107
5177
  "row.showCreditsCn": "Show WB CN credits in the composer",
4108
5178
  "row.showCreditsGlobal": "Show WB AI credits in the composer",
4109
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).",
@@ -4155,6 +5225,7 @@ window.__ModuleLoader__.load({
4155
5225
  "row.sourceDsh": "插件副本",
4156
5226
  "row.searchedMore": "显示另外 {count} 条「不存在」的路径",
4157
5227
  "row.reloginHint": "出现错误,重新登录 WorkBuddy APP 即可。",
5228
+ "row.rescanHidesKept": "已删除的账号仍是隐藏状态:插件没能更新自己的名单文件。Windows 上通常是文件被占用——请关闭可能正在占用 DSH 配置目录的杀毒/同步工具后重试。",
4158
5229
  "row.credentialRejectedTitle": "WorkBuddy 拒绝了这个账号的 token",
4159
5230
  "row.credentialRejectedIntro": "「{accountName}」的 token 已不被上游接受,所以积分与签到都用不了。重新登录只在「确实是 token 的问题」时才有用——当前实际适用的是下面这一条:",
4160
5231
  "row.credentialRejectedSwitch": "本机另一个账号「{accountName}」刚刚实测可用,切换到它即可恢复,不必重新登录。",
@@ -4221,6 +5292,43 @@ window.__ModuleLoader__.load({
4221
5292
  "row.probeFailedStatus": "✗ 测试失败(HTTP {status})",
4222
5293
  "row.probeError": "无法执行测试:{message}",
4223
5294
  "row.cheer": "鼓励一下",
5295
+ "row.oauthTitle": "扫码添加账号",
5296
+ "row.oauthStart": "扫码登录",
5297
+ "row.oauthStarting": "正在打开…",
5298
+ "row.oauthOpenHint": "打开登录页并完成确认——完成后账号会自动出现在这里:",
5299
+ "row.oauthOpened": "已打开,等待你完成登录…",
5300
+ "row.oauthWaiting": "正在等待登录完成…({seconds} 秒)",
5301
+ "row.oauthExpires": "登录窗口约 {minutes} 分钟后过期。",
5302
+ "row.oauthCancel": "取消",
5303
+ "row.oauthCancelled": "已取消登录。",
5304
+ "row.oauthTimedOut": "登录超时——请重新开始。",
5305
+ "row.oauthDone": "已添加「{accountName}」。在上方选中它即可使用。",
5306
+ "row.oauthError": "登录失败:{message}",
5307
+ "row.oauthCopy": "复制链接",
5308
+ "row.oauthCopied": "已复制链接",
5309
+ "row.oauthReopen": "重新打开页面",
5310
+ "row.oauthDialogClose": "关闭",
5311
+ "row.oauthTitleGlobal": "网页登录添加账号",
5312
+ "row.oauthStartGlobal": "网页登录",
5313
+ "row.veilHintList": "完成后列表会立即刷新。",
5314
+ "row.oauthSyncing": "正在添加账号…",
5315
+ "row.transferTitle": "导出 / 导入账号",
5316
+ "row.transferHint": "用 JSON 文件在电脑之间迁移账号。文件里是账号凭据——请妥善保管。该格式与同类 WorkBuddy 凭据管理工具通用。",
5317
+ "row.transferExport": "导出账号",
5318
+ "row.transferExporting": "正在导出…",
5319
+ "row.transferExportPick": "选择要导出的账号(共 {total} 个):",
5320
+ "row.transferExportConfirm": "导出 {count} 个账号",
5321
+ "row.transferExported": "已导出 {count} 个账号到 {fileName}。",
5322
+ "row.transferImport": "导入账号",
5323
+ "row.transferImporting": "正在导入…",
5324
+ "row.transferPreviewing": "正在读取文件…",
5325
+ "row.transferPreviewTotal": "文件中共 {total} 个账号——不需要的请取消勾选。导入会覆盖这里已存储的同名账号。",
5326
+ "row.transferImportConfirm": "导入 {count} 个账号",
5327
+ "row.transferImported": "已导入 {imported} 个账号;跳过 {skipped} 个(缺 token,或属于另一个区域)。",
5328
+ "row.transferEntryUnknown": "未知账号",
5329
+ "row.transferNoToken": "此记录没有 token",
5330
+ "row.transferCancel": "取消",
5331
+ "row.transferError": "传输失败:{message}",
4224
5332
  "row.poolTitle": "账号池",
4225
5333
  "row.poolNoCandidate": "账号池里目前没有可用的账号。",
4226
5334
  "row.poolNoCandidateHint": "成员要么在限流冷却中、要么积分耗尽、要么凭据被拒、要么上游无响应。限流、积分耗尽与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要重新登录。",
@@ -4319,6 +5427,13 @@ window.__ModuleLoader__.load({
4319
5427
  "row.poolCurrentHeader": "当前使用:{account}",
4320
5428
  "row.poolNotMember": "未入池",
4321
5429
  "row.poolUnsavedMembers": "勾选已改动 —— 保存后生效",
5430
+ "row.poolRowTest": "测试",
5431
+ "row.poolRowRemove": "删除",
5432
+ "row.poolRowRemoveHint": "把这个账号从列表中移除。桌面 App 不会被退出登录——它保留自己的登录状态。移除只是暂时隐藏,点「重新检测账号」即可找回。",
5433
+ "row.poolRowRemoveAria": "删除 {accountName} 的已存凭据",
5434
+ "row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
5435
+ "row.poolRowRemoving": "正在删除账号…",
5436
+ "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
4322
5437
  "row.showCreditsCn": "在对话框显示 WB CN 积分",
4323
5438
  "row.showCreditsGlobal": "在对话框显示 WB AI 积分",
4324
5439
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",