dsh-workbuddy-connect 0.3.2 → 0.4.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
@@ -10,6 +10,420 @@ window.__ModuleLoader__.load({
10
10
  /** Node-free constants and types shared by the Host and browser halves. */
11
11
  /** Plugin-owned status endpoint consumed by its browser half. */
12
12
  const WORKBUDDY_STATUS_PATH = "/plugins/dsh-workbuddy-connect/status";
13
+ /**
14
+ * Plugin-owned probe control endpoint.
15
+ *
16
+ * Separate from the status route because it accepts writes: the status route's
17
+ * loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
18
+ * not the same as authorizing a state-changing action. This route therefore
19
+ * also requires the in-process key the browser half receives with the status
20
+ * document.
21
+ */
22
+ const WORKBUDDY_PROBE_PATH = "/plugins/dsh-workbuddy-connect/probe";
23
+ //#endregion
24
+ //#region src/client/WorkBuddyProbeControl.tsx
25
+ /**
26
+ * Per-model reasoning-effort entry beside the Composer's model selector.
27
+ *
28
+ * Interaction follows the Fast Mode control `dsh-codex-connect` ships in this
29
+ * same seat, which is the established shape for composer chrome here:
30
+ *
31
+ * - a **static inline label** next to the icon names the feature ("Reasoning
32
+ * levels"), set smaller and dimmer than the surrounding chrome so it reads as
33
+ * an annotation on the icon. It never carries state: the verified levels
34
+ * already appear in the model dropdown (the adapter exposes them as
35
+ * selectable efforts), so repeating them here would duplicate the real answer
36
+ * and make the label's width jump as results change.
37
+ * - a **hover/focus tooltip** carries the state and the click's purpose, the way
38
+ * Fast Mode's tooltip explains its current speed.
39
+ * - the **confirmation** is a small bubble anchored to the control, not a
40
+ * `window.confirm`. Probing spends real credit, so a confirmation stays — but
41
+ * it belongs next to the thing it acts on, sized to one line plus two small
42
+ * buttons.
43
+ *
44
+ * @module dsh-workbuddy-connect/client/probe-control
45
+ */
46
+ /** How often the control re-checks state when the window regains focus. */
47
+ const RECONCILE_MS = 6e4;
48
+ const wrapperStyle = {
49
+ display: "inline-flex",
50
+ position: "relative",
51
+ alignItems: "center",
52
+ transform: "translateY(2px)",
53
+ marginRight: -8
54
+ };
55
+ const buttonStyle$1 = {
56
+ display: "inline-flex",
57
+ alignItems: "center",
58
+ justifyContent: "center",
59
+ gap: 2,
60
+ height: 30,
61
+ padding: "0 6px",
62
+ border: 0,
63
+ borderRadius: 8,
64
+ background: "transparent",
65
+ color: "var(--dsw-alias-label-secondary)",
66
+ font: "inherit",
67
+ whiteSpace: "nowrap",
68
+ cursor: "pointer"
69
+ };
70
+ /**
71
+ * The inline label. Smaller and dimmer than the surrounding chrome on purpose:
72
+ * it names the feature, so it should read as an annotation attached to the icon
73
+ * rather than compete with the adjacent model selector.
74
+ */
75
+ const labelStyle = {
76
+ fontSize: 11,
77
+ lineHeight: "16px",
78
+ color: "var(--dsw-alias-label-tertiary)"
79
+ };
80
+ /** Tooltip bubble: the Fast Mode shape (nowrap, one line, above the control). */
81
+ const tooltipStyle = {
82
+ position: "absolute",
83
+ left: "50%",
84
+ bottom: "calc(100% + 8px)",
85
+ zIndex: 1e3,
86
+ transform: "translateX(-50%)",
87
+ padding: "4px 8px",
88
+ borderRadius: 6,
89
+ background: "var(--dsw-specific-tip, #1f2329)",
90
+ boxShadow: "var(--dsw-shadow-lv2)",
91
+ color: "var(--dsw-alias-label-primary, #fff)",
92
+ fontSize: 12,
93
+ lineHeight: "18px",
94
+ whiteSpace: "nowrap",
95
+ pointerEvents: "none"
96
+ };
97
+ /** Confirmation bubble: same anchor, but interactive and allowed to wrap. */
98
+ const confirmStyle = {
99
+ position: "absolute",
100
+ right: 0,
101
+ bottom: "calc(100% + 8px)",
102
+ zIndex: 1001,
103
+ display: "flex",
104
+ flexDirection: "column",
105
+ gap: 8,
106
+ width: 260,
107
+ padding: "10px 12px",
108
+ border: "1px solid var(--dsw-alias-border-l2)",
109
+ borderRadius: 8,
110
+ background: "var(--dsw-alias-bg-layer-1, #fff)",
111
+ boxShadow: "var(--dsw-shadow-lv2)",
112
+ color: "var(--dsw-alias-label-primary)",
113
+ fontSize: 12,
114
+ lineHeight: "18px"
115
+ };
116
+ const confirmRowStyle$1 = {
117
+ display: "flex",
118
+ justifyContent: "flex-end",
119
+ gap: 8
120
+ };
121
+ const confirmButtonStyle = {
122
+ padding: "3px 10px",
123
+ border: "1px solid var(--dsw-alias-border-l2)",
124
+ borderRadius: 6,
125
+ background: "transparent",
126
+ color: "inherit",
127
+ font: "inherit",
128
+ fontSize: 12,
129
+ cursor: "pointer"
130
+ };
131
+ /**
132
+ * Primary action inside the confirmation bubble.
133
+ *
134
+ * The fill and its text colour must come as a pair: `brand-primary` resolves to
135
+ * a light accent in this theme, so hardcoding `color: #fff` on top of it renders
136
+ * white-on-white. `button-primary-fill` + `label-primary-foreground` is the
137
+ * theme's own pair for exactly this, and is what `dsh-codex-connect` uses for
138
+ * the same job.
139
+ */
140
+ const primaryButtonStyle$1 = {
141
+ ...confirmButtonStyle,
142
+ border: "1px solid var(--dsw-alias-button-primary-fill)",
143
+ background: "var(--dsw-alias-button-primary-fill)",
144
+ color: "var(--dsw-alias-label-primary-foreground)"
145
+ };
146
+ /**
147
+ * Result note: a single line + a dismiss button, anchored to the control's
148
+ * right side. Smaller than the confirmation bubble because it carries an
149
+ * *outcome*, not a *decision* — the work is done, the user only has to read
150
+ * and dismiss.
151
+ */
152
+ const noteStyle = {
153
+ position: "absolute",
154
+ right: 0,
155
+ bottom: "calc(100% + 8px)",
156
+ zIndex: 1001,
157
+ display: "flex",
158
+ alignItems: "center",
159
+ gap: 12,
160
+ padding: "6px 10px",
161
+ border: "1px solid var(--dsw-alias-border-l2)",
162
+ borderRadius: 8,
163
+ background: "var(--dsw-alias-bg-layer-1)",
164
+ boxShadow: "var(--dsw-shadow-lv2)",
165
+ color: "var(--dsw-alias-label-primary)",
166
+ fontSize: 12,
167
+ lineHeight: "18px",
168
+ whiteSpace: "nowrap"
169
+ };
170
+ /**
171
+ * The note's dismiss action. Outlined rather than bare text: inside an already
172
+ * bordered bubble, an unbordered word does not read as something you can click.
173
+ * Matches the outlined pill convention the plugin's other secondary actions use.
174
+ */
175
+ const noteDismissStyle = {
176
+ padding: "2px 8px",
177
+ border: "1px solid var(--dsw-alias-border-l2)",
178
+ borderRadius: 6,
179
+ background: "transparent",
180
+ color: "var(--dsw-alias-label-secondary)",
181
+ font: "inherit",
182
+ fontSize: 12,
183
+ lineHeight: "18px",
184
+ cursor: "pointer"
185
+ };
186
+ /**
187
+ * The feature's static inline label. Deliberately not a state readout — see the
188
+ * module comment.
189
+ */
190
+ function useLabel(t) {
191
+ return t("probeLabel");
192
+ }
193
+ /** Pick the model's recorded observation out of the probe section. */
194
+ function resultFor(status, model) {
195
+ if (status.status !== "signed-in") return void 0;
196
+ return status.probe?.results.find((result) => result.id === model);
197
+ }
198
+ /**
199
+ * The one-line tooltip: current state first, then what a click does — the same
200
+ * two-part shape Fast Mode uses.
201
+ */
202
+ function tooltipText(t, model, state) {
203
+ if (state.busy) return t("probeRunning", { model });
204
+ if (state.failed) return t("probeTooltipRetry");
205
+ const result = state.result;
206
+ if (result === void 0) return t("probeTooltipIdle", { model });
207
+ if (result.validation === "validating" && result.efforts.length > 0) return t("probeTooltipVerified", { levels: result.efforts.join(" / ") });
208
+ if (result.validation === "non-validating") return t("probeTooltipNotValidating");
209
+ return t("probeTooltipRetry");
210
+ }
211
+ /** Model-independent shell: resolves the selection, then delegates per model. */
212
+ function WorkBuddyProbeControl({ directory, t }) {
213
+ const subscribe = (0, react.useCallback)((listener) => directory.subscribe(listener), [directory]);
214
+ const snapshot = (0, react.useCallback)(() => directory.getSnapshot(), [directory]);
215
+ const selection = (0, react.useSyncExternalStore)(subscribe, snapshot, snapshot).current;
216
+ const model = selection?.provider === "workbuddy" ? selection.model : void 0;
217
+ const label = useLabel(t);
218
+ return model === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelProbe, {
219
+ model,
220
+ label,
221
+ t
222
+ }, model);
223
+ }
224
+ function ModelProbe({ model, label, t }) {
225
+ const [status, setStatus] = (0, react.useState)();
226
+ const [busy, setBusy] = (0, react.useState)(false);
227
+ const [confirming, setConfirming] = (0, react.useState)(false);
228
+ const [tooltipVisible, setTooltipVisible] = (0, react.useState)(false);
229
+ const [failed, setFailed] = (0, react.useState)(false);
230
+ const [note, setNote] = (0, react.useState)();
231
+ const inFlight = (0, react.useRef)(false);
232
+ const mounted = (0, react.useRef)(false);
233
+ const tooltipId = (0, react.useId)();
234
+ const refresh = (0, react.useCallback)(async (signal) => {
235
+ const response = await fetch(WORKBUDDY_STATUS_PATH, {
236
+ credentials: "same-origin",
237
+ headers: { accept: "application/json" },
238
+ ...signal === void 0 ? {} : { signal }
239
+ });
240
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
241
+ const value = await response.json();
242
+ if (mounted.current && !signal?.aborted) setStatus(value);
243
+ }, []);
244
+ (0, react.useEffect)(() => {
245
+ mounted.current = true;
246
+ const controller = new AbortController();
247
+ const load = () => {
248
+ refresh(controller.signal).catch(() => {});
249
+ };
250
+ load();
251
+ const timer = window.setInterval(load, RECONCILE_MS);
252
+ window.addEventListener("focus", load);
253
+ return () => {
254
+ mounted.current = false;
255
+ controller.abort();
256
+ window.clearInterval(timer);
257
+ window.removeEventListener("focus", load);
258
+ };
259
+ }, [refresh]);
260
+ const probe = status?.status === "signed-in" ? status.probe : void 0;
261
+ const key = status?.status === "signed-in" ? status.probeKey : void 0;
262
+ const result = status === void 0 ? void 0 : resultFor(status, model);
263
+ const visible = probe?.candidates.includes(model) === true || result !== void 0;
264
+ (0, react.useEffect)(() => {
265
+ setConfirming(false);
266
+ setNote(void 0);
267
+ }, [model]);
268
+ const detect = async () => {
269
+ if (key === void 0 || inFlight.current || probe?.running === true) return;
270
+ inFlight.current = true;
271
+ setNote(void 0);
272
+ setConfirming(false);
273
+ setBusy(true);
274
+ setFailed(false);
275
+ try {
276
+ const response = await fetch(WORKBUDDY_PROBE_PATH, {
277
+ method: "POST",
278
+ credentials: "same-origin",
279
+ headers: {
280
+ "Content-Type": "application/json",
281
+ "X-WorkBuddy-Probe-Key": key
282
+ },
283
+ body: JSON.stringify({
284
+ action: "probe",
285
+ model
286
+ })
287
+ });
288
+ const body = await response.json();
289
+ if (!response.ok || body.state !== "ok" || body.validation !== "validating" && body.validation !== "non-validating" || !Array.isArray(body.efforts) || !body.efforts.every((effort) => typeof effort === "string")) throw new Error("probe failed");
290
+ if (mounted.current) {
291
+ const completed = {
292
+ id: model,
293
+ name: model,
294
+ validation: body.validation,
295
+ efforts: body.efforts,
296
+ probedAt: Date.now()
297
+ };
298
+ setNote(completed);
299
+ }
300
+ refresh().catch(() => {});
301
+ } catch {
302
+ if (mounted.current) setFailed(true);
303
+ } finally {
304
+ inFlight.current = false;
305
+ if (mounted.current) setBusy(false);
306
+ }
307
+ };
308
+ if (!visible) return null;
309
+ const text = tooltipText(t, model, {
310
+ busy,
311
+ result,
312
+ failed
313
+ });
314
+ const disabled = busy || probe?.running === true || key === void 0;
315
+ const showTooltip = tooltipVisible && !confirming && note === void 0;
316
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
317
+ style: wrapperStyle,
318
+ onMouseEnter: () => {
319
+ setTooltipVisible(true);
320
+ },
321
+ onMouseLeave: () => {
322
+ setTooltipVisible(false);
323
+ },
324
+ children: [
325
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
326
+ type: "button",
327
+ "aria-label": text,
328
+ "aria-describedby": showTooltip ? tooltipId : void 0,
329
+ "aria-busy": busy,
330
+ "aria-expanded": confirming,
331
+ disabled,
332
+ onClick: () => {
333
+ setConfirming(true);
334
+ },
335
+ onFocus: () => {
336
+ setTooltipVisible(true);
337
+ },
338
+ onBlur: () => {
339
+ setTooltipVisible(false);
340
+ },
341
+ style: {
342
+ ...buttonStyle$1,
343
+ opacity: disabled && !confirming ? .6 : 1,
344
+ cursor: disabled ? "default" : "pointer"
345
+ },
346
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
347
+ width: "16",
348
+ height: "16",
349
+ viewBox: "0 0 24 24",
350
+ fill: "none",
351
+ stroke: "currentColor",
352
+ strokeWidth: "1.6",
353
+ "aria-hidden": "true",
354
+ focusable: "false",
355
+ children: [
356
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
357
+ cx: "12",
358
+ cy: "12",
359
+ r: "9"
360
+ }),
361
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
362
+ cx: "12",
363
+ cy: "12",
364
+ r: "4"
365
+ }),
366
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 12 20 4" }),
367
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
368
+ cx: "12",
369
+ cy: "12",
370
+ r: "1"
371
+ })
372
+ ]
373
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
374
+ style: labelStyle,
375
+ children: label
376
+ })]
377
+ }),
378
+ showTooltip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
379
+ id: tooltipId,
380
+ role: "tooltip",
381
+ style: tooltipStyle,
382
+ children: text
383
+ }),
384
+ confirming && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
385
+ style: confirmStyle,
386
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("probeBubbleBody") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
387
+ style: confirmRowStyle$1,
388
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
389
+ type: "button",
390
+ style: confirmButtonStyle,
391
+ onClick: () => {
392
+ setConfirming(false);
393
+ },
394
+ children: t("cancel")
395
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
396
+ type: "button",
397
+ style: primaryButtonStyle$1,
398
+ onClick: () => {
399
+ detect();
400
+ },
401
+ children: t("probeConfirmAction")
402
+ })]
403
+ })]
404
+ }),
405
+ note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
406
+ role: "status",
407
+ "aria-live": "polite",
408
+ style: noteStyle,
409
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: noteText(t, note) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
410
+ type: "button",
411
+ style: noteDismissStyle,
412
+ onClick: () => {
413
+ setNote(void 0);
414
+ },
415
+ children: t("probeNoteDismiss")
416
+ })]
417
+ })
418
+ ]
419
+ });
420
+ }
421
+ /** Compose the one-line outcome string the note bubble shows. */
422
+ function noteText(t, result) {
423
+ if (result.validation === "validating" && result.efforts.length > 0) return t("probeNoteVerified", { levels: result.efforts.join(" / ") });
424
+ if (result.validation === "non-validating") return t("probeNoteNotValidating");
425
+ return t("probeNoteUnknown");
426
+ }
13
427
  //#endregion
