dsh-workbuddy-connect 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -30,6 +30,21 @@ window.__ModuleLoader__.load({
30
30
  */
31
31
  const WORKBUDDY_AI_STATUS_PATH = "/plugins/dsh-workbuddy-connect/ai/status";
32
32
  const WORKBUDDY_AI_PROBE_PATH = "/plugins/dsh-workbuddy-connect/ai/probe";
33
+ /** Every reason code, for validation without trusting a wire value. */
34
+ const SIGNED_OUT_REASON_CODES = [
35
+ "no-credential",
36
+ "credential-region-mismatch",
37
+ "encrypted-credential-unreadable",
38
+ "electron-binary-not-found",
39
+ "electron-binary-ambiguous",
40
+ "electron-binary-unavailable",
41
+ "electron-path-invalid",
42
+ "electron-discovery-incomplete"
43
+ ];
44
+ /** Whether a value is one of the closed set of signed-out reason codes. */
45
+ function isWorkBuddySignedOutReasonCode(value) {
46
+ return typeof value === "string" && SIGNED_OUT_REASON_CODES.includes(value);
47
+ }
33
48
  //#endregion
34
49
  //#region src/client/status-document.ts
35
50
  /**
@@ -43,6 +58,11 @@ window.__ModuleLoader__.load({
43
58
  * The check is deliberately limited to the discriminator (plus `error`'s
44
59
  * `message`, which the error paragraph renders): validating optional fields
45
60
  * here would reject documents the host legitimately omits fields from.
61
+ *
62
+ * `reasonCode` is therefore *not* rejected here — a card renders `reason`
63
+ * either way — but every reader must narrow it with
64
+ * `isWorkBuddySignedOutReasonCode` before branching on it, since the wire
65
+ * value is not guaranteed to be inside the enum.
46
66
  */
47
67
  function isWorkBuddyWebStatus(value) {
48
68
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
@@ -53,7 +73,13 @@ window.__ModuleLoader__.load({
53
73
  }
54
74
  //#endregion
55
75
  //#region src/client/WorkBuddyPluginCard.tsx
56
- /** WorkBuddy status card contributed to Harness Plugin configuration. */
76
+ /**
77
+ * WorkBuddy status card, rendered on whichever settings surface the host
78
+ * provides: dispatched directly by DSH 0.1.5's settings Plugins tab (one card
79
+ * per variant), or mounted by the bundle configuration page DSH 0.1.6+'s
80
+ * Plugins page renders. The component itself is surface-agnostic — its props
81
+ * are only the injected copy and variant.
82
+ */
57
83
  /** CN WorkBuddy; the plugin's long-standing card and default. */
58
84
  const CN_CARD_VARIANT = {
59
85
  id: "workbuddy",
@@ -61,7 +87,9 @@ window.__ModuleLoader__.load({
61
87
  introKey: "intro",
62
88
  signedOutKey: "signedOutHint",
63
89
  statusPath: WORKBUDDY_STATUS_PATH,
64
- probePath: WORKBUDDY_PROBE_PATH
90
+ probePath: WORKBUDDY_PROBE_PATH,
91
+ appName: "WorkBuddy",
92
+ unavailableKey: "assistUnavailableCN"
65
93
  };
66
94
  /** International WorkBuddy AI. */
67
95
  const AI_CARD_VARIANT = {
@@ -70,16 +98,125 @@ window.__ModuleLoader__.load({
70
98
  introKey: "introAI",
71
99
  signedOutKey: "signedOutHintAI",
72
100
  statusPath: WORKBUDDY_AI_STATUS_PATH,
73
- probePath: WORKBUDDY_AI_PROBE_PATH
101
+ probePath: WORKBUDDY_AI_PROBE_PATH,
102
+ appName: "WorkBuddy AI",
103
+ unavailableKey: "assistUnavailableAI"
74
104
  };
75
105
  /** Both cards, in display order. */
76
106
  const CARD_VARIANTS = [CN_CARD_VARIANT, AI_CARD_VARIANT];
77
107
  const POLL_INTERVAL_MS = 6e4;
108
+ /**
109
+ * The reason codes whose failures the Agent assist block covers: the plugin
110
+ * cannot reach a decryption program, for any of the five reasons in §5.5.
111
+ *
112
+ * This is the *only* place the card decides whether the block applies. It
113
+ * branches on the code, never on `reason` text: the prose is written for a
114
+ * human and is expected to change, so matching it would silently stop
115
+ * matching after any wording edit.
116
+ *
117
+ * `encrypted-credential-unreadable` is deliberately absent — the app was found
118
+ * and ran, so "look for the app" is not the fix for it.
119
+ */
120
+ const ASSIST_REASON_CODES = [
121
+ "electron-binary-not-found",
122
+ "electron-binary-ambiguous",
123
+ "electron-binary-unavailable",
124
+ "electron-path-invalid",
125
+ "electron-discovery-incomplete"
126
+ ];
127
+ /** Locale key for one failure's summary inside the Agent prompt. */
128
+ function assistSummaryKey(code, variant) {
129
+ switch (code) {
130
+ case "electron-binary-not-found": return "assistNotFound";
131
+ case "electron-binary-ambiguous": return "assistAmbiguous";
132
+ case "electron-discovery-incomplete": return "assistIncomplete";
133
+ case "electron-path-invalid": return "assistPathInvalid";
134
+ default: return variant.unavailableKey;
135
+ }
136
+ }
137
+ /** Copy text to the clipboard, reporting whether it worked. */
138
+ async function copyPrompt(text) {
139
+ try {
140
+ if (navigator.clipboard?.writeText === void 0) return false;
141
+ await navigator.clipboard.writeText(text);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+ /**
148
+ * The Agent assist block for a path failure: what is wrong, one copyable
149
+ * request, and a re-check. Rendered only for the codes above.
150
+ */
151
+ function AssistBlock({ t, variant, code, busy, onRecheck }) {
152
+ const [copied, setCopied] = (0, react.useState)(false);
153
+ const [copyFailed, setCopyFailed] = (0, react.useState)(false);
154
+ const prompt = t("assistantPrompt", {
155
+ appName: variant.appName,
156
+ failureSummary: t(assistSummaryKey(code, variant))
157
+ });
158
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
159
+ style: assistStyle,
160
+ children: [
161
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
162
+ style: assistHeadingStyle,
163
+ children: t("assistantHeading")
164
+ }),
165
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
166
+ style: bodyStyle,
167
+ children: t("assistantIntro")
168
+ }),
169
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
170
+ style: assistPromptRowStyle,
171
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
172
+ style: assistPromptStyle,
173
+ children: prompt
174
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
175
+ type: "button",
176
+ style: buttonStyle$1,
177
+ onClick: () => {
178
+ (async () => {
179
+ const ok = await copyPrompt(prompt);
180
+ setCopied(ok);
181
+ setCopyFailed(!ok);
182
+ })();
183
+ },
184
+ children: t("assistantCopy")
185
+ })]
186
+ }),
187
+ copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
188
+ style: assistFeedbackStyle,
189
+ role: "status",
190
+ children: t("assistantCopied")
191
+ }) : null,
192
+ copyFailed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
193
+ style: assistFeedbackStyle,
194
+ role: "status",
195
+ children: t("assistantCopyFailed")
196
+ }) : null,
197
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
198
+ style: bodyStyle,
199
+ children: t("assistantAfter")
200
+ }),
201
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
202
+ style: rowStyle,
203
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
204
+ type: "button",
205
+ style: buttonStyle$1,
206
+ disabled: busy,
207
+ onClick: onRecheck,
208
+ children: busy ? t("assistantRechecking") : t("assistantRecheck")
209
+ })
210
+ })
211
+ ]
212
+ });
213
+ }
78
214
  const cardStyle = {
79
215
  overflow: "hidden",
80
216
  border: "1px solid var(--dsw-alias-border-l2)",
81
217
  borderRadius: 10,
82
- background: "var(--dsw-alias-bg-module-platform)"
218
+ background: "var(--dsw-alias-bg-module-platform)",
219
+ listStyle: "none"
83
220
  };
