dsh-workbuddy-connect 0.5.3 → 0.6.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
@@ -53,7 +53,13 @@ window.__ModuleLoader__.load({
53
53
  }
54
54
  //#endregion
55
55
  //#region src/client/WorkBuddyPluginCard.tsx
56
- /** WorkBuddy status card contributed to Harness Plugin configuration. */
56
+ /**
57
+ * WorkBuddy status card, rendered on whichever settings surface the host
58
+ * provides: dispatched directly by DSH 0.1.5's settings Plugins tab (one card
59
+ * per variant), or mounted by the bundle configuration page DSH 0.1.6+'s
60
+ * Plugins page renders. The component itself is surface-agnostic — its props
61
+ * are only the injected copy and variant.
62
+ */
57
63
  /** CN WorkBuddy; the plugin's long-standing card and default. */
58
64
  const CN_CARD_VARIANT = {
59
65
  id: "workbuddy",
@@ -79,7 +85,8 @@ window.__ModuleLoader__.load({
79
85
  overflow: "hidden",
80
86
  border: "1px solid var(--dsw-alias-border-l2)",
81
87
  borderRadius: 10,
82
- background: "var(--dsw-alias-bg-module-platform)"
88
+ background: "var(--dsw-alias-bg-module-platform)",
89
+ listStyle: "none"
83
90
  };
84
91
  const headerStyle = {
85
92
  boxSizing: "border-box",
@@ -217,6 +224,17 @@ window.__ModuleLoader__.load({
217
224
  flexDirection: "column",
218
225
  gap: 2
219
226
  };
227
+ /**
228
+ * Left half of one merged context/visibility row: the visibility checkbox (when
229
+ * the account has one) beside the model's name and rate. Kept as a flex span so
230
+ * the capacity column stays flush right no matter how long the name runs.
231
+ */
232
+ const contextRowMainStyle = {
233
+ display: "flex",
234
+ alignItems: "center",
235
+ gap: 9,
236
+ minWidth: 0
237
+ };
220
238
  const modelBadgeChipStyle = {
221
239
  padding: "1px 8px",
222
240
  borderRadius: 999,
@@ -455,26 +473,41 @@ window.__ModuleLoader__.load({
455
473
  });
456
474
  }
457
475
  /**
458
- * Context capacity, listed in full.
476
+ * Context window and model visibility, one row per catalog model.
477
+ *
478
+ * The list is driven by the full current catalog, not by context metadata:
479
+ * hiding a model is a statement about the picker, and a model without a
480
+ * declared window is still hideable — its row just shows an em dash where the
481
+ * capacity would be. Rows with a window keep the original ordering (largest
482
+ * first); rows without one trail at the end in catalog order.
459
483
  *
460
- * Every model the upstream reports a capacity for, largest first. A one-line
461
- * summary with the exceptions on hover was tried and rejected: capacity is
462
- * reference data you scan by model, and hiding most of it behind a hover made
463
- * the common case (a model you already have in mind) the hard one to look up.
484
+ * Each row is one <label>, so the checkbox is named by its row without a
485
+ * duplicated aria string. The checkbox state comes from the status document
486
+ * only — no optimistic flip — so a save that fails leaves the box where the
487
+ * host's truth says it is, next to the failure notice `control` records.
488
+ * Checkboxes render only when the document carries a visibility section (a
489
+ * signed-in account with a stable user id); a uid-less credential shows the
490
+ * plain capacity list rather than editing a bucket every such account would
491
+ * share.
464
492
  *
465
- * Purely a report of the upstream's own numbers. The plugin offers no tier
466
- * picker: the CN catalog declares one capacity per model and publishes no
467
- * alternatives, so a menu there would mean inventing client-side policy. The
468
- * international document does declare alternatives (`supportedLengths`), and
469
- * they are shown as a secondary figure rather than merged into one number —
493
+ * Purely a report of the upstream's own numbers otherwise. The plugin offers
494
+ * no tier picker: the CN catalog declares one capacity per model and publishes
495
+ * no alternatives, so a menu there would mean inventing client-side policy.
496
+ * The international document does declare alternatives (`supportedLengths`),
497
+ * and they are shown as a secondary figure rather than merged into one number —
470
498
  * the default is the budget actually requested, while the larger value is a
471
499
  * ceiling the upstream would accept.
472
500
  */
473
- function ContextTable({ models, t, useMaximumContextWindow, disabled, onUseMaximumContextWindow }) {
474
- const known = (models ?? []).filter((model) => model.contextWindow !== void 0).sort((a, b) => b.contextWindow - a.contextWindow);
475
- const canSelectMaximum = known.some((model) => model.maxContextWindow !== void 0 && model.maxContextWindow > (model.defaultContextWindow ?? model.contextWindow ?? 0));
501
+ function ContextTable({ models, t, useMaximumContextWindow, contextPreferenceDisabled, onUseMaximumContextWindow, visibility, visibilityControlsDisabled, visibilityToggling, onVisibilityToggle }) {
502
+ const rows = [...models ?? []].sort((a, b) => {
503
+ if (a.contextWindow === void 0) return b.contextWindow === void 0 ? 0 : 1;
504
+ if (b.contextWindow === void 0) return -1;
505
+ return b.contextWindow - a.contextWindow;
506
+ });
507
+ const canSelectMaximum = rows.some((model) => model.maxContextWindow !== void 0 && model.maxContextWindow > (model.defaultContextWindow ?? model.contextWindow ?? 0));
476
508
  const showPreference = onUseMaximumContextWindow !== void 0 && (canSelectMaximum || useMaximumContextWindow === true);
477
- if (known.length === 0 && !showPreference) return null;
509
+ if (rows.length === 0 && !showPreference) return null;
510
+ const hidden = new Set(visibility?.disabled ?? []);
478
511
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
479
512
  style: quotaListStyle,
480
513
  children: [
@@ -487,7 +520,7 @@ window.__ModuleLoader__.load({
487
520
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
488
521
  type: "checkbox",
489
522
  checked: useMaximumContextWindow === true,
490
- disabled,
523
+ disabled: contextPreferenceDisabled,
491
524
  onChange: (event) => {
492
525
  onUseMaximumContextWindow(event.currentTarget.checked);
493
526
  }
@@ -499,25 +532,65 @@ window.__ModuleLoader__.load({
499
532
  })]
500
533
  })]
501
534
  }) : null,
502
- known.map((model) => {
503
- const capacity = model.contextWindow;
504
- const alternative = model.maxContextWindow !== void 0 && model.maxContextWindow > capacity ? model.maxContextWindow : void 0;
505
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
506
- style: quotaLabelStyle,
507
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
508
- style: modelOfferStyle,
509
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
510
- style: { textAlign: "right" },
511
- children: formatTokens(capacity)
512
- }), alternative !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
513
- style: modelRateStyle,
514
- children: t("contextUpTo", { size: formatTokens(alternative) })
515
- }) : model.defaultContextWindow !== void 0 && model.defaultContextWindow < capacity ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
516
- style: modelRateStyle,
517
- children: t("contextDefault", { size: formatTokens(model.defaultContextWindow) })
518
- }) : null]
519
- })]
520
- }, model.id);
535
+ visibility === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
536
+ style: bodyStyle,
537
+ children: t("visibilityIntro")
538
+ }),
539
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
540
+ style: quotaGroupStyle,
541
+ children: rows.map((model) => {
542
+ const capacity = model.contextWindow;
543
+ const alternative = capacity !== void 0 && model.maxContextWindow !== void 0 && model.maxContextWindow > capacity ? model.maxContextWindow : void 0;
544
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
545
+ style: quotaLabelStyle,
546
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
547
+ style: contextRowMainStyle,
548
+ children: [visibility === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
549
+ type: "checkbox",
550
+ checked: !hidden.has(model.id),
551
+ disabled: visibilityControlsDisabled || visibilityToggling.has(model.id),
552
+ onChange: (event) => {
553
+ onVisibilityToggle?.(model.id, event.currentTarget.checked);
554
+ }
555
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
556
+ style: modelOfferStyle,
557
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
558
+ style: modelBadgeStyle,
559
+ children: [
560
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }),
561
+ model.badges?.map((badge) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
562
+ style: modelBadgeChipStyle,
563
+ children: modelBadgeLabel(badge, t)
564
+ }, badge)),
565
+ model.free === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
566
+ style: modelBadgeChipStyle,
567
+ children: t("freeModel")
568
+ }) : null
569
+ ]
570
+ }), model.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
571
+ style: modelRateStyle,
572
+ children: model.credits
573
+ })]
574
+ })]
575
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
576
+ style: modelOfferStyle,
577
+ children: [capacity === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
578
+ style: modelRateStyle,
579
+ "aria-label": t("contextUnknown"),
580
+ children: "—"
581
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
582
+ style: { textAlign: "right" },
583
+ children: formatTokens(capacity)
584
+ }), alternative !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
585
+ style: modelRateStyle,
586
+ children: t("contextUpTo", { size: formatTokens(alternative) })
587
+ }) : capacity !== void 0 && model.defaultContextWindow !== void 0 && model.defaultContextWindow < capacity ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
588
+ style: modelRateStyle,
589
+ children: t("contextDefault", { size: formatTokens(model.defaultContextWindow) })
590
+ }) : null]
591
+ })]
592
+ }, model.id);
593
+ })
521
594
  })