14
428
  //#region src/client/WorkBuddyPluginCard.tsx
15
429
  /** WorkBuddy status card contributed to Harness Plugin configuration. */
@@ -160,6 +574,82 @@ window.__ModuleLoader__.load({
160
574
  borderRadius: 999,
161
575
  background: "var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.08))"
162
576
  };
577
+ /**
578
+ * Inline confirmation box for a paid detection. Replaces the previous
579
+ * `window.confirm`: the decision is one line plus two buttons, and a modal
580
+ * alert for that is heavier than the action it guards.
581
+ */
582
+ const confirmBoxStyle = {
583
+ display: "flex",
584
+ flexDirection: "column",
585
+ gap: 10,
586
+ padding: "10px 12px",
587
+ border: "1px solid var(--dsw-alias-border-l2)",
588
+ borderRadius: 8,
589
+ background: "var(--dsw-alias-bg-layer-1)"
590
+ };
591
+ const confirmRowStyle = {
592
+ display: "flex",
593
+ justifyContent: "flex-end",
594
+ gap: 8
595
+ };
596
+ /** One probeable model's row: name on the left, state and action on the right. */
597
+ const probeRowStyle = {
598
+ display: "flex",
599
+ alignItems: "center",
600
+ justifyContent: "space-between",
601
+ gap: 12
602
+ };
603
+ const probeRowEndStyle = {
604
+ display: "inline-flex",
605
+ alignItems: "center",
606
+ gap: 8,
607
+ flex: "0 0 auto"
608
+ };
609
+ /**
610
+ * Tab strip for the card body. Kept visually light — a full pill would compete
611
+ * with the section headings, and the card is already the densest surface the
612
+ * plugin owns.
613
+ */
614
+ const tabBarStyle = {
615
+ display: "flex",
616
+ gap: 4,
617
+ marginTop: 4,
618
+ borderBottom: "1px solid var(--dsw-alias-border-l2)"
619
+ };
620
+ const tabStyle = {
621
+ padding: "6px 12px",
622
+ border: 0,
623
+ borderBottom: "2px solid transparent",
624
+ background: "transparent",
625
+ color: "var(--dsw-alias-label-tertiary)",
626
+ font: "inherit",
627
+ fontSize: 13,
628
+ lineHeight: "20px",
629
+ cursor: "pointer"
630
+ };
631
+ const tabActiveStyle = {
632
+ borderBottom: "2px solid var(--dsw-alias-brand-primary)",
633
+ color: "var(--dsw-alias-label-primary)",
634
+ fontWeight: 600
635
+ };
636
+ const tabPanelStyle = {
637
+ display: "flex",
638
+ flexDirection: "column",
639
+ gap: 18,
640
+ paddingTop: 16
641
+ };
642
+ /**
643
+ * Primary action of the inline confirmation. Fill and text colour come from the
644
+ * theme as a pair: `brand-primary` is a light accent here, so pairing it with a
645
+ * hardcoded white would render white-on-white.
646
+ */
647
+ const primaryButtonStyle = {
648
+ ...buttonStyle,
649
+ border: "1px solid var(--dsw-alias-button-primary-fill)",
650
+ background: "var(--dsw-alias-button-primary-fill)",
651
+ color: "var(--dsw-alias-label-primary-foreground)"
652
+ };
163
653
  function progressFillStyle(percent) {
164
654
  return {
165
655
  width: `${Math.max(0, Math.min(100, percent))}%`,
@@ -245,12 +735,177 @@ window.__ModuleLoader__.load({
245
735
  })]
246
736
  });