84
221
  const headerStyle = {
85
222
  boxSizing: "border-box",
@@ -217,6 +354,64 @@ window.__ModuleLoader__.load({
217
354
  flexDirection: "column",
218
355
  gap: 2
219
356
  };
357
+ /**
358
+ * The Agent assist block. Sits beside the existing error line rather than
359
+ * replacing it: the short diagnosis stays the headline, this adds the way out.
360
+ */
361
+ const assistStyle = {
362
+ display: "flex",
363
+ flexDirection: "column",
364
+ gap: 10,
365
+ padding: "12px 14px",
366
+ marginTop: 12,
367
+ border: "1px solid var(--dsw-alias-border-l2)",
368
+ borderRadius: 10,
369
+ background: "var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04))"
370
+ };
371
+ const assistHeadingStyle = {
372
+ margin: 0,
373
+ fontSize: 14,
374
+ lineHeight: "20px",
375
+ fontWeight: 600,
376
+ color: "var(--dsw-alias-label-primary)"
377
+ };
378
+ const assistPromptRowStyle = {
379
+ display: "flex",
380
+ flexWrap: "wrap",
381
+ alignItems: "flex-start",
382
+ gap: 8,
383
+ padding: "8px 9px 8px 11px",
384
+ borderRadius: 7,
385
+ background: "var(--dsw-alias-bg-layer-1)"
386
+ };
387
+ const assistPromptStyle = {
388
+ flex: "1 1 220px",
389
+ minWidth: 0,
390
+ margin: 0,
391
+ color: "var(--dsw-alias-label-primary)",
392
+ fontSize: 12,
393
+ lineHeight: "19px",
394
+ whiteSpace: "pre-wrap",
395
+ overflowWrap: "anywhere",
396
+ userSelect: "text"
397
+ };
398
+ const assistFeedbackStyle = {
399
+ margin: 0,
400
+ fontSize: 12,
401
+ lineHeight: "19px",
402
+ color: "var(--dsw-alias-label-secondary)"
403
+ };
404
+ /**
405
+ * Left half of one merged context/visibility row: the visibility checkbox (when
406
+ * the account has one) beside the model's name and rate. Kept as a flex span so
407
+ * the capacity column stays flush right no matter how long the name runs.
408
+ */
409
+ const contextRowMainStyle = {
410
+ display: "flex",
411
+ alignItems: "center",
412
+ gap: 9,
413
+ minWidth: 0
414
+ };
220
415
  const modelBadgeChipStyle = {
221
416
  padding: "1px 8px",
222
417
  borderRadius: 999,
@@ -455,26 +650,41 @@ window.__ModuleLoader__.load({
455
650
  });
456
651
  }
457
652
  /**
458
- * Context capacity, listed in full.
653
+ * Context window and model visibility, one row per catalog model.
654
+ *
655
+ * The list is driven by the full current catalog, not by context metadata:
656
+ * hiding a model is a statement about the picker, and a model without a
657
+ * declared window is still hideable — its row just shows an em dash where the
658
+ * capacity would be. Rows with a window keep the original ordering (largest
659
+ * first); rows without one trail at the end in catalog order.
459
660
  *
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.
661
+ * Each row is one <label>, so the checkbox is named by its row without a
662
+ * duplicated aria string. The checkbox state comes from the status document
663
+ * only — no optimistic flip — so a save that fails leaves the box where the
664
+ * host's truth says it is, next to the failure notice `control` records.
665
+ * Checkboxes render only when the document carries a visibility section (a
666
+ * signed-in account with a stable user id); a uid-less credential shows the
667
+ * plain capacity list rather than editing a bucket every such account would
668
+ * share.
464
669
  *
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 —
670
+ * Purely a report of the upstream's own numbers otherwise. The plugin offers
671
+ * no tier picker: the CN catalog declares one capacity per model and publishes
672
+ * no alternatives, so a menu there would mean inventing client-side policy.
673
+ * The international document does declare alternatives (`supportedLengths`),
674
+ * and they are shown as a secondary figure rather than merged into one number —
470
675
  * the default is the budget actually requested, while the larger value is a
471
676
  * ceiling the upstream would accept.
472
677
  */
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));
678
+ function ContextTable({ models, t, useMaximumContextWindow, contextPreferenceDisabled, onUseMaximumContextWindow, visibility, visibilityControlsDisabled, visibilityToggling, onVisibilityToggle }) {
679
+ const rows = [...models ?? []].sort((a, b) => {
680
+ if (a.contextWindow === void 0) return b.contextWindow === void 0 ? 0 : 1;
681
+ if (b.contextWindow === void 0) return -1;
682
+ return b.contextWindow - a.contextWindow;
683
+ });
684
+ const canSelectMaximum = rows.some((model) => model.maxContextWindow !== void 0 && model.maxContextWindow > (model.defaultContextWindow ?? model.contextWindow ?? 0));
476
685
  const showPreference = onUseMaximumContextWindow !== void 0 && (canSelectMaximum || useMaximumContextWindow === true);
477
- if (known.length === 0 && !showPreference) return null;
686
+ if (rows.length === 0 && !showPreference) return null;
687
+ const hidden = new Set(visibility?.disabled ?? []);
478
688
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
479
689
  style: quotaListStyle,
480
690
  children: [
@@ -487,7 +697,7 @@ window.__ModuleLoader__.load({
487
697
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
488
698
  type: "checkbox",
489
699
  checked: useMaximumContextWindow === true,
490
- disabled,
700
+ disabled: contextPreferenceDisabled,
491
701
  onChange: (event) => {
492
702
  onUseMaximumContextWindow(event.currentTarget.checked);
493
703
  }
@@ -499,25 +709,65 @@ window.__ModuleLoader__.load({
499
709
  })]
500
710
  })]
