dsh-workbuddy-connect 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -97,7 +97,8 @@ window.__ModuleLoader__.load({
97
97
  statusPath: WORKBUDDY_STATUS_PATH,
98
98
  probePath: WORKBUDDY_PROBE_PATH,
99
99
  appName: "WorkBuddy",
100
- unavailableKey: "assistUnavailableCN"
100
+ unavailableKey: "assistUnavailableCN",
101
+ creditPrefix: "WB."
101
102
  };
102
103
  /** International WorkBuddy AI. */
103
104
  const AI_CARD_VARIANT = {
@@ -108,7 +109,8 @@ window.__ModuleLoader__.load({
108
109
  statusPath: WORKBUDDY_AI_STATUS_PATH,
109
110
  probePath: WORKBUDDY_AI_PROBE_PATH,
110
111
  appName: "WorkBuddy AI",
111
- unavailableKey: "assistUnavailableAI"
112
+ unavailableKey: "assistUnavailableAI",
113
+ creditPrefix: "WB AI."
112
114
  };
113
115
  /** Both cards, in display order. */
114
116
  const CARD_VARIANTS = [CN_CARD_VARIANT, AI_CARD_VARIANT];
@@ -304,6 +306,31 @@ window.__ModuleLoader__.load({
304
306
  ...bodyStyle$1,
305
307
  color: "var(--dsw-alias-state-error-primary)"
306
308
  };
309
+ const noticeStyle = {
310
+ display: "flex",
311
+ flexDirection: "column",
312
+ gap: 2,
313
+ padding: "10px 12px",
314
+ borderRadius: 8,
315
+ border: "1px solid",
316
+ fontSize: 13,
317
+ lineHeight: "18px"
318
+ };
319
+ const noticeDetailStyle = {
320
+ fontSize: 12,
321
+ lineHeight: "16px",
322
+ opacity: .8
323
+ };
324
+ const urgentNoticeStyle = {
325
+ background: "var(--dsw-alias-state-warn-tertiary, #fff4e5)",
326
+ borderColor: "var(--dsw-alias-state-warn-primary, #d97706)",
327
+ color: "var(--dsw-alias-label-primary)"
328
+ };
329
+ const warnNoticeStyle = {
330
+ background: "var(--dsw-alias-bg-l2, #f6f7f9)",
331
+ borderColor: "var(--dsw-alias-border-l2)",
332
+ color: "var(--dsw-alias-label-primary)"
333
+ };
307
334
  const quotaListStyle = {
308
335
  display: "flex",
309
336
  flexDirection: "column",
@@ -563,6 +590,54 @@ window.__ModuleLoader__.load({
563
590
  return time;
564
591
  }
565
592
  /**
593
+ * A warning that credit is about to lapse.
594
+ *
595
+ * The total alone cannot express this: a package expiring in an hour and one
596
+ * expiring next month both render as "500 credit", so a user watching only the
597
+ * total loses the first one silently. The banner names the amount, the package
598
+ * count and the soonest expiry, and escalates 7d → 24h.
599
+ *
600
+ * Only future horizons are covered: the billing query behind this returns
601
+ * `valid` + `usedUp` packages from the current time forward, so an
602
+ * already-lapsed package never reaches here.
603
+ *
604
+ * Renders nothing when the upstream reported no end times at all — absence of
605
+ * expiry data is not evidence that nothing expires, so the component stays
606
+ * silent rather than reassuring.
607
+ */
608
+ function ExpiryNotice({ expiry, t }) {
609
+ const { within24h, within7d } = expiry;
610
+ let message;
611
+ let soonest;
612
+ let tone;
613
+ if (within24h.packages > 0) {
614
+ message = t("expiryWarningSoon", {
615
+ total: formatNumber(Math.round(within24h.total * 100) / 100),
616
+ packages: String(within24h.packages)
617
+ });
618
+ soonest = within24h.earliestEndTimeMs;
619
+ tone = urgentNoticeStyle;
620
+ } else if (within7d.packages > 0) {
621
+ message = t("expiryWarningWeek", {
622
+ total: formatNumber(Math.round(within7d.total * 100) / 100),
623
+ packages: String(within7d.packages)
624
+ });
625
+ soonest = within7d.earliestEndTimeMs;
626
+ tone = warnNoticeStyle;
627
+ } else return null;
628
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
629
+ style: {
630
+ ...noticeStyle,
631
+ ...tone
632
+ },
633
+ role: "status",
634
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: message }), soonest === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
635
+ style: noticeDetailStyle,
636
+ children: t("expiryExpiresAt", { time: formatTime(soonest) })
637
+ })]
638
+ });
639
+ }
640
+ /**
566
641
  * One billing package as a labeled progress bar.
567
642
  *
568
643
  * A package whose allowance the upstream never reported (`size` not positive)
@@ -1288,19 +1363,26 @@ window.__ModuleLoader__.load({
1288
1363
  children: [
1289
1364
  status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1290
1365
  style: quotaListStyle,
1291
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1292
- style: rowStyle$1,
1293
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1294
- style: quotaTitleStyle,
1295
- children: t("creditsHeading")
1296
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1297
- style: bodyStyle$1,
1298
- children: status.credits.unlimited === true ? t("creditsTotalUnlimited") : t("creditsTotal", { total: formatNumber(status.credits.total) })
1299
- })]
1300
- }), status.credits.cycleResetTime === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1301
- style: descriptionStyle,
1302
- children: t("cycleResetAt", { time: formatCycleReset(status.credits.cycleResetTime) })
1303
- })]
1366
+ children: [
1367
+ status.credits.expiry === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExpiryNotice, {
1368
+ expiry: status.credits.expiry,
1369
+ t
1370
+ }),
1371
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1372
+ style: rowStyle$1,
1373
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1374
+ style: quotaTitleStyle,
1375
+ children: t("creditsHeading")
1376
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1377
+ style: bodyStyle$1,
1378
+ children: status.credits.unlimited === true ? t("creditsTotalUnlimited") : t("creditsTotal", { total: formatNumber(status.credits.total) })
1379
+ })]
1380
+ }),
1381
+ status.credits.cycleResetTime === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1382
+ style: descriptionStyle,
1383
+ children: t("cycleResetAt", { time: formatCycleReset(status.credits.cycleResetTime) })
1384
+ })
1385
+ ]
1304
1386
  }),
1305
1387
  status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1306
1388
  style: errorStyle,
@@ -1410,6 +1492,19 @@ window.__ModuleLoader__.load({
1410
1492
  * `window.confirm`. Probing spends real credit, so a confirmation stays — but
1411
1493
  * it belongs next to the thing it acts on, sized to one line plus two small
1412
1494
  * buttons.
1495
+ * - a **credit badge** (#99): the account's remaining balance as one tiny
1496
+ * annotation on the same seat, read from the same status document as the
1497
+ * probe entry. Hidden whenever the document has no answer (signed-out,
1498
+ * error, missing `credits`); zero is a real balance and is shown, `unlimited`
1499
+ * renders as ∞ rather than as 0. The badge is why the control stays mounted
1500
+ * for non-candidate models: the probe entry alone is what disappears for
1501
+ * them.
1502
+ * - a **↻ beside the badge** is the only re-read after the mount fetch: there
1503
+ * is no interval and no focus polling, because every status GET also queries
1504
+ * billing upstream and the balance should not refresh behind the user's
1505
+ * back. A failed manual read hides the balance rather than passing the old
1506
+ * one off as current; a click also picks up probes started elsewhere, which
1507
+ * is the reconcile the removed poll used to provide.
1413
1508
  *
1414
1509
  * @module dsh-workbuddy-connect/client/probe-control
1415
1510
  */