247
737
  }
738
+ /**
739
+ * Context capacity, listed in full.
740
+ *
741
+ * Every model the upstream reports a capacity for, largest first. A one-line
742
+ * summary with the exceptions on hover was tried and rejected: capacity is
743
+ * reference data you scan by model, and hiding most of it behind a hover made
744
+ * the common case (a model you already have in mind) the hard one to look up.
745
+ *
746
+ * Purely a report of the upstream's own number. The plugin offers no tier
747
+ * picker: the catalog declares one capacity per model and publishes no
748
+ * alternatives, so a menu would mean inventing client-side policy. (The
749
+ * desktop app's "300K / 1M" selector appears nowhere in the API.)
750
+ */
751
+ function ContextTable({ models, t }) {
752
+ const known = (models ?? []).filter((model) => model.contextWindow !== void 0).sort((a, b) => b.contextWindow - a.contextWindow);
753
+ if (known.length === 0) return null;
754
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
755
+ style: quotaListStyle,
756
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
757
+ style: quotaTitleStyle,
758
+ children: t("contextHeading")
759
+ }), known.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
760
+ style: quotaLabelStyle,
761
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTokens(model.contextWindow) })]
762
+ }, model.id))]
763
+ });
764
+ }
765
+ /**
766
+ * Compact token count for display: the catalog's own round numbers (`200000`,
767
+ * `1000000`) read better as `200K` / `1M`, and no precision is lost because
768
+ * these values are always whole thousands.
769
+ */
770
+ function formatTokens(tokens) {
771
+ if (tokens >= 1e6 && tokens % 1e6 === 0) return `${tokens / 1e6}M`;
772
+ if (tokens >= 1e3 && tokens % 1e3 === 0) return `${tokens / 1e3}K`;
773
+ return String(tokens);
774
+ }
775
+ /**
776
+ * Reasoning-effort detection section: consent switches, per-model detection,
777
+ * and the recorded observations.
778
+ *
779
+ * Two deliberate UX rules from the plan (§3.1, §3.2):
780
+ * - the confirmation is shown *before* any request, and its copy states the
781
+ * credit caveat;
782
+ * - a `non-validating` result is presented as an observation about the
783
+ * parameter ("this model does not check it"), never as a statement that a
784
+ * level is unsupported.
785
+ */
786
+ function ProbeSection({ probe, t, onDetect, onClear, busy }) {
787
+ const [pending, setPending] = (0, react.useState)();
788
+ const [runningModel, setRunningModel] = (0, react.useState)();
789
+ (0, react.useEffect)(() => {
790
+ if (pending !== void 0 && !probe.candidates.includes(pending)) setPending(void 0);
791
+ }, [pending, probe.candidates]);
792
+ const runningArmed = (0, react.useRef)(false);
793
+ (0, react.useEffect)(() => {
794
+ if (runningModel === void 0) return;
795
+ if (busy || probe.running) {
796
+ runningArmed.current = true;
797
+ return;
798
+ }
799
+ if (!runningArmed.current) return;
800
+ runningArmed.current = false;
801
+ setRunningModel(void 0);
802
+ }, [
803
+ runningModel,
804
+ busy,
805
+ probe.running
806
+ ]);
807
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
808
+ style: quotaListStyle,
809
+ children: [
810
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
811
+ style: quotaTitleStyle,
812
+ children: t("probeHeading")
813
+ }),
814
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
815
+ style: bodyStyle,
816
+ children: t("probeIntro")
817
+ }),
818
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
819
+ style: bodyStyle,
820
+ children: t("probeConsentHint")
821
+ }),
822
+ probe.running ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
823
+ style: bodyStyle,
824
+ children: t("probeRunningGeneric")
825
+ }) : null,
826
+ probe.candidates.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
827
+ style: bodyStyle,
828
+ children: t("probeResultEmpty")
829
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
830
+ style: quotaGroupStyle,
831
+ children: probe.candidates.map((id) => {
832
+ const result = probe.results.find((entry) => entry.id === id);
833
+ const name = result?.name ?? id;
834
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
835
+ style: modelOfferStyle,
836
+ children: [
837
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
838
+ style: probeRowStyle,
839
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
840
+ style: probeRowEndStyle,
841
+ children: [result === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
842
+ style: modelBadgeChipStyle,
843
+ children: result.validation === "validating" && result.efforts.length > 0 ? result.efforts.join(" / ") : t(result.validation === "non-validating" ? "probeResultNotValidating" : "probeResultUnknown")
844
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
845
+ type: "button",
846
+ style: buttonStyle,
847
+ disabled: probe.running || busy,
848
+ onClick: () => {
849
+ setPending(id);
850
+ },
851
+ children: runningModel === id ? t("probeRunning", { model: id }) : t(result === void 0 ? "probeStart" : "probeRedetect")
852
+ })]
853
+ })]
854
+ }),
855
+ result === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
856
+ style: modelRateStyle,
857
+ children: t("probeResultAt", { time: formatTime(result.probedAt) })
858
+ }),
859
+ pending === id ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
860
+ style: confirmBoxStyle,
861
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
862
+ style: bodyStyle,
863
+ children: t("probeConfirmBody", { model: name })
864
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
865
+ style: confirmRowStyle,
866
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
867
+ type: "button",
868
+ style: buttonStyle,
869
+ onClick: () => {
870
+ setPending(void 0);
871
+ },
872
+ children: t("cancel")
873
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
874
+ type: "button",
875
+ style: primaryButtonStyle,
876
+ disabled: probe.running || busy,
877
+ onClick: () => {
878
+ setRunningModel(id);
879
+ setPending(void 0);
880
+ onDetect(id);
881
+ },
882
+ children: t("probeConfirmAction")
883
+ })]
884
+ })]
885
+ }) : null
886
+ ]
887
+ }, id);
888
+ })
889
+ }),
890
+ probe.results.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
891
+ type: "button",
892
+ style: buttonStyle,
893
+ disabled: busy,
894
+ onClick: () => {
895
+ onClear();
896
+ },
897
+ children: t("probeClear")
898
+ })
899
+ ]
900
+ });
901
+ }
248
902
  /** Render WorkBuddy sign-in state and credit as one expandable card. */