501
711
  }) : 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);
712
+ visibility === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
713
+ style: bodyStyle,
714
+ children: t("visibilityIntro")
715
+ }),
716
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
717
+ style: quotaGroupStyle,
718
+ children: rows.map((model) => {
719
+ const capacity = model.contextWindow;
720
+ const alternative = capacity !== void 0 && model.maxContextWindow !== void 0 && model.maxContextWindow > capacity ? model.maxContextWindow : void 0;
721
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
722
+ style: quotaLabelStyle,
723
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
724
+ style: contextRowMainStyle,
725
+ children: [visibility === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
726
+ type: "checkbox",
727
+ checked: !hidden.has(model.id),
728
+ disabled: visibilityControlsDisabled || visibilityToggling.has(model.id),
729
+ onChange: (event) => {
730
+ onVisibilityToggle?.(model.id, event.currentTarget.checked);
731
+ }
732
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
733
+ style: modelOfferStyle,
734
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
735
+ style: modelBadgeStyle,
736
+ children: [
737
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }),
738
+ model.badges?.map((badge) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
739
+ style: modelBadgeChipStyle,
740
+ children: modelBadgeLabel(badge, t)
741
+ }, badge)),
742
+ model.free === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
743
+ style: modelBadgeChipStyle,
744
+ children: t("freeModel")
745
+ }) : null
746
+ ]
747
+ }), model.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
748
+ style: modelRateStyle,
749
+ children: model.credits
750
+ })]
751
+ })]
752
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
753
+ style: modelOfferStyle,
754
+ children: [capacity === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
755
+ style: modelRateStyle,
756
+ "aria-label": t("contextUnknown"),
757
+ children: "—"
758
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
759
+ style: { textAlign: "right" },
760
+ children: formatTokens(capacity)
761
+ }), alternative !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
762
+ style: modelRateStyle,
763
+ children: t("contextUpTo", { size: formatTokens(alternative) })
764
+ }) : capacity !== void 0 && model.defaultContextWindow !== void 0 && model.defaultContextWindow < capacity ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
765
+ style: modelRateStyle,
766
+ children: t("contextDefault", { size: formatTokens(model.defaultContextWindow) })
767
+ }) : null]
768
+ })]
769
+ }, model.id);
770
+ })
521
771
  })