@@ -1423,8 +1518,6 @@ window.__ModuleLoader__.load({
1423
1518
  function cardVariantFor(provider) {
1424
1519
  return CARD_VARIANTS.find((card) => card.id === provider);
1425
1520
  }
1426
- /** How often the control re-checks state when the window regains focus. */
1427
- const RECONCILE_MS = 6e4;
1428
1521
  const wrapperStyle = {
1429
1522
  display: "inline-flex",
1430
1523
  position: "relative",
@@ -1457,6 +1550,35 @@ window.__ModuleLoader__.load({
1457
1550
  lineHeight: "16px",
1458
1551
  color: "var(--dsw-alias-label-tertiary)"
1459
1552
  };
1553
+ /**
1554
+ * The credit badge (#99). The same annotation register as {@link labelStyle} —
1555
+ * small and dim, on the model name's side of the seat — so a changing balance
1556
+ * reads as a note on the selection, not composer chrome of its own.
1557
+ */
1558
+ const creditStyle = {
1559
+ fontSize: 11,
1560
+ lineHeight: "16px",
1561
+ color: "var(--dsw-alias-label-tertiary)",
1562
+ whiteSpace: "nowrap",
1563
+ padding: "0 4px"
1564
+ };
1565
+ /**
1566
+ * The badge's ↻ (#99): the only re-read after the mount fetch. An icon-only
1567
+ * button in the same dim register, so it reads as part of the annotation
1568
+ * rather than a fourth composer control.
1569
+ */
1570
+ const refreshStyle = {
1571
+ display: "inline-flex",
1572
+ alignItems: "center",
1573
+ justifyContent: "center",
1574
+ width: 20,
1575
+ height: 20,
1576
+ padding: 0,
1577
+ border: 0,
1578
+ borderRadius: 6,
1579
+ background: "transparent",
1580
+ color: "var(--dsw-alias-label-tertiary)"
1581
+ };
1460
1582
  /** Tooltip bubble: the Fast Mode shape (nowrap, one line, above the control). */
1461
1583
  const tooltipStyle = {
1462
1584
  position: "absolute",
@@ -1576,6 +1698,28 @@ window.__ModuleLoader__.load({
1576
1698
  return status.probe?.results.find((result) => result.id === model);
1577
1699
  }
1578
1700
  /**
1701
+ * The credit badge's one line, or `undefined` whenever there is no honest
1702
+ * answer to show (#99).
1703
+ *
1704
+ * Every non-answer collapses to hidden: a signed-out or errored document, a
1705
+ * missing `credits` section (which is also all a `creditsError` leaves behind),
1706
+ * or a total that is not a finite number — the status guard deliberately does
1707
+ * not validate optional fields, so this narrows before reading. Zero is a real
1708
+ * balance and is shown; `unlimited` renders as ∞, never as 0.
1709
+ */
1710
+ function creditBadge(status, card, t) {
1711
+ if (status === void 0 || status.status !== "signed-in") return void 0;
1712
+ if (status.creditsError !== void 0) return void 0;
1713
+ const credits = status.credits;
1714
+ if (credits === void 0) return void 0;
1715
+ const total = credits.unlimited === true ? "∞" : typeof credits.total === "number" && Number.isFinite(credits.total) ? new Intl.NumberFormat(void 0, { maximumFractionDigits: 2 }).format(credits.total) : void 0;
1716
+ if (total === void 0) return void 0;
1717
+ return t("composerCredit", {
1718
+ prefix: card.creditPrefix,
1719
+ total
1720
+ });
1721
+ }
1722
+ /**
1579
1723
  * The one-line tooltip: current state first, then what a click does — the same
1580
1724
  * two-part shape Fast Mode uses.
1581
1725
  *
@@ -1618,7 +1762,16 @@ window.__ModuleLoader__.load({
1618
1762
  const [tooltipVisible, setTooltipVisible] = (0, react.useState)(false);
1619
1763
  const [failed, setFailed] = (0, react.useState)(false);
1620
1764
  const [note, setNote] = (0, react.useState)();
1765
+ /**
1766
+ * A manual credit read failed (#99). The balance hides until a later read
1767
+ * succeeds, so the old number can never pose as current; the probe state is
1768
+ * untouched — the two features merely share one document.
1769
+ */
1770
+ const [creditFailed, setCreditFailed] = (0, react.useState)(false);
1771
+ /** Whether a manual credit refresh is in flight, for the ↻'s busy state. */
1772
+ const [refreshing, setRefreshing] = (0, react.useState)(false);
1621
1773
  const inFlight = (0, react.useRef)(false);
1774
+ const refreshingRef = (0, react.useRef)(false);
1622
1775
  const mounted = (0, react.useRef)(false);
1623
1776
  const readSeq = (0, react.useRef)(0);
1624
1777
  const tooltipId = (0, react.useId)();
@@ -1626,30 +1779,56 @@ window.__ModuleLoader__.load({
1626
1779
  const seq = ++readSeq.current;
1627
1780
  const response = await fetch(card.statusPath, {
1628
1781
  credentials: "same-origin",
1782
+ cache: "no-store",
1629
1783
  headers: { accept: "application/json" },
1630
1784
  ...signal === void 0 ? {} : { signal }
1631
1785
  });
1632
1786
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
1633
1787
  const value = await response.json().catch(() => void 0);
1634
1788
  if (!isWorkBuddyWebStatus(value)) throw new Error(t("statusResponseInvalid"));
1635
- if (mounted.current && !signal?.aborted && seq === readSeq.current) setStatus(value);
1789
+ if (mounted.current && !signal?.aborted && seq === readSeq.current) {
1790
+ setStatus(value);
1791
+ setCreditFailed(false);
1792
+ }
1636
1793
  }, [card.statusPath, t]);
1637
1794
  (0, react.useEffect)(() => {
1638
1795
  mounted.current = true;
1639
1796
  const controller = new AbortController();
1640
- const load = () => {
1641
- refresh(controller.signal).catch(() => {});
1642
- };
1643
- load();
1644
- const timer = window.setInterval(load, RECONCILE_MS);
1645
- window.addEventListener("focus", load);
1797
+ refresh(controller.signal).catch(() => {});
1646
1798
  return () => {
1647
1799
  mounted.current = false;
1648
1800
  controller.abort();
1649
- window.clearInterval(timer);
1650
- window.removeEventListener("focus", load);
1651
1801
  };
1652
1802
  }, [refresh]);
1803
+ /**
1804
+ * The ↻'s action (#99): one manual re-read through the same status route.
1805
+ *
1806
+ * The guard is a ref, not the state: two clicks inside one act batch both
1807
+ * see the same stale `refreshing` closure, and only a synchronous check can
1808
+ * keep the second click from sending a second billing read.
1809
+ *
1810
+ * A failure hides the balance (see `creditFailed`) instead of leaving the old
1811
+ * number on screen as though it were fresh — but only when the failed read is
1812
+ * still the newest one (`refresh` bumped `readSeq` for it, and nothing newer
1813
+ * has started since). An out-of-date manual failure racing a successful
1814
+ * probe's trailing read must not hide the fresh balance that just arrived:
1815
+ * `readSeq` is what decides, and `refresh` itself already clears the flag on
1816
+ * any newer successful write.
1817
+ */
1818
+ const checkNow = async () => {
1819
+ if (refreshingRef.current) return;
1820
+ refreshingRef.current = true;
1821
+ setRefreshing(true);
1822
+ const seqAtStart = readSeq.current + 1;
1823
+ try {
1824
+ await refresh();
1825
+ } catch {
1826
+ if (mounted.current && readSeq.current === seqAtStart) setCreditFailed(true);
1827
+ } finally {
1828
+ refreshingRef.current = false;
1829
+ if (mounted.current) setRefreshing(false);
1830
+ }
1831
+ };
1653
1832
  const probe = status?.status === "signed-in" ? status.probe : void 0;
1654
1833
  const key = status?.status === "signed-in" ? status.probeKey : void 0;
1655
1834
  const result = status === void 0 ? void 0 : resultFor(status, model);
@@ -1701,7 +1880,7 @@ window.__ModuleLoader__.load({
1701
1880
  if (mounted.current) setBusy(false);
1702
1881
  }
1703
1882
  };
1704
- if (!visible) return null;
1883
+ const credit = creditFailed ? void 0 : creditBadge(status, card, t);
1705
1884
  const text = tooltipText(t, model, {
1706
1885
  busy,
1707
1886
  result,
@@ -1718,7 +1897,39 @@ window.__ModuleLoader__.load({
1718
1897
  setTooltipVisible(false);
1719
1898
  },
1720
1899
  children: [
1721
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1900
+ credit === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1901
+ style: creditStyle,
1902
+ children: credit
1903
+ }),
1904
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1905
+ type: "button",
1906
+ style: {
1907
+ ...refreshStyle,
1908
+ opacity: refreshing ? .6 : 1,
1909
+ cursor: refreshing ? "default" : "pointer"
1910
+ },
1911
+ title: t("composerCreditRefresh"),
1912
+ "aria-label": t("composerCreditRefresh"),
1913
+ "aria-busy": refreshing,
1914
+ disabled: refreshing,
1915
+ onClick: () => {
1916
+ checkNow();
1917
+ },
1918
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1919
+ width: "12",
1920
+ height: "12",
1921
+ viewBox: "0 0 24 24",
1922
+ fill: "none",
1923
+ stroke: "currentColor",
1924
+ strokeWidth: "2",
1925
+ strokeLinecap: "round",
1926
+ strokeLinejoin: "round",
1927
+ "aria-hidden": "true",
1928
+ focusable: "false",
1929
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M20 12a8 8 0 1 1-2.34-5.66" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M20 4v4h-4" })]
1930
+ })
1931
+ }),
1932
+ visible && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1722
1933
  type: "button",
1723
1934
  "aria-label": text,
1724
1935
  "aria-describedby": showTooltip ? tooltipId : void 0,
@@ -1771,13 +1982,13 @@ window.__ModuleLoader__.load({
1771
1982
  children: label
1772
1983
  })]