249
903
  function WorkBuddyPluginCard({ t }) {
250
904
  if (t === void 0) throw new Error("WorkBuddy plugin card requires its translation function");
251
905
  const [open, setOpen] = (0, react.useState)(false);
252
906
  const [status, setStatus] = (0, react.useState)({ status: "signed-out" });
253
907
  const [busy, setBusy] = (0, react.useState)(false);
908
+ const [tab, setTab] = (0, react.useState)("status");
254
909
  const mounted = (0, react.useRef)(true);
255
910
  (0, react.useEffect)(() => {
256
911
  mounted.current = true;
@@ -306,6 +961,56 @@ window.__ModuleLoader__.load({
306
961
  if (mounted.current) setBusy(false);
307
962
  }
308
963
  };
964
+ /**
965
+ * Run one control action and refresh the card's state afterwards.
966
+ *
967
+ * The key travels in a header, not the body: it authorizes the write, and
968
+ * the host never accepts a prompt, a sentinel, or a model outside its own
969
+ * catalog from here.
970
+ */
971
+ const control = (0, react.useCallback)(async (action) => {
972
+ const key = status.status === "signed-in" ? status.probeKey : void 0;
973
+ if (key === void 0) return;
974
+ setBusy(true);
975
+ try {
976
+ const response = await fetch(WORKBUDDY_PROBE_PATH, {
977
+ method: "POST",
978
+ headers: {
979
+ "Content-Type": "application/json",
980
+ "X-WorkBuddy-Probe-Key": key
981
+ },
982
+ credentials: "same-origin",
983
+ body: JSON.stringify(action)
984
+ });
985
+ const value = await response.json().catch(() => void 0);
986
+ if (!response.ok) {
987
+ const message = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
988
+ throw new Error(message);
989
+ }
990
+ await refresh();
991
+ } catch (error) {
992
+ if (mounted.current) setStatus((previous) => ({
993
+ status: "error",
994
+ message: error instanceof Error ? error.message : t("requestFailed")
995
+ }));
996
+ } finally {
997
+ if (mounted.current) setBusy(false);
998
+ }
999
+ }, [
1000
+ refresh,
1001
+ status,
1002
+ t
1003
+ ]);
1004
+ /**
1005
+ * Start a detection. Confirmation happens inline in the section, so this is
1006
+ * only ever called after the user has already agreed.
1007
+ */
1008
+ const confirmDetect = (0, react.useCallback)((modelId) => {
1009
+ control({
1010
+ action: "probe",
1011
+ model: modelId
1012
+ });
1013
+ }, [control]);
309
1014
  const title = t("title");
310
1015
  const label = status.status === "signed-in" ? status.nickname === void 0 ? t("signedInAs", { nickname: "" }).replace(/[::]\s*$/, "") : t("signedInAs", { nickname: status.nickname }) : status.status === "error" ? t("requestFailed") : t("signedOut");
311
1016
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
@@ -366,37 +1071,86 @@ window.__ModuleLoader__.load({
366
1071
  style: bodyStyle,
367
1072
  children: t("accessTokenExpires", { time: formatTime(status.expiresAt) })
368
1073
  }),
369
- status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
370
- style: quotaListStyle,
371
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
372
- style: rowStyle,
373
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
374
- style: quotaTitleStyle,
375
- children: t("creditsHeading")
376
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
377
- style: bodyStyle,
378
- children: t("creditsTotal", { total: formatNumber(status.credits.total) })
379
- })]
380
- }), status.credits.accounts.filter((account) => account.remain > 0).map((account, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CreditBar, {
381
- label: account.packageName,
382
- remain: account.remain,
383
- size: account.size,
384
- t
385
- }, `${account.packageName}-${String(index)}`))]
386
- }),
387
- status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
388
- style: errorStyle,
389
- children: t("creditsError", { message: status.creditsError })
1074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1075
+ role: "tablist",
1076
+ style: tabBarStyle,
1077
+ children: [
1078
+ "status",
1079
+ "context",
1080
+ "details"
1081
+ ].map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1082
+ type: "button",
1083
+ role: "tab",
1084
+ "aria-selected": tab === id,
1085
+ onClick: () => {
1086
+ setTab(id);
1087
+ },
1088
+ style: {
1089
+ ...tabStyle,
1090
+ ...tab === id ? tabActiveStyle : {}
1091
+ },
1092
+ children: t(id === "status" ? "tabStatus" : id === "context" ? "tabContext" : "tabDetails")
1093
+ }, id))
390
1094
  }),