522
772
  ]
523
773
  });
@@ -543,7 +793,7 @@ window.__ModuleLoader__.load({
543
793
  * parameter ("this model does not check it"), never as a statement that a
544
794
  * level is unsupported.
545
795
  */
546
- function ProbeSection({ probe, t, onDetect, onClear, busy }) {
796
+ function ProbeSection({ probe, models, t, onDetect, onClear, busy }) {
547
797
  const [pending, setPending] = (0, react.useState)();
548
798
  const [runningModel, setRunningModel] = (0, react.useState)();
549
799
  (0, react.useEffect)(() => {
@@ -590,7 +840,7 @@ window.__ModuleLoader__.load({
590
840
  style: quotaGroupStyle,
591
841
  children: probe.candidates.map((id) => {
592
842
  const result = probe.results.find((entry) => entry.id === id);
593
- const name = result?.name ?? id;
843
+ const name = models?.find((model) => model.id === id)?.name ?? result?.name ?? id;
594
844
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
595
845
  style: modelOfferStyle,
596
846
  children: [
@@ -608,7 +858,7 @@ window.__ModuleLoader__.load({
608
858
  onClick: () => {
609
859
  setPending(id);
610
860
  },
611
- children: runningModel === id ? t("probeRunning", { model: id }) : t(result === void 0 ? "probeStart" : "probeRedetect")
861
+ children: runningModel === id ? t("probeRunning", { model: name }) : t(result === void 0 ? "probeStart" : "probeRedetect")
612
862
  })]
613
863
  })]
614
864
  }),
@@ -688,6 +938,17 @@ window.__ModuleLoader__.load({
688
938
  */
689
939
  const [readFailure, setReadFailure] = (0, react.useState)();
690
940
  const [busy, setBusy] = (0, react.useState)(false);
941
+ /**
942
+ * The model ids whose visibility writes are in flight, empty when none are.
943
+ * Deliberately NOT the card-wide `busy` (that flag drives the Refresh
944
+ * buttons' labels, which must not claim a refresh the user never pressed)
945
+ * and deliberately per-row rather than whole-list: a visibility write only
946
+ * adds or removes one model's id, so the rows are independent — locking
947
+ * every checkbox for one row's write made the whole list visibly blink for
948
+ * no correctness gain. A set, because two writes can be open at once when
949
+ * the user moves down the list; only the rows being written lock.
950
+ */
951
+ const [togglingModels, setTogglingModels] = (0, react.useState)(() => /* @__PURE__ */ new Set());
691
952
  const [tab, setTab] = (0, react.useState)("status");
692
953
  const mounted = (0, react.useRef)(true);
693
954
  /**
@@ -843,14 +1104,16 @@ window.__ModuleLoader__.load({
843
1104
  const control = (0, react.useCallback)(async (action) => {
844
1105
  const key = status?.status === "signed-in" ? status.probeKey : void 0;
845
1106
  if (key === void 0) return;
846
- setBusy(true);
1107
+ const visibility = action.action === "set-model-visibility";
1108
+ if (visibility) setTogglingModels((previous) => new Set(previous).add(action.model));
1109
+ else setBusy(true);
847
1110
  const controller = trackController();
848
1111
  try {
849
1112
  const response = await fetch(variant.probePath, {
850
1113
  method: "POST",
851
1114
  headers: {
852
1115
  "Content-Type": "application/json",
853
- "X-WorkBuddy-Probe-Key": key
1116
+ "X-Workbuddy-Probe-Key": key
854
1117
  },
855
1118
  credentials: "same-origin",
856
1119
  signal: controller.signal,
@@ -861,7 +1124,12 @@ window.__ModuleLoader__.load({
861
1124
  const message = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
862
1125
  throw new Error(message);
863
1126
  }
864
- if (action.action === "set-maximum-context-window" && (typeof value !== "object" || value === null || value["state"] !== "updated")) {
1127
+ if ((action.action === "set-maximum-context-window" || action.action === "set-model-visibility") && (typeof value !== "object" || value === null || value["state"] !== "updated")) {
1128
+ const state = typeof value === "object" && value !== null ? value["state"] : void 0;
1129
+ if (action.action === "set-model-visibility" && state === "stale-account") {
1130
+ await refresh(controller.signal);
1131
+ throw new Error(t("visibilityStaleAccount"));
1132
+ }
865
1133
  const reason = typeof value === "object" && value !== null && "reason" in value ? String(value["reason"]) : t("requestFailed");
866
1134
  throw new Error(reason);
867
1135
  }
@@ -870,7 +1138,13 @@ window.__ModuleLoader__.load({
870
1138
  if (mounted.current && controller.signal.aborted !== true) setReadFailure(error instanceof Error ? error.message : t("requestFailed"));
871
1139
  } finally {
872
1140
  manualControllers.current.delete(controller);
873
- if (mounted.current) setBusy(false);
1141
+ if (!mounted.current) return;
1142
+ if (visibility) setTogglingModels((previous) => {
1143
+ const next = new Set(previous);
1144
+ next.delete(action.model);
1145
+ return next;
1146
+ });
1147
+ else setBusy(false);
874
1148
  }
875
1149
  }, [
876
1150
  refresh,
@@ -890,6 +1164,13 @@ window.__ModuleLoader__.load({
890
1164
  });
891
1165
  }, [control]);
892
1166
  const title = t(variant.titleKey);
1167
+ /**
1168
+ * The failure the assist block covers, when this document has one. Computed
1169
+ * once so the block and the header's refresh button agree on whether the
1170
+ * block owns the re-check action — showing both would put two buttons with
1171
+ * the same effect side by side.
1172
+ */
1173
+ const assistCode = status?.status === "signed-out" && isWorkBuddySignedOutReasonCode(status.reasonCode) && ASSIST_REASON_CODES.includes(status.reasonCode) ? status.reasonCode : void 0;
893
1174
  const label = status === void 0 ? t("loading") : status.status === "signed-in" ? status.nickname === void 0 ? t("signedInAs", { nickname: "" }).trimEnd().replace(/[::]$/, "") : t("signedInAs", { nickname: status.nickname }) : status.status === "error" ? t("requestFailed") : t("signedOut");
894
1175
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
895
1176
  style: cardStyle,
@@ -935,7 +1216,7 @@ window.__ModuleLoader__.load({
935
1216
  "aria-hidden": "true",
936
1217
  style: dotStyle(status === void 0 ? "loading" : status.status)
937
1218
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
938
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1219
+ }), assistCode === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
939
1220
  type: "button",
940
1221
  style: buttonStyle$1,
941
1222
  disabled: busy,
@@ -943,7 +1224,7 @@ window.__ModuleLoader__.load({
943
1224
  manualRefresh();
944
1225
  },
945
1226
  children: busy ? t("refreshing") : t("refresh")
946
- })]
1227
+ }) : null]
947
1228
  }),