522
595
  ]
523
596
  });
@@ -543,7 +616,7 @@ window.__ModuleLoader__.load({
543
616
  * parameter ("this model does not check it"), never as a statement that a
544
617
  * level is unsupported.
545
618
  */
546
- function ProbeSection({ probe, t, onDetect, onClear, busy }) {
619
+ function ProbeSection({ probe, models, t, onDetect, onClear, busy }) {
547
620
  const [pending, setPending] = (0, react.useState)();
548
621
  const [runningModel, setRunningModel] = (0, react.useState)();
549
622
  (0, react.useEffect)(() => {
@@ -590,7 +663,7 @@ window.__ModuleLoader__.load({
590
663
  style: quotaGroupStyle,
591
664
  children: probe.candidates.map((id) => {
592
665
  const result = probe.results.find((entry) => entry.id === id);
593
- const name = result?.name ?? id;
666
+ const name = models?.find((model) => model.id === id)?.name ?? result?.name ?? id;
594
667
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
595
668
  style: modelOfferStyle,
596
669
  children: [
@@ -608,7 +681,7 @@ window.__ModuleLoader__.load({
608
681
  onClick: () => {
609
682
  setPending(id);
610
683
  },
611
- children: runningModel === id ? t("probeRunning", { model: id }) : t(result === void 0 ? "probeStart" : "probeRedetect")
684
+ children: runningModel === id ? t("probeRunning", { model: name }) : t(result === void 0 ? "probeStart" : "probeRedetect")
612
685
  })]
613
686
  })]
614
687
  }),
@@ -688,6 +761,17 @@ window.__ModuleLoader__.load({
688
761
  */
689
762
  const [readFailure, setReadFailure] = (0, react.useState)();
690
763
  const [busy, setBusy] = (0, react.useState)(false);
764
+ /**
765
+ * The model ids whose visibility writes are in flight, empty when none are.
766
+ * Deliberately NOT the card-wide `busy` (that flag drives the Refresh
767
+ * buttons' labels, which must not claim a refresh the user never pressed)
768
+ * and deliberately per-row rather than whole-list: a visibility write only
769
+ * adds or removes one model's id, so the rows are independent — locking
770
+ * every checkbox for one row's write made the whole list visibly blink for
771
+ * no correctness gain. A set, because two writes can be open at once when
772
+ * the user moves down the list; only the rows being written lock.
773
+ */
774
+ const [togglingModels, setTogglingModels] = (0, react.useState)(() => /* @__PURE__ */ new Set());
691
775
  const [tab, setTab] = (0, react.useState)("status");
692
776
  const mounted = (0, react.useRef)(true);
693
777
  /**
@@ -843,14 +927,16 @@ window.__ModuleLoader__.load({
843
927
  const control = (0, react.useCallback)(async (action) => {
844
928
  const key = status?.status === "signed-in" ? status.probeKey : void 0;
845
929
  if (key === void 0) return;
846
- setBusy(true);
930
+ const visibility = action.action === "set-model-visibility";
931
+ if (visibility) setTogglingModels((previous) => new Set(previous).add(action.model));
932
+ else setBusy(true);
847
933
  const controller = trackController();
848
934
  try {
849
935
  const response = await fetch(variant.probePath, {
850
936
  method: "POST",
851
937
  headers: {
852
938
  "Content-Type": "application/json",
853
- "X-WorkBuddy-Probe-Key": key
939
+ "X-Workbuddy-Probe-Key": key
854
940
  },
855
941
  credentials: "same-origin",
856
942
  signal: controller.signal,
@@ -861,7 +947,12 @@ window.__ModuleLoader__.load({
861
947
  const message = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
862
948
  throw new Error(message);
863
949
  }
864
- if (action.action === "set-maximum-context-window" && (typeof value !== "object" || value === null || value["state"] !== "updated")) {
950
+ if ((action.action === "set-maximum-context-window" || action.action === "set-model-visibility") && (typeof value !== "object" || value === null || value["state"] !== "updated")) {
951
+ const state = typeof value === "object" && value !== null ? value["state"] : void 0;
952
+ if (action.action === "set-model-visibility" && state === "stale-account") {
953
+ await refresh(controller.signal);
954
+ throw new Error(t("visibilityStaleAccount"));
955
+ }
865
956
  const reason = typeof value === "object" && value !== null && "reason" in value ? String(value["reason"]) : t("requestFailed");
866
957
  throw new Error(reason);
867
958
  }
@@ -870,7 +961,13 @@ window.__ModuleLoader__.load({
870
961
  if (mounted.current && controller.signal.aborted !== true) setReadFailure(error instanceof Error ? error.message : t("requestFailed"));
871
962
  } finally {
872
963
  manualControllers.current.delete(controller);
873
- if (mounted.current) setBusy(false);
964
+ if (!mounted.current) return;
965
+ if (visibility) setTogglingModels((previous) => {
966
+ const next = new Set(previous);
967
+ next.delete(action.model);
968
+ return next;
969
+ });
970
+ else setBusy(false);
874
971
  }
875
972
  }, [
876
973
  refresh,
@@ -1019,6 +1116,7 @@ window.__ModuleLoader__.load({
1019
1116
  }),
1020
1117
  status.probe === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProbeSection, {
1021
1118
  probe: status.probe,
1119
+ models: status.models,
1022
1120
  t,
1023
1121
  busy,
1024
1122
  onDetect: confirmDetect,
@@ -1032,14 +1130,27 @@ window.__ModuleLoader__.load({
1032
1130
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextTable, {
1033
1131
  models: status.models,
1034
1132
  t,
1035
- disabled: busy,
1036
- ...status.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: status.useMaximumContextWindow },
1037
- ...variant.id === AI_CARD_VARIANT.id ? { onUseMaximumContextWindow: (enabled) => {
1133
+ contextPreferenceDisabled: busy,
1134
+ ...variant.id === AI_CARD_VARIANT.id && status.useMaximumContextWindow !== void 0 ? {
1135
+ useMaximumContextWindow: status.useMaximumContextWindow,
1136
+ onUseMaximumContextWindow: (enabled) => {
1137
+ control({
1138
+ action: "set-maximum-context-window",
1139
+ enabled
1140
+ });
1141
+ }
1142
+ } : {},
1143
+ visibility: status.visibility,
1144
+ visibilityControlsDisabled: busy,
1145
+ visibilityToggling: togglingModels,
1146
+ onVisibilityToggle: (modelId, visible) => {
1038
1147
  control({
1039
- action: "set-maximum-context-window",
1040
- enabled
1148
+ action: "set-model-visibility",
1149
+ model: modelId,
1150
+ visible,
1151
+ account: status.visibility?.account ?? ""
1041
1152
  });
1042
- } } : {}
1153
+ }
1043
1154
  })
1044
1155
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1045
1156
  style: tabPanelStyle,
@@ -1510,6 +1621,44 @@ window.__ModuleLoader__.load({
1510
1621
  return t("probeNoteUnknown");
1511
1622
  }
1512
1623
  //#endregion
1624
+ //#region src/client/WorkBuddyConfigPage.tsx
1625
+ /**
1626
+ * The cards' list; the page supplies no other chrome. A semantic `<ul>` —
1627
+ * each card below is an `<li>` — with the user-agent list defaults cleared so
1628
+ * only the column/gap rhythm remains, keeping the layout identical to the
1629
+ * flex column it replaced while giving the page real list semantics.
1630
+ */
1631
+ const pageStyle = {
1632
+ display: "flex",
1633
+ flexDirection: "column",
1634
+ gap: 12,
1635
+ listStyle: "none",
1636
+ margin: 0,
1637
+ padding: 0
1638
+ };
1639
+ /**
1640
+ * Render the bundle's configuration: one status card per WorkBuddy variant.
1641
+ *
1642
+ * Both variants share one page. DSH 0.1.6 replaced the settings section's
1643
+ * keyed `settings.plugin.item` slot — dispatched once per served settings
1644
+ * namespace, which is why the two variants used to be two cards — with the
1645
+ * Plugins page's `plugins.bundle.config`, keyed by the bundle's package name.
1646
+ * A bundle therefore carries exactly one configuration entry, so the cards are
1647
+ * stacked here instead of being dispatched separately. (The 0.1.5 settings tab
1648
+ * and its two dispatched cards still exist on 0.1.5 hosts; this page only
1649
+ * mounts where the Plugins page declares its slot.)
1650
+ */
1651
+ function WorkBuddyConfigPage({ view, t }) {
1652
+ if (view === "summary") return t("intro");
1653
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1654
+ style: pageStyle,
1655
+ children: CARD_VARIANTS.map((variant) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WorkBuddyPluginCard, {
1656
+ t,
1657
+ variant
1658
+ }, variant.id))
1659
+ });
1660
+ }
1661
+ //#endregion
1513
1662
  //#region src/client/locales.ts
1514
1663
  /** Plugin-card copy registered under the settings.workbuddy locale namespace. */
1515
1664
  const en = {
@@ -1557,8 +1706,11 @@ window.__ModuleLoader__.load({
1557
1706
  contextHeading: "Context window",
1558
1707
  contextUpTo: "up to {size}",
1559
1708
  contextDefault: "default {size}",
1709
+ contextUnknown: "no declared context window",
1560
1710
  useMaximumContextWindow: "Use the largest declared context window",
1561
1711
  useMaximumContextWindowHint: "Applies to WorkBuddy AI models that offer a larger window.",
1712
+ visibilityIntro: "Uncheck a model to hide it from the model picker. Saved per signed-in account; chats already using a hidden model keep working.",
1713
+ visibilityStaleAccount: "The signed-in account changed — this change was not saved.",
1562
1714
  freeModel: "Free",
1563
1715
  badgeLimitedFree: "Limited-time free",
1564
1716
  badgeNightDiscount: "Night discount",
@@ -1640,8 +1792,11 @@ window.__ModuleLoader__.load({
1640
1792
  contextHeading: "上下文窗口",
1641
1793
  contextUpTo: "最高 {size}",
1642
1794
  contextDefault: "默认 {size}",
1795
+ contextUnknown: "未声明上下文窗口",
1643
1796
  useMaximumContextWindow: "使用上游声明的最大上下文窗口",
1644
1797
  useMaximumContextWindowHint: "仅作用于 WorkBuddy AI 中声明了更大窗口的模型。",
1798
+ visibilityIntro: "取消勾选即可在模型选择器中隐藏该模型;按当前登录账号分别保存,已在用该模型的会话不受影响。",
1799
+ visibilityStaleAccount: "登录账号已切换——本次修改未保存。",
1645
1800
  freeModel: "免费",
1646
1801
  badgeLimitedFree: "限时免费",
1647
1802
  badgeNightDiscount: "夜间折扣",
@@ -1683,16 +1838,26 @@ window.__ModuleLoader__.load({
1683
1838
  /** Stable browser-plugin name. */
1684
1839
  const name = "dsh-workbuddy-connect-client";
1685
1840
  /**
1686
- * Client services required by the Plugin configuration contribution.
1841
+ * The bundle's package name, which is also this half's configuration key.
1842
+ *
1843
+ * The Plugins page dispatches `plugins.bundle.config` by the bundle's package
1844
+ * name, so the key has to spell exactly what the profile installs.
1845
+ */
1846
+ const BUNDLE_NAME = "dsh-workbuddy-connect";
1847
+ /**
1848
+ * Client services required by this browser half.
1687
1849
  *
1688
1850
  * DSH 0.1.2 removed `@deepseek-ai/dsh-client-runtime` (the package that used to
1689
- * hold the browser `ClientContext` alias and the `slots` service). The services
1690
- * this card relies on now come from narrower packages: the `slots` registry
1691
- * moved to `@deepseek-ai/dsh-client-ui-renderer`, `locale` stayed in
1692
- * `@deepseek-ai/dsh-client-locale`, and the `settings.plugin.item` slot is
1693
- * declared by `@deepseek-ai/dsh-client-ui-settings-plugins`. All three are
1694
- * named in the package's `dsh.client.inject` list, so cordis has activated
1695
- * them before this plugin's fiber starts.
1851
+ * hold the browser `ClientContext` alias and the `slots` service), so the
1852
+ * services come from narrower packages: the `slots` registry lives in
1853
+ * `@deepseek-ai/dsh-client-ui-renderer` and `locale` in
1854
+ * `@deepseek-ai/dsh-client-locale`. Neither slot owner is named here on
1855
+ * purpose: `settings.plugin.item`'s declarer (`…-ui-settings-plugins`) is
1856
+ * absent from 0.1.6+ hosts and `plugins.bundle.config`'s declarer
1857
+ * (`…-ui-plugin-manager`) is absent from 0.1.5 hosts, and the seam choice is
1858
+ * made by slot-declaration lifetime, not by activation order — `ctx.slots.inject`
1859
+ * fires whenever the declaring package commits the slot, before or after this
1860
+ * fiber starts.
1696
1861
  */
1697
1862
  const inject = [
1698
1863
  "slots",
@@ -1700,57 +1865,105 @@ window.__ModuleLoader__.load({
1700
1865
  "remote",
1701
1866
  "remote.session"
1702
1867
  ];
1868
+ /** Prefix every guarded client contribution's degradation logs with this. */
1869
+ const CLIENT_CONTRIBUTION_FAILED = "[dsh-workbuddy-connect] client contribution failed to load (host provider unaffected):";
1870
+ /** Disposer handed back when a deferred registration degraded: nothing to undo. */
1871
+ const NOOP_DISPOSER = () => {};
1703
1872
  /**
1704
- * Register card copy and the WorkBuddy card under Plugin configuration.
1873
+ * Run ONE browser-side contribution, degrading its failure to a `console.error`
1874
+ * instead of throwing into the DSH loader. Returns the contribution's own
1875
+ * value on success, or `undefined` when it degraded — the deferred slot
1876
+ * callbacks below substitute `NOOP_DISPOSER` for that, because the slot
1877
+ * runtime always expects a disposer back.
1878
+ *
1879
+ * Every contribution is guarded at BOTH boundaries where it can throw:
1705
1880
  *
1706
- * The entire body is wrapped so that a DSH slot-API breaking change (for
1707
- * example the rc.6→rc.7 `id`→`key` / `order`→`priority` rename) degrades
1708
- * to a `console.error` instead of throwing into the DSH loader and raising
1709
- * the red "Failed to load plugins" banner. The host provider keeps working:
1710
- * the `workbuddy` model channel is unaffected, and `dsh-workbuddy-connect
1711
- * status` reports host health via the heartbeat file.
1881
+ * 1. the eager `ctx.slots.inject(...)` / `ctx.inject(...)` call itself, which
1882
+ * runs synchronously inside `apply()` — e.g. a slot-API shape break such as
1883
+ * the rc.6→rc.7 `id`→`key` rename;
1884
+ * 2. the deferred callback, which the slot runtime invokes later — when the
1885
+ * owner commits the slot's declaration, or when the injected services
1886
+ * arrive — long after `apply()` has returned, where no enclosing try/catch
1887
+ * could still catch it.
1712
1888
  *
1713
- * NOTE: the try/catch boundary of this function is mirrored (duplicated) in
1714
- * `tests/client-fallback.spec.ts`, because the real client entry imports
1715
- * browser-only DSH packages that cannot load in the Node test environment.
1716
- * That test therefore does not import this function — it replicates its
1717
- * shape. If you change the guarded body or the `console.error` message here,
1718
- * update the mirrored `apply()` in that spec too, or the fallback test will
1719
- * silently diverge from this real implementation.
1889
+ * The pair is what makes the contributions independent: a failure in one
1890
+ * settings seam, or in the probe control, leaves every other registration
1891
+ * intact. Guards are for THIS browser half only; the host half reports its own
1892
+ * errors through `ctx.logger`.
1720
1893
  */
1721
- function apply(ctx) {
1894
+ function guardClientContribution(label, fn) {
1722
1895
  try {
1723
- const namespace = "settings.workbuddy";
1896
+ return fn();
1897
+ } catch (error) {
1898
+ console.error(`${CLIENT_CONTRIBUTION_FAILED} ${label}`, error);
1899
+ return;
1900
+ }
1901
+ }
1902
+ /**
1903
+ * Register the card copy and both settings-surface seams, one guarded
1904
+ * contribution at a time.
1905
+ *
1906
+ * A DSH slot-API breaking change degrades to a `console.error` per
1907
+ * contribution instead of throwing into the DSH loader and raising the red
1908
+ * "Failed to load plugins" banner; because each contribution carries its own
1909
+ * guard, one failing registration never takes the others with it (the old
1910
+ * settings cards survive a broken Plugins-page seam, and the probe control
1911
+ * survives either). The host provider keeps working throughout: the
1912
+ * `workbuddy` model channel is unaffected, and `dsh-workbuddy-connect status`
1913
+ * reports host health via the heartbeat file.
1914
+ *
1915
+ * The tests import this function directly (`tests/client-fallback.spec.ts`),
1916
+ * so its isolation semantics are pinned against the real entry — keep any
1917
+ * change to the guarded structure in sync with that spec.
1918
+ */
1919
+ function apply(ctx) {
1920
+ const namespace = "settings.workbuddy";
1921
+ guardClientContribution("settings copy", () => {
1724
1922
  ctx.effect(() => ctx.locale.register(namespace, {
1725
1923
  zh,
1726
1924
  en
1727
1925
  }), "dsh-workbuddy-connect: settings copy");
1728
- const t = ctx.locale.bind(namespace);
1729
- for (const [index, variant] of CARD_VARIANTS.entries()) ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
1730
- name: "settings.plugin.item",
1731
- key: variant.id,
1732
- priority: 30 - index,
1733
- inject: () => ({
1734
- t,
1735
- variant
1736
- })
1737
- }, WorkBuddyPluginCard));
1738
- ctx.inject(["modelDirectories"], (scope) => {
1739
- scope.slots.inject("conversation.input.right", () => scope.slots.register({
1740
- name: "conversation.input.right",
1741
- id: "workbuddy-probe",
1742
- order: 10,
1743
- inject: (sessionId) => ({
1744
- directory: scope.modelDirectories.directoryFor(sessionId).store,
1745
- t
1926
+ });
1927
+ const t = ctx.locale.bind(namespace);
1928
+ for (const [index, variant] of CARD_VARIANTS.entries()) {
1929
+ const label = `settings.plugin.item card "${variant.id}"`;
1930
+ guardClientContribution(label, () => {
1931
+ ctx.slots.inject("settings.plugin.item", () => guardClientContribution(label, () => ctx.slots.register({
1932
+ name: "settings.plugin.item",
1933
+ key: variant.id,
1934
+ priority: 30 - index,
1935
+ inject: () => ({
1936
+ t,
1937
+ variant
1746
1938
  })
1747
- }, WorkBuddyProbeControl));
1939
+ }, WorkBuddyPluginCard)) ?? NOOP_DISPOSER);
1748
1940
  });
1749
- } catch (error) {
1750
- console.error("[dsh-workbuddy-connect] client card failed to load (host provider unaffected):", error);
1751
1941
  }
1942
+ guardClientContribution("plugins.bundle.config page", () => {
1943
+ ctx.slots.inject("plugins.bundle.config", () => guardClientContribution("plugins.bundle.config page", () => ctx.slots.register({
1944
+ name: "plugins.bundle.config",
1945
+ key: "dsh-workbuddy-connect",
1946
+ locale: namespace
1947
+ }, WorkBuddyConfigPage)) ?? NOOP_DISPOSER);
1948
+ });
1949
+ guardClientContribution("conversation probe control", () => {
1950
+ ctx.inject(["modelDirectories"], (scope) => {
1951
+ guardClientContribution("conversation probe control", () => {
1952
+ scope.slots.inject("conversation.input.right", () => guardClientContribution("conversation probe control", () => scope.slots.register({
1953
+ name: "conversation.input.right",
1954
+ id: "workbuddy-probe",
1955
+ order: 10,
1956
+ inject: (sessionId) => ({
1957
+ directory: scope.modelDirectories.directoryFor(sessionId).store,
1958
+ t
1959
+ })
1960
+ }, WorkBuddyProbeControl)) ?? NOOP_DISPOSER);
1961
+ });
1962
+ });
1963
+ });
1752
1964
  }
1753
1965
  //#endregion
1966
+ exports.BUNDLE_NAME = BUNDLE_NAME;
1754
1967
  exports.apply = apply;
1755
1968
  exports.inject = inject;
1756
1969
  exports.name = name;