391
- status.models === void 0 || status.models.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
392
- style: quotaListStyle,
393
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
394
- style: quotaTitleStyle,
395
- children: t("modelsHeading")
396
- }), status.models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelOfferRow, {
397
- model,
1095
+ tab === "status" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1096
+ style: tabPanelStyle,
1097
+ children: [
1098
+ status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1099
+ style: quotaListStyle,
1100
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1101
+ style: rowStyle,
1102
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1103
+ style: quotaTitleStyle,
1104
+ children: t("creditsHeading")
1105
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1106
+ style: bodyStyle,
1107
+ children: t("creditsTotal", { total: formatNumber(status.credits.total) })
1108
+ })]
1109
+ })
1110
+ }),
1111
+ status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1112
+ style: errorStyle,
1113
+ children: t("creditsError", { message: status.creditsError })
1114
+ }),
1115
+ status.probe === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProbeSection, {
1116
+ probe: status.probe,
1117
+ t,
1118
+ busy,
1119
+ onDetect: confirmDetect,
1120
+ onClear: () => {
1121
+ control({ action: "clear" });
1122
+ }
1123
+ })
1124
+ ]
1125
+ }) : tab === "context" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1126
+ style: tabPanelStyle,
1127
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextTable, {
1128
+ models: status.models,
398
1129
  t
399
- }, model.id))]
1130
+ })
1131
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1132
+ style: tabPanelStyle,
1133
+ children: [status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1134
+ style: quotaListStyle,
1135
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1136
+ style: quotaTitleStyle,
1137
+ children: t("creditsDetailHeading")
1138
+ }), status.credits.accounts.filter((account) => account.remain > 0).map((account, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CreditBar, {
1139
+ label: account.packageName,
1140
+ remain: account.remain,
1141
+ size: account.size,
1142
+ t
1143
+ }, `${account.packageName}-${String(index)}`))]
1144
+ }), status.models === void 0 || status.models.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1145
+ style: quotaListStyle,
1146
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1147
+ style: quotaTitleStyle,
1148
+ children: t("modelsHeading")
1149
+ }), status.models.filter((model) => model.free === true || (model.badges?.length ?? 0) > 0).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelOfferRow, {
1150
+ model,
1151
+ t
1152
+ }, model.id))]
1153
+ })]
400
1154
  })