948
1229
  readFailure === void 0 || signedIn === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
949
1230
  style: errorStyle,
@@ -1019,6 +1300,7 @@ window.__ModuleLoader__.load({
1019
1300
  }),
1020
1301
  status.probe === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProbeSection, {
1021
1302
  probe: status.probe,
1303
+ models: status.models,
1022
1304
  t,
1023
1305
  busy,
1024
1306
  onDetect: confirmDetect,
@@ -1032,14 +1314,27 @@ window.__ModuleLoader__.load({
1032
1314
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextTable, {
1033
1315
  models: status.models,
1034
1316
  t,
1035
- disabled: busy,
1036
- ...status.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: status.useMaximumContextWindow },
1037
- ...variant.id === AI_CARD_VARIANT.id ? { onUseMaximumContextWindow: (enabled) => {
1317
+ contextPreferenceDisabled: busy,
1318
+ ...variant.id === AI_CARD_VARIANT.id && status.useMaximumContextWindow !== void 0 ? {
1319
+ useMaximumContextWindow: status.useMaximumContextWindow,
1320
+ onUseMaximumContextWindow: (enabled) => {
1321
+ control({
1322
+ action: "set-maximum-context-window",
1323
+ enabled
1324
+ });
1325
+ }
1326
+ } : {},
1327
+ visibility: status.visibility,
1328
+ visibilityControlsDisabled: busy,
1329
+ visibilityToggling: togglingModels,
1330
+ onVisibilityToggle: (modelId, visible) => {
1038
1331
  control({
1039
- action: "set-maximum-context-window",
1040
- enabled
1332
+ action: "set-model-visibility",
1333
+ model: modelId,
1334
+ visible,
1335
+ account: status.visibility?.account ?? ""
1041
1336
  });
1042
- } } : {}
1337
+ }
1043
1338
  })
1044
1339
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1045
1340
  style: tabPanelStyle,
@@ -1067,10 +1362,18 @@ window.__ModuleLoader__.load({
1067
1362
  })]
1068
1363
  })
1069
1364
  ] }) : null,
1070
- status?.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1365
+ status?.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1071
1366
  style: status.reason === void 0 ? bodyStyle : errorStyle,
1072
1367
  children: status.reason ?? t(variant.signedOutKey)
1073
- }) : null,
1368
+ }), assistCode === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AssistBlock, {
1369
+ t,
1370
+ variant,
1371
+ code: assistCode,
1372
+ busy,
1373
+ onRecheck: () => {
1374
+ manualRefresh();
1375
+ }
1376
+ })] }) : null,
1074
1377
  status?.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1075
1378
  style: errorStyle,