1773
1984
  }),
1774
- showTooltip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1985
+ visible && showTooltip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1775
1986
  id: tooltipId,
1776
1987
  role: "tooltip",
1777
1988
  style: tooltipStyle,
1778
1989
  children: text
1779
1990
  }),
1780
- confirming && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1991
+ visible && confirming && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1781
1992
  style: confirmStyle,
1782
1993
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("probeBubbleBody") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1783
1994
  style: confirmRowStyle,
@@ -1798,7 +2009,7 @@ window.__ModuleLoader__.load({
1798
2009
  })]
1799
2010
  })]
1800
2011
  }),
1801
- note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2012
+ visible && note !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1802
2013
  role: "status",
1803
2014
  "aria-live": "polite",
1804
2015
  style: noteStyle,
@@ -2561,7 +2772,7 @@ window.__ModuleLoader__.load({
2561
2772
  };
2562
2773
  //#endregion
2563
2774
  //#region src/version.ts
2564
- const WORKBUDDY_CONNECT_VERSION = "0.7.0";
2775
+ const WORKBUDDY_CONNECT_VERSION = "0.7.2";
2565
2776
  //#endregion
2566
2777
  //#region src/client/WorkBuddyConfigPage.tsx
2567
2778
  /**
@@ -2624,8 +2835,13 @@ window.__ModuleLoader__.load({
2624
2835
  creditsTotal: "Total: {total}",
2625
2836
  creditsTotalUnlimited: "Total: Unlimited",
2626
2837
  unlimitedQuota: "Unlimited",
2838
+ composerCredit: "{prefix}{total} credits",
2839
+ composerCreditRefresh: "Refresh credit balance",
2627
2840
  packageEnterprise: "Enterprise quota",
2628
2841
  cycleResetAt: "Resets {time}",
2842
+ expiryWarningSoon: "{total} credit expires within 24 hours ({packages} packages)",
2843
+ expiryWarningWeek: "{total} credit expires within 7 days ({packages} packages)",
2844
+ expiryExpiresAt: "Earliest: {time}",
2629
2845
  percentRemaining: "{percent}% remaining",
2630
2846
  percentUnknown: "Remaining share unknown",
2631
2847
  exactRemaining: "{remain} / {size} remaining",
@@ -2743,8 +2959,13 @@ window.__ModuleLoader__.load({
2743
2959
  creditsTotal: "合计:{total}",
2744
2960
  creditsTotalUnlimited: "合计:不限额",
2745
2961
  unlimitedQuota: "不限额",
2962
+ composerCredit: "{prefix}{total}(积分)",
2963
+ composerCreditRefresh: "刷新积分",
2746
2964
  packageEnterprise: "企业额度",
2747
2965
  cycleResetAt: "重置时间:{time}",
2966
+ expiryWarningSoon: "有 {total} 积分将在 24 小时内过期({packages} 个套餐)",
2967
+ expiryWarningWeek: "有 {total} 积分将在 7 天内过期({packages} 个套餐)",
2968
+ expiryExpiresAt: "最早到期:{time}",
2748
2969
  percentRemaining: "剩余 {percent}%",
2749
2970
  percentUnknown: "剩余占比未知",
2750
2971
  exactRemaining: "剩余 {remain} / {size}",