401
1155
  ] }) : null,
402
1156
  status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -425,6 +1179,10 @@ window.__ModuleLoader__.load({
425
1179
  signedInAs: "Signed in as {nickname}",
426
1180
  accessTokenExpires: "Access token expires {time} (refresh is automatic)",
427
1181
  creditsHeading: "Remaining credit",
1182
+ tabStatus: "Status",
1183
+ tabContext: "Context window",
1184
+ tabDetails: "Credit details",
1185
+ creditsDetailHeading: "By package",
428
1186
  creditsTotal: "Total: {total}",
429
1187
  percentRemaining: "{percent}% remaining",
430
1188
  exactRemaining: "{remain} / {size} remaining",
@@ -435,10 +1193,41 @@ window.__ModuleLoader__.load({
435
1193
  requestFailed: "Request failed",
436
1194
  accountHeading: "Account",
437
1195
  modelsHeading: "Model offers",
1196
+ contextHeading: "Context window",
438
1197
  freeModel: "Free",
439
1198
  badgeLimitedFree: "Limited-time free",
440
1199
  badgeNightDiscount: "Night discount",
441
- rate: "{rate} credits per message"
1200
+ rate: "{rate} credits per message",
1201
+ probeLabel: "Reasoning levels",
1202
+ probeTooltipIdle: "Detect the reasoning levels {model} accepts",
1203
+ probeTooltipVerified: "Accepted levels: {levels} · click to detect again",
1204
+ probeTooltipNotValidating: "This model does not check the effort parameter",
1205
+ probeTooltipRetry: "Detection did not complete · click to retry",
1206
+ probeBubbleBody: "Send test requests to confirm the available reasoning levels. May consume a small amount of credit.",
1207
+ probeConfirmAction: "Confirm",
1208
+ probeNoteVerified: "Detected: {levels}",
1209
+ probeNoteNotValidating: "This model does not check the effort parameter",
1210
+ probeNoteUnknown: "Detection did not complete",
1211
+ probeNoteDismiss: "Got it",
1212
+ probeHeading: "Reasoning effort detection",
1213
+ probeResultNoLevels: "No tested levels were accepted.",
1214
+ probeIntro: "Some models reason but declare no selectable effort levels. Detecting which levels a model accepts sends a few real requests that may consume credit.",
1215
+ probeConsent: "Authorize detection",
1216
+ probeConsentHint: "Each detection sends test requests to one model to confirm its available reasoning levels, and may consume a small amount of credit.",
1217
+ probeStart: "Detect",
1218
+ probeRedetect: "Detect again",
1219
+ probeRunning: "Detecting {model}…",
1220
+ probeRunningGeneric: "Detecting…",
1221
+ probeClear: "Clear detected results",
1222
+ probeCandidates: "Detectable models: {count}",
1223
+ probeConfirmBody: "Send test requests to {model} to confirm its available reasoning levels. May consume a small amount of credit.",
1224
+ cancel: "Cancel",
1225
+ probeResultVerified: "Verified levels: {levels}",
1226
+ probeResultNotValidating: "This model does not check the effort parameter",
1227
+ probeResultUnknown: "Detection did not complete",
1228
+ probeResultAt: "Detected {time}",
1229
+ probeResultEmpty: "No detectable models right now.",
1230
+ probeFailed: "Detection failed: {message}"
442
1231
  };
443
1232
  const zh = {
444
1233
  title: "DSH WorkBuddy Connect",
@@ -451,6 +1240,10 @@ window.__ModuleLoader__.load({
451
1240
  signedInAs: "已登录:{nickname}",
452
1241
  accessTokenExpires: "访问令牌 {time} 过期(自动续期)",
453
1242
  creditsHeading: "剩余积分",
1243
+ tabStatus: "状态",
1244
+ tabContext: "上下文窗口",
1245
+ tabDetails: "积分详情",
1246
+ creditsDetailHeading: "按套餐",
454
1247
  creditsTotal: "合计:{total}",
455
1248
  percentRemaining: "剩余 {percent}%",
456
1249
  exactRemaining: "剩余 {remain} / {size}",
@@ -461,10 +1254,41 @@ window.__ModuleLoader__.load({
461
1254
  requestFailed: "请求失败",
462
1255
  accountHeading: "账号",
463
1256
  modelsHeading: "模型优惠",
1257
+ contextHeading: "上下文窗口",
464
1258
  freeModel: "免费",
465
1259
  badgeLimitedFree: "限时免费",
466
1260
  badgeNightDiscount: "夜间折扣",
467
- rate: "{rate} 积分/次"
1261
+ rate: "{rate} 积分/次",
1262
+ probeLabel: "推理等级",
1263
+ probeTooltipIdle: "检测 {model} 可用的推理档位",
1264
+ probeTooltipVerified: "已接受:{levels} · 点击可重新检测",
1265
+ probeTooltipNotValidating: "该模型不校验该参数",
1266
+ probeTooltipRetry: "检测未完成 · 点击重试",
1267
+ probeBubbleBody: "发送探测请求以确认可用推理档位。可能消耗少量积分。",
1268
+ probeConfirmAction: "确认检测",
1269
+ probeNoteVerified: "已检测:{levels}",
1270
+ probeNoteNotValidating: "该模型不校验该参数",
1271
+ probeNoteUnknown: "检测未完成",
1272
+ probeNoteDismiss: "知道了",
1273
+ probeHeading: "推理档位检测",
1274
+ probeResultNoLevels: "本次测试的档位均未被接受。",
1275
+ probeIntro: "部分模型具备思考能力,但没有声明可选档位。检测会发送少量真实请求,可能消耗积分。",
1276
+ probeConsent: "授权检测",
1277
+ probeConsentHint: "每次检测会向该模型发送探测请求,以确认可用推理档位,可能消耗少量积分。",
1278
+ probeStart: "开始检测",
1279
+ probeRedetect: "重新检测",
1280
+ probeRunning: "正在检测 {model}…",
1281
+ probeRunningGeneric: "正在检测…",
1282
+ probeClear: "清除已探测结果",
1283
+ probeCandidates: "可检测模型:{count} 个",
1284
+ probeConfirmBody: "向 {model} 发送探测请求,以确认可用推理档位。可能消耗少量积分。",
1285
+ cancel: "取消",
1286
+ probeResultVerified: "已验证接受的档位:{levels}",
1287
+ probeResultNotValidating: "该模型不校验该参数",
1288
+ probeResultUnknown: "检测未完成",
1289
+ probeResultAt: "检测于 {time}",
1290
+ probeResultEmpty: "当前没有可检测的模型。",
1291
+ probeFailed: "检测失败:{message}"
468
1292
  };
469
1293
  //#endregion
470
1294
  //#region src/client/index.tsx
@@ -515,6 +1339,17 @@ window.__ModuleLoader__.load({
515
1339
  priority: 30,
516
1340
  inject: () => ({ t })
517
1341
  }, WorkBuddyPluginCard));
1342
+ ctx.inject(["modelDirectories"], (scope) => {
1343
+ scope.slots.inject("conversation.input.right", () => scope.slots.register({
1344
+ name: "conversation.input.right",
1345
+ id: "workbuddy-probe",
1346
+ order: 10,
1347
+ inject: (sessionId) => ({
1348
+ directory: scope.modelDirectories.directoryFor(sessionId).store,
1349
+ t
1350
+ })
1351
+ }, WorkBuddyProbeControl));
1352
+ });
518
1353
  } catch (error) {
519
1354
  console.error("[dsh-workbuddy-connect] client card failed to load (host provider unaffected):", error);
520
1355
  }