1076
1379
  children: status.message
@@ -1510,6 +1813,44 @@ window.__ModuleLoader__.load({
1510
1813
  return t("probeNoteUnknown");
1511
1814
  }
1512
1815
  //#endregion
1816
+ //#region src/client/WorkBuddyConfigPage.tsx
1817
+ /**
1818
+ * The cards' list; the page supplies no other chrome. A semantic `<ul>` —
1819
+ * each card below is an `<li>` — with the user-agent list defaults cleared so
1820
+ * only the column/gap rhythm remains, keeping the layout identical to the
1821
+ * flex column it replaced while giving the page real list semantics.
1822
+ */
1823
+ const pageStyle = {
1824
+ display: "flex",
1825
+ flexDirection: "column",
1826
+ gap: 12,
1827
+ listStyle: "none",
1828
+ margin: 0,
1829
+ padding: 0
1830
+ };
1831
+ /**
1832
+ * Render the bundle's configuration: one status card per WorkBuddy variant.
1833
+ *
1834
+ * Both variants share one page. DSH 0.1.6 replaced the settings section's
1835
+ * keyed `settings.plugin.item` slot — dispatched once per served settings
1836
+ * namespace, which is why the two variants used to be two cards — with the
1837
+ * Plugins page's `plugins.bundle.config`, keyed by the bundle's package name.
1838
+ * A bundle therefore carries exactly one configuration entry, so the cards are
1839
+ * stacked here instead of being dispatched separately. (The 0.1.5 settings tab
1840
+ * and its two dispatched cards still exist on 0.1.5 hosts; this page only
1841
+ * mounts where the Plugins page declares its slot.)
1842
+ */
1843
+ function WorkBuddyConfigPage({ view, t }) {
1844
+ if (view === "summary") return t("intro");
1845
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1846
+ style: pageStyle,
1847
+ children: CARD_VARIANTS.map((variant) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WorkBuddyPluginCard, {
1848
+ t,
1849
+ variant
1850
+ }, variant.id))
1851
+ });
1852
+ }
1853
+ //#endregion
1513
1854
  //#region src/client/locales.ts
1514
1855
  /** Plugin-card copy registered under the settings.workbuddy locale namespace. */
1515
1856
  const en = {
@@ -1557,8 +1898,11 @@ window.__ModuleLoader__.load({
1557
1898
  contextHeading: "Context window",
1558
1899
  contextUpTo: "up to {size}",
1559
1900
  contextDefault: "default {size}",
1901
+ contextUnknown: "no declared context window",
1560
1902
  useMaximumContextWindow: "Use the largest declared context window",
1561
1903
  useMaximumContextWindowHint: "Applies to WorkBuddy AI models that offer a larger window.",
1904
+ visibilityIntro: "Uncheck a model to hide it from the model picker. Saved per signed-in account; chats already using a hidden model keep working.",
1905
+ visibilityStaleAccount: "The signed-in account changed — this change was not saved.",
1562
1906
  freeModel: "Free",
1563
1907
  badgeLimitedFree: "Limited-time free",
1564
1908
  badgeNightDiscount: "Night discount",
@@ -1593,7 +1937,22 @@ window.__ModuleLoader__.load({
1593
1937
  probeResultUnknown: "Detection did not complete",
1594
1938
  probeResultAt: "Detected {time}",
1595
1939
  probeResultEmpty: "No detectable models right now.",
1596
- probeFailed: "Detection failed: {message}"
1940
+ probeFailed: "Detection failed: {message}",
1941
+ assistantHeading: "Let an Agent sort this out",
1942
+ assistantIntro: "Send the request below to your Agent; it will check the app location and the launch configuration for you.",
1943
+ assistantCopy: "Copy for Agent",
1944
+ assistantCopied: "Copied",
1945
+ assistantCopyFailed: "Copy failed — select the text above and copy it manually",
1946
+ assistantAfter: "When your Agent is done, come back and check again. If the DSH launch environment was changed, restart DSH first as instructed.",
1947
+ assistantRecheck: "Done — check again",
1948
+ assistantRechecking: "Checking…",
1949
+ assistantPrompt: "DSH's dsh-workbuddy-connect cannot use my {appName}: {failureSummary}. Please check the actual installation location and any existing path configuration, help the plugin use it correctly, and verify recovery. If the DSH launch environment must be changed or DSH restarted, give me clear steps; do not only set an environment variable temporarily in the current shell.",
1950
+ assistNotFound: "no usable decryption program was found",
1951
+ assistAmbiguous: "more than one WorkBuddy copy was found and none could be chosen safely",
1952
+ assistIncomplete: "the automatic search could not be completed",
1953
+ assistPathInvalid: "the configured program path is not usable",
1954
+ assistUnavailableCN: "no decryption program is configured for this platform",
1955
+ assistUnavailableAI: "no decryption program is configured for WorkBuddy AI"
1597
1956
  };
1598
1957
  const zh = {
1599
1958
  title: "DSH WorkBuddy Connect",
@@ -1640,8 +1999,11 @@ window.__ModuleLoader__.load({
1640
1999
  contextHeading: "上下文窗口",
1641
2000
  contextUpTo: "最高 {size}",
1642
2001
  contextDefault: "默认 {size}",
2002
+ contextUnknown: "未声明上下文窗口",
1643
2003
  useMaximumContextWindow: "使用上游声明的最大上下文窗口",
1644
2004
  useMaximumContextWindowHint: "仅作用于 WorkBuddy AI 中声明了更大窗口的模型。",
2005
+ visibilityIntro: "取消勾选即可在模型选择器中隐藏该模型;按当前登录账号分别保存,已在用该模型的会话不受影响。",
2006
+ visibilityStaleAccount: "登录账号已切换——本次修改未保存。",
1645
2007
  freeModel: "免费",
1646
2008
  badgeLimitedFree: "限时免费",
1647
2009
  badgeNightDiscount: "夜间折扣",
@@ -1676,23 +2038,48 @@ window.__ModuleLoader__.load({
1676
2038
  probeResultUnknown: "检测未完成",
1677
2039
  probeResultAt: "检测于 {time}",
1678
2040
  probeResultEmpty: "当前没有可检测的模型。",
1679
- probeFailed: "检测失败:{message}"
2041
+ probeFailed: "检测失败:{message}",
2042
+ assistantHeading: "让 Agent 帮你处理",
2043
+ assistantIntro: "把下面这段请求发给你的 Agent,它会协助检查应用位置和启动配置。",
2044
+ assistantCopy: "复制给 Agent",
2045
+ assistantCopied: "已复制",
2046
+ assistantCopyFailed: "复制失败,请手动选择上方文字复制",
2047
+ assistantAfter: "Agent 处理完成后,回到这里重新检查;如果修改了 DSH 的启动环境,请先按指引重启 DSH。",
2048
+ assistantRecheck: "已处理,重新检查",
2049
+ assistantRechecking: "正在检查…",
2050
+ assistantPrompt: "DSH 的 dsh-workbuddy-connect 无法使用我的 {appName}:{failureSummary}。请帮我检查实际安装位置和已有路径配置,让插件能正确使用它,并验证恢复结果;如果需要修改 DSH 的启动环境或重启,请给我明确的操作步骤,不要只在当前 shell 临时设置环境变量。",
2051
+ assistNotFound: "没有找到可用的解密程序",
2052
+ assistAmbiguous: "找到了多个 WorkBuddy 副本,无法安全自动选择",
2053
+ assistIncomplete: "自动定位未能完成",
2054
+ assistPathInvalid: "指定的程序路径不可用",
2055
+ assistUnavailableCN: "当前平台尚未配置解密程序",
2056
+ assistUnavailableAI: "尚未配置 WorkBuddy AI 的解密程序"
1680
2057
  };
1681
2058
  //#endregion
1682
2059
  //#region src/client/index.tsx
1683
2060
  /** Stable browser-plugin name. */
1684
2061
  const name = "dsh-workbuddy-connect-client";
1685
2062
  /**
1686
- * Client services required by the Plugin configuration contribution.
2063
+ * The bundle's package name, which is also this half's configuration key.
2064
+ *
2065
+ * The Plugins page dispatches `plugins.bundle.config` by the bundle's package
2066
+ * name, so the key has to spell exactly what the profile installs.
2067
+ */
2068
+ const BUNDLE_NAME = "dsh-workbuddy-connect";
2069
+ /**
2070
+ * Client services required by this browser half.
1687
2071
  *
1688
2072
  * 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.
2073
+ * hold the browser `ClientContext` alias and the `slots` service), so the
2074
+ * services come from narrower packages: the `slots` registry lives in
2075
+ * `@deepseek-ai/dsh-client-ui-renderer` and `locale` in
2076
+ * `@deepseek-ai/dsh-client-locale`. Neither slot owner is named here on
2077
+ * purpose: `settings.plugin.item`'s declarer (`…-ui-settings-plugins`) is
2078
+ * absent from 0.1.6+ hosts and `plugins.bundle.config`'s declarer
2079
+ * (`…-ui-plugin-manager`) is absent from 0.1.5 hosts, and the seam choice is
2080
+ * made by slot-declaration lifetime, not by activation order — `ctx.slots.inject`
2081
+ * fires whenever the declaring package commits the slot, before or after this
2082
+ * fiber starts.
1696
2083
  */
1697
2084
  const inject = [
1698
2085
  "slots",
@@ -1700,57 +2087,105 @@ window.__ModuleLoader__.load({
1700
2087
  "remote",
1701
2088
  "remote.session"
1702
2089
  ];
2090
+ /** Prefix every guarded client contribution's degradation logs with this. */
2091
+ const CLIENT_CONTRIBUTION_FAILED = "[dsh-workbuddy-connect] client contribution failed to load (host provider unaffected):";
2092
+ /** Disposer handed back when a deferred registration degraded: nothing to undo. */
2093
+ const NOOP_DISPOSER = () => {};
1703
2094
  /**
1704
- * Register card copy and the WorkBuddy card under Plugin configuration.
2095
+ * Run ONE browser-side contribution, degrading its failure to a `console.error`
2096
+ * instead of throwing into the DSH loader. Returns the contribution's own
2097
+ * value on success, or `undefined` when it degraded — the deferred slot
2098
+ * callbacks below substitute `NOOP_DISPOSER` for that, because the slot
2099
+ * runtime always expects a disposer back.
1705
2100
  *
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.
2101
+ * Every contribution is guarded at BOTH boundaries where it can throw:
1712
2102
  *
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.
2103
+ * 1. the eager `ctx.slots.inject(...)` / `ctx.inject(...)` call itself, which
2104
+ * runs synchronously inside `apply()` — e.g. a slot-API shape break such as
2105
+ * the rc.6→rc.7 `id`→`key` rename;
2106
+ * 2. the deferred callback, which the slot runtime invokes later — when the
2107
+ * owner commits the slot's declaration, or when the injected services
2108
+ * arrive — long after `apply()` has returned, where no enclosing try/catch
2109
+ * could still catch it.
2110
+ *
2111
+ * The pair is what makes the contributions independent: a failure in one
2112
+ * settings seam, or in the probe control, leaves every other registration
2113
+ * intact. Guards are for THIS browser half only; the host half reports its own
2114
+ * errors through `ctx.logger`.
1720
2115
  */
1721
- function apply(ctx) {
2116
+ function guardClientContribution(label, fn) {
1722
2117
  try {
1723
- const namespace = "settings.workbuddy";
2118
+ return fn();
2119
+ } catch (error) {
2120
+ console.error(`${CLIENT_CONTRIBUTION_FAILED} ${label}`, error);
2121
+ return;
2122
+ }
2123
+ }
2124
+ /**
2125
+ * Register the card copy and both settings-surface seams, one guarded
2126
+ * contribution at a time.
2127
+ *
2128
+ * A DSH slot-API breaking change degrades to a `console.error` per
2129
+ * contribution instead of throwing into the DSH loader and raising the red
2130
+ * "Failed to load plugins" banner; because each contribution carries its own
2131
+ * guard, one failing registration never takes the others with it (the old
2132
+ * settings cards survive a broken Plugins-page seam, and the probe control
2133
+ * survives either). The host provider keeps working throughout: the
2134
+ * `workbuddy` model channel is unaffected, and `dsh-workbuddy-connect status`
2135
+ * reports host health via the heartbeat file.
2136
+ *
2137
+ * The tests import this function directly (`tests/client-fallback.spec.ts`),
2138
+ * so its isolation semantics are pinned against the real entry — keep any
2139
+ * change to the guarded structure in sync with that spec.
2140
+ */
2141
+ function apply(ctx) {
2142
+ const namespace = "settings.workbuddy";
2143
+ guardClientContribution("settings copy", () => {
1724
2144
  ctx.effect(() => ctx.locale.register(namespace, {
1725
2145
  zh,
1726
2146
  en
1727
2147
  }), "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
2148
+ });
2149
+ const t = ctx.locale.bind(namespace);
2150
+ for (const [index, variant] of CARD_VARIANTS.entries()) {
2151
+ const label = `settings.plugin.item card "${variant.id}"`;
2152
+ guardClientContribution(label, () => {
2153
+ ctx.slots.inject("settings.plugin.item", () => guardClientContribution(label, () => ctx.slots.register({
2154
+ name: "settings.plugin.item",
2155
+ key: variant.id,
2156
+ priority: 30 - index,
2157
+ inject: () => ({
2158
+ t,
2159
+ variant
1746
2160
  })
1747
- }, WorkBuddyProbeControl));
2161
+ }, WorkBuddyPluginCard)) ?? NOOP_DISPOSER);
1748
2162
  });
1749
- } catch (error) {
1750
- console.error("[dsh-workbuddy-connect] client card failed to load (host provider unaffected):", error);
1751
2163
  }
2164
+ guardClientContribution("plugins.bundle.config page", () => {
2165
+ ctx.slots.inject("plugins.bundle.config", () => guardClientContribution("plugins.bundle.config page", () => ctx.slots.register({
2166
+ name: "plugins.bundle.config",
2167
+ key: "dsh-workbuddy-connect",
2168
+ locale: namespace
2169
+ }, WorkBuddyConfigPage)) ?? NOOP_DISPOSER);
2170
+ });
2171
+ guardClientContribution("conversation probe control", () => {
2172
+ ctx.inject(["modelDirectories"], (scope) => {
2173
+ guardClientContribution("conversation probe control", () => {
2174
+ scope.slots.inject("conversation.input.right", () => guardClientContribution("conversation probe control", () => scope.slots.register({
2175
+ name: "conversation.input.right",
2176
+ id: "workbuddy-probe",
2177
+ order: 10,
2178
+ inject: (sessionId) => ({
2179
+ directory: scope.modelDirectories.directoryFor(sessionId).store,
2180
+ t
2181
+ })
2182
+ }, WorkBuddyProbeControl)) ?? NOOP_DISPOSER);
2183
+ });
2184
+ });
2185
+ });
1752
2186
  }
1753
2187
  //#endregion
2188
+ exports.BUNDLE_NAME = BUNDLE_NAME;
1754
2189
  exports.apply = apply;
1755
2190
  exports.inject = inject;
1756
2191
  exports.name = name;