dsh-workbuddy-connect 0.3.2 → 0.5.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,9 +10,47 @@ 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
+ /**
24
+ * The international (WorkBuddy AI) variant's own pair of routes.
25
+ *
26
+ * Kept as separate constants rather than a computed suffix so both halves
27
+ * reference literal strings: the browser bundle and the host bundle are built
28
+ * independently, and a shared expression is one build-config drift away from
29
+ * the desk asking a route the host never mounted.
30
+ */
31
+ const WORKBUDDY_AI_STATUS_PATH = "/plugins/dsh-workbuddy-connect/ai/status";
32
+ const WORKBUDDY_AI_PROBE_PATH = "/plugins/dsh-workbuddy-connect/ai/probe";
13
33
  //#endregion
14
34
  //#region src/client/WorkBuddyPluginCard.tsx
15
35
  /** WorkBuddy status card contributed to Harness Plugin configuration. */
36
+ /** CN WorkBuddy; the plugin's long-standing card and default. */
37
+ const CN_CARD_VARIANT = {
38
+ id: "workbuddy",
39
+ titleKey: "title",
40
+ introKey: "intro",
41
+ signedOutKey: "signedOutHint",
42
+ statusPath: WORKBUDDY_STATUS_PATH,
43
+ probePath: WORKBUDDY_PROBE_PATH
44
+ };
45
+ /** Both cards, in display order. */
46
+ const CARD_VARIANTS = [CN_CARD_VARIANT, {
47
+ id: "workbuddy-ai",
48
+ titleKey: "titleAI",
49
+ introKey: "introAI",
50
+ signedOutKey: "signedOutHintAI",
51
+ statusPath: WORKBUDDY_AI_STATUS_PATH,
52
+ probePath: WORKBUDDY_AI_PROBE_PATH
53
+ }];
16
54
  const POLL_INTERVAL_MS = 6e4;
17
55
  const cardStyle = {
18
56
  overflow: "hidden",
@@ -82,7 +120,7 @@ window.__ModuleLoader__.load({
82
120
  fontWeight: 500,
83
121
  color: "var(--dsw-alias-label-primary)"
84
122
  };
85
- const buttonStyle = {
123
+ const buttonStyle$1 = {
86
124
  boxSizing: "border-box",
87
125
  minHeight: 34,
88
126
  padding: "6px 14px",
@@ -148,10 +186,19 @@ window.__ModuleLoader__.load({
148
186
  background: "var(--dsw-alias-state-success-subtle, rgba(34, 160, 107, 0.12))",
149
187
  color: "var(--dsw-alias-state-success-primary, #22a06b)"
150
188
  };
151
- /** Localize an upstream promotional badge label, with an unknown-badge fallback. */
189
+ /**
190
+ * Localize an upstream promotional badge label, with an unknown-badge fallback.
191
+ *
192
+ * The CN catalog spells badges in Chinese (`限时免费`, `夜间折扣`); the
193
+ * international document's `modelPromotions` carries English (`Free now`). Both
194
+ * are mapped so the same promotion reads consistently in either UI language,
195
+ * and anything else passes through verbatim — an unrecognized badge is still
196
+ * information the upstream chose to show.
197
+ */
152
198
  function modelBadgeLabel(badge, t) {
153
199
  if (badge === "限时免费") return t("badgeLimitedFree");
154
200
  if (badge === "夜间折扣") return t("badgeNightDiscount");
201
+ if (badge === "Free now") return t("badgeFreeNow");
155
202
  return badge;
156
203
  }
157
204
  const progressTrackStyle = {
@@ -160,6 +207,82 @@ window.__ModuleLoader__.load({
160
207
  borderRadius: 999,
161
208
  background: "var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.08))"
162
209
  };
210
+ /**
211
+ * Inline confirmation box for a paid detection. Replaces the previous
212
+ * `window.confirm`: the decision is one line plus two buttons, and a modal
213
+ * alert for that is heavier than the action it guards.
214
+ */
215
+ const confirmBoxStyle = {
216
+ display: "flex",
217
+ flexDirection: "column",
218
+ gap: 10,
219
+ padding: "10px 12px",
220
+ border: "1px solid var(--dsw-alias-border-l2)",
221
+ borderRadius: 8,
222
+ background: "var(--dsw-alias-bg-layer-1)"
223
+ };
224
+ const confirmRowStyle$1 = {
225
+ display: "flex",
226
+ justifyContent: "flex-end",
227
+ gap: 8
228
+ };
229
+ /** One probeable model's row: name on the left, state and action on the right. */
230
+ const probeRowStyle = {
231
+ display: "flex",
232
+ alignItems: "center",
233
+ justifyContent: "space-between",
234
+ gap: 12
235
+ };
236
+ const probeRowEndStyle = {
237
+ display: "inline-flex",
238
+ alignItems: "center",
239
+ gap: 8,
240
+ flex: "0 0 auto"
241
+ };
242
+ /**
243
+ * Tab strip for the card body. Kept visually light — a full pill would compete
244
+ * with the section headings, and the card is already the densest surface the
245
+ * plugin owns.
246
+ */
247
+ const tabBarStyle = {
248
+ display: "flex",
249
+ gap: 4,
250
+ marginTop: 4,
251
+ borderBottom: "1px solid var(--dsw-alias-border-l2)"
252
+ };
253
+ const tabStyle = {
254
+ padding: "6px 12px",
255
+ border: 0,
256
+ borderBottom: "2px solid transparent",
257
+ background: "transparent",
258
+ color: "var(--dsw-alias-label-tertiary)",
259
+ font: "inherit",
260
+ fontSize: 13,
261
+ lineHeight: "20px",
262
+ cursor: "pointer"
263
+ };
264
+ const tabActiveStyle = {
265
+ borderBottom: "2px solid var(--dsw-alias-brand-primary)",
266
+ color: "var(--dsw-alias-label-primary)",
267
+ fontWeight: 600
268
+ };
269
+ const tabPanelStyle = {
270
+ display: "flex",
271
+ flexDirection: "column",
272
+ gap: 18,
273
+ paddingTop: 16
274
+ };
275
+ /**
276
+ * Primary action of the inline confirmation. Fill and text colour come from the
277
+ * theme as a pair: `brand-primary` is a light accent here, so pairing it with a
278
+ * hardcoded white would render white-on-white.
279
+ */
280
+ const primaryButtonStyle$1 = {
281
+ ...buttonStyle$1,
282
+ border: "1px solid var(--dsw-alias-button-primary-fill)",
283
+ background: "var(--dsw-alias-button-primary-fill)",
284
+ color: "var(--dsw-alias-label-primary-foreground)"
285
+ };
163
286
  function progressFillStyle(percent) {
164
287
  return {
165
288
  width: `${Math.max(0, Math.min(100, percent))}%`,
@@ -239,18 +362,202 @@ window.__ModuleLoader__.load({
239
362
  children: t("freeModel")
240
363
  }) : null]
241
364
  })]
242
- }), model.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
365
+ }), model.credits === void 0 ? model.rateUnknown === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
366
+ style: modelRateStyle,
367
+ children: t("rateUnknown")
368
+ }) : null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
243
369
  style: modelRateStyle,
244
370
  children: t("rate", { rate: model.credits })
245
371
  })]
246
372
  });
247
373
  }
374
+ /**
375
+ * Context capacity, listed in full.
376
+ *
377
+ * Every model the upstream reports a capacity for, largest first. A one-line
378
+ * summary with the exceptions on hover was tried and rejected: capacity is
379
+ * reference data you scan by model, and hiding most of it behind a hover made
380
+ * the common case (a model you already have in mind) the hard one to look up.
381
+ *
382
+ * Purely a report of the upstream's own numbers. The plugin offers no tier
383
+ * picker: the CN catalog declares one capacity per model and publishes no
384
+ * alternatives, so a menu there would mean inventing client-side policy. The
385
+ * international document does declare alternatives (`supportedLengths`), and
386
+ * they are shown as a secondary figure rather than merged into one number —
387
+ * the default is the budget actually requested, while the larger value is a
388
+ * ceiling the upstream would accept.
389
+ */
390
+ function ContextTable({ models, t }) {
391
+ const known = (models ?? []).filter((model) => model.contextWindow !== void 0).sort((a, b) => b.contextWindow - a.contextWindow);
392
+ if (known.length === 0) return null;
393
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
394
+ style: quotaListStyle,
395
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
396
+ style: quotaTitleStyle,
397
+ children: t("contextHeading")
398
+ }), known.map((model) => {
399
+ const capacity = model.contextWindow;
400
+ const alternative = model.maxContextWindow !== void 0 && model.maxContextWindow > capacity ? model.maxContextWindow : void 0;
401
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
402
+ style: quotaLabelStyle,
403
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
404
+ style: modelOfferStyle,
405
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
406
+ style: { textAlign: "right" },
407
+ children: formatTokens(capacity)
408
+ }), alternative === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
409
+ style: modelRateStyle,
410
+ children: t("contextUpTo", { size: formatTokens(alternative) })
411
+ })]
412
+ })]
413
+ }, model.id);
414
+ })]
415
+ });
416
+ }
417
+ /**
418
+ * Compact token count for display: the catalog's own round numbers (`200000`,
419
+ * `1000000`) read better as `200K` / `1M`, and no precision is lost because
420
+ * these values are always whole thousands.
421
+ */
422
+ function formatTokens(tokens) {
423
+ if (tokens >= 1e6 && tokens % 1e6 === 0) return `${tokens / 1e6}M`;
424
+ if (tokens >= 1e3 && tokens % 1e3 === 0) return `${tokens / 1e3}K`;
425
+ return String(tokens);
426
+ }
427
+ /**
428
+ * Reasoning-effort detection section: consent switches, per-model detection,
429
+ * and the recorded observations.
430
+ *
431
+ * Two deliberate UX rules from the plan (§3.1, §3.2):
432
+ * - the confirmation is shown *before* any request, and its copy states the
433
+ * credit caveat;
434
+ * - a `non-validating` result is presented as an observation about the
435
+ * parameter ("this model does not check it"), never as a statement that a
436
+ * level is unsupported.
437
+ */
438
+ function ProbeSection({ probe, t, onDetect, onClear, busy }) {
439
+ const [pending, setPending] = (0, react.useState)();
440
+ const [runningModel, setRunningModel] = (0, react.useState)();
441
+ (0, react.useEffect)(() => {
442
+ if (pending !== void 0 && !probe.candidates.includes(pending)) setPending(void 0);
443
+ }, [pending, probe.candidates]);
444
+ const runningArmed = (0, react.useRef)(false);
445
+ (0, react.useEffect)(() => {
446
+ if (runningModel === void 0) return;
447
+ if (busy || probe.running) {
448
+ runningArmed.current = true;
449
+ return;
450
+ }
451
+ if (!runningArmed.current) return;
452
+ runningArmed.current = false;
453
+ setRunningModel(void 0);
454
+ }, [
455
+ runningModel,
456
+ busy,
457
+ probe.running
458
+ ]);
459
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
460
+ style: quotaListStyle,
461
+ children: [
462
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
463
+ style: quotaTitleStyle,
464
+ children: t("probeHeading")
465
+ }),
466
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
467
+ style: bodyStyle,
468
+ children: t("probeIntro")
469
+ }),
470
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
471
+ style: bodyStyle,
472
+ children: t("probeConsentHint")
473
+ }),
474
+ probe.running ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
475
+ style: bodyStyle,
476
+ children: t("probeRunningGeneric")
477
+ }) : null,
478
+ probe.candidates.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
479
+ style: bodyStyle,
480
+ children: t("probeResultEmpty")
481
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
482
+ style: quotaGroupStyle,
483
+ children: probe.candidates.map((id) => {
484
+ const result = probe.results.find((entry) => entry.id === id);
485
+ const name = result?.name ?? id;
486
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
487
+ style: modelOfferStyle,
488
+ children: [
489
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
490
+ style: probeRowStyle,
491
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
492
+ style: probeRowEndStyle,
493
+ children: [result === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
494
+ style: modelBadgeChipStyle,
495
+ children: result.validation === "validating" && result.efforts.length > 0 ? result.efforts.join(" / ") : t(result.validation === "non-validating" ? "probeResultNotValidating" : "probeResultUnknown")
496
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
497
+ type: "button",
498
+ style: buttonStyle$1,
499
+ disabled: probe.running || busy,
500
+ onClick: () => {
501
+ setPending(id);
502
+ },
503
+ children: runningModel === id ? t("probeRunning", { model: id }) : t(result === void 0 ? "probeStart" : "probeRedetect")
504
+ })]
505
+ })]
506
+ }),
507
+ result === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
508
+ style: modelRateStyle,
509
+ children: t("probeResultAt", { time: formatTime(result.probedAt) })
510
+ }),
511
+ pending === id ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
512
+ style: confirmBoxStyle,
513
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
514
+ style: bodyStyle,
515
+ children: t("probeConfirmBody", { model: name })
516
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
517
+ style: confirmRowStyle$1,
518
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
519
+ type: "button",
520
+ style: buttonStyle$1,
521
+ onClick: () => {
522
+ setPending(void 0);
523
+ },
524
+ children: t("cancel")
525
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
526
+ type: "button",
527
+ style: primaryButtonStyle$1,
528
+ disabled: probe.running || busy,
529
+ onClick: () => {
530
+ setRunningModel(id);
531
+ setPending(void 0);
532
+ onDetect(id);
533
+ },
534
+ children: t("probeConfirmAction")
535
+ })]
536
+ })]
537
+ }) : null
538
+ ]
539
+ }, id);
540
+ })
541
+ }),
542
+ probe.results.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
543
+ type: "button",
544
+ style: buttonStyle$1,
545
+ disabled: busy,
546
+ onClick: () => {
547
+ onClear();
548
+ },
549
+ children: t("probeClear")
550
+ })
551
+ ]
552
+ });
553
+ }
248
554
  /** Render WorkBuddy sign-in state and credit as one expandable card. */
249
- function WorkBuddyPluginCard({ t }) {
555
+ function WorkBuddyPluginCard({ t, variant = CN_CARD_VARIANT }) {
250
556
  if (t === void 0) throw new Error("WorkBuddy plugin card requires its translation function");
251
557
  const [open, setOpen] = (0, react.useState)(false);
252
558
  const [status, setStatus] = (0, react.useState)({ status: "signed-out" });
253
559
  const [busy, setBusy] = (0, react.useState)(false);
560
+ const [tab, setTab] = (0, react.useState)("status");
254
561
  const mounted = (0, react.useRef)(true);
255
562
  (0, react.useEffect)(() => {
256
563
  mounted.current = true;
@@ -260,7 +567,7 @@ window.__ModuleLoader__.load({
260
567
  }, []);
261
568
  const refresh = (0, react.useCallback)(async (signal) => {
262
569
  try {
263
- const response = await fetch(WORKBUDDY_STATUS_PATH, {
570
+ const response = await fetch(variant.statusPath, {
264
571
  headers: { accept: "application/json" },
265
572
  credentials: "same-origin",
266
573
  ...signal === void 0 ? {} : { signal }
@@ -274,7 +581,7 @@ window.__ModuleLoader__.load({
274
581
  message: error instanceof Error ? error.message : t("requestFailed")
275
582
  });
276
583
  }
277
- }, [t]);
584
+ }, [t, variant.statusPath]);
278
585
  (0, react.useEffect)(() => {
279
586
  if (!open) return;
280
587
  const controller = new AbortController();
@@ -306,8 +613,98 @@ window.__ModuleLoader__.load({
306
613
  if (mounted.current) setBusy(false);
307
614
  }
308
615
  };
309
- const title = t("title");
310
- 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");
616
+ /**
617
+ * Ask the host to re-read the credential and re-fetch this variant's catalog.
618
+ *
619
+ * Shares the probe route's key and guards: it is a write that spends an
620
+ * upstream request, so it does not belong on the read-only status GET. A
621
+ * failure is surfaced through the refreshed document's `catalog.error` rather
622
+ * than thrown away, so the reason survives the round trip.
623
+ */
624
+ const refreshModels = (0, react.useCallback)(async () => {
625
+ const key = status.status === "signed-in" ? status.probeKey : void 0;
626
+ if (key === void 0) return;
627
+ setBusy(true);
628
+ try {
629
+ const response = await fetch(variant.probePath, {
630
+ method: "POST",
631
+ headers: {
632
+ "Content-Type": "application/json",
633
+ "X-WorkBuddy-Probe-Key": key
634
+ },
635
+ credentials: "same-origin",
636
+ body: JSON.stringify({ action: "refresh" })
637
+ });
638
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
639
+ } catch (error) {
640
+ if (mounted.current) setStatus((previous) => ({
641
+ status: "error",
642
+ message: error instanceof Error ? error.message : t("requestFailed")
643
+ }));
644
+ return;
645
+ } finally {
646
+ if (mounted.current) setBusy(false);
647
+ }
648
+ await refresh();
649
+ }, [
650
+ refresh,
651
+ status,
652
+ t,
653
+ variant.probePath
654
+ ]);
655
+ /**
656
+ * Run one control action and refresh the card's state afterwards.
657
+ *
658
+ * The key travels in a header, not the body: it authorizes the write, and
659
+ * the host never accepts a prompt, a sentinel, or a model outside its own
660
+ * catalog from here.
661
+ */
662
+ const control = (0, react.useCallback)(async (action) => {
663
+ const key = status.status === "signed-in" ? status.probeKey : void 0;
664
+ if (key === void 0) return;
665
+ setBusy(true);
666
+ try {
667
+ const response = await fetch(variant.probePath, {
668
+ method: "POST",
669
+ headers: {
670
+ "Content-Type": "application/json",
671
+ "X-WorkBuddy-Probe-Key": key
672
+ },
673
+ credentials: "same-origin",
674
+ body: JSON.stringify(action)
675
+ });
676
+ const value = await response.json().catch(() => void 0);
677
+ if (!response.ok) {
678
+ const message = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
679
+ throw new Error(message);
680
+ }
681
+ await refresh();
682
+ } catch (error) {
683
+ if (mounted.current) setStatus((previous) => ({
684
+ status: "error",
685
+ message: error instanceof Error ? error.message : t("requestFailed")
686
+ }));
687
+ } finally {
688
+ if (mounted.current) setBusy(false);
689
+ }
690
+ }, [
691
+ refresh,
692
+ status,
693
+ t,
694
+ variant.probePath
695
+ ]);
696
+ /**
697
+ * Start a detection. Confirmation happens inline in the section, so this is
698
+ * only ever called after the user has already agreed.
699
+ */
700
+ const confirmDetect = (0, react.useCallback)((modelId) => {
701
+ control({
702
+ action: "probe",
703
+ model: modelId
704
+ });
705
+ }, [control]);
706
+ const title = t(variant.titleKey);
707
+ const label = 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");
311
708
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
312
709
  style: cardStyle,
313
710
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -325,7 +722,7 @@ window.__ModuleLoader__.load({
325
722
  children: title
326
723
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
327
724
  style: descriptionStyle,
328
- children: t("intro")
725
+ children: t(variant.introKey)
329
726
  })]
330
727
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
331
728
  "aria-hidden": "true",
@@ -353,7 +750,7 @@ window.__ModuleLoader__.load({
353
750
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
354
751
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
355
752
  type: "button",
356
- style: buttonStyle,
753
+ style: buttonStyle$1,
357
754
  disabled: busy,
358
755
  onClick: () => {
359
756
  manualRefresh();
@@ -366,42 +763,110 @@ window.__ModuleLoader__.load({
366
763
  style: bodyStyle,
367
764
  children: t("accessTokenExpires", { time: formatTime(status.expiresAt) })
368
765
  }),
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)}`))]
766
+ status.catalog === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
767
+ style: rowStyle,
768
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
769
+ style: bodyStyle,
770
+ children: [status.catalog.source === "live" && status.catalog.fetchedAt !== void 0 ? t("catalogLive", { time: formatTime(status.catalog.fetchedAt) }) : status.catalog.source === "saved" && status.catalog.fetchedAt !== void 0 ? t("catalogSaved", { time: formatTime(status.catalog.fetchedAt) }) : t("catalogFallback"), status.catalog.appVersion === void 0 ? "" : ` · ${t("catalogAppVersion", { version: status.catalog.appVersion })}`]
771
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
772
+ type: "button",
773
+ style: buttonStyle$1,
774
+ disabled: busy,
775
+ onClick: () => {
776
+ refreshModels();
777
+ },
778
+ children: busy ? t("refreshingModels") : t("refreshModels")
779
+ })]
386
780
  }),
387
- status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
781
+ status.catalog?.error === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
388
782
  style: errorStyle,
389
- children: t("creditsError", { message: status.creditsError })
783
+ children: t("catalogError", { message: status.catalog.error })
390
784
  }),
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,
785
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
786
+ role: "tablist",
787
+ style: tabBarStyle,
788
+ children: [
789
+ "status",
790
+ "context",
791
+ "details"
792
+ ].map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
793
+ type: "button",
794
+ role: "tab",
795
+ "aria-selected": tab === id,
796
+ onClick: () => {
797
+ setTab(id);
798
+ },
799
+ style: {
800
+ ...tabStyle,
801
+ ...tab === id ? tabActiveStyle : {}
802
+ },
803
+ children: t(id === "status" ? "tabStatus" : id === "context" ? "tabContext" : "tabDetails")
804
+ }, id))
805
+ }),
806
+ tab === "status" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
807
+ style: tabPanelStyle,
808
+ children: [
809
+ status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
810
+ style: quotaListStyle,
811
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
812
+ style: rowStyle,
813
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
814
+ style: quotaTitleStyle,
815
+ children: t("creditsHeading")
816
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
817
+ style: bodyStyle,
818
+ children: t("creditsTotal", { total: formatNumber(status.credits.total) })
819
+ })]
820
+ })
821
+ }),
822
+ status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
823
+ style: errorStyle,
824
+ children: t("creditsError", { message: status.creditsError })
825
+ }),
826
+ status.probe === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProbeSection, {
827
+ probe: status.probe,
828
+ t,
829
+ busy,
830
+ onDetect: confirmDetect,
831
+ onClear: () => {
832
+ control({ action: "clear" });
833
+ }
834
+ })
835
+ ]
836
+ }) : tab === "context" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
837
+ style: tabPanelStyle,
838
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextTable, {
839
+ models: status.models,
398
840
  t
399
- }, model.id))]
841
+ })
842
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
843
+ style: tabPanelStyle,
844
+ children: [status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
845
+ style: quotaListStyle,
846
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
847
+ style: quotaTitleStyle,
848
+ children: t("creditsDetailHeading")
849
+ }), status.credits.accounts.filter((account) => account.remain > 0).map((account, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CreditBar, {
850
+ label: account.packageName,
851
+ remain: account.remain,
852
+ size: account.size,
853
+ t
854
+ }, `${account.packageName}-${String(index)}`))]
855
+ }), status.models === void 0 || status.models.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
856
+ style: quotaListStyle,
857
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
858
+ style: quotaTitleStyle,
859
+ children: t("modelsHeading")
860
+ }), status.models.filter((model) => model.free === true || (model.badges?.length ?? 0) > 0).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelOfferRow, {
861
+ model,
862
+ t
863
+ }, model.id))]
864
+ })]
400
865
  })
401
866
  ] }) : null,
402
867
  status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
403
- style: bodyStyle,
404
- children: t("signedOutHint")
868
+ style: status.reason === void 0 ? bodyStyle : errorStyle,
869
+ children: status.reason ?? t(variant.signedOutKey)
405
870
  }) : null,
406
871
  status.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
407
872
  style: errorStyle,
@@ -412,19 +877,441 @@ window.__ModuleLoader__.load({
412
877
  });
413
878
  }
414
879
  //#endregion
880
+ //#region src/client/WorkBuddyProbeControl.tsx
881
+ /**
882
+ * Per-model reasoning-effort entry beside the Composer's model selector.
883
+ *
884
+ * Interaction follows the Fast Mode control `dsh-codex-connect` ships in this
885
+ * same seat, which is the established shape for composer chrome here:
886
+ *
887
+ * - a **static inline label** next to the icon names the feature ("Reasoning
888
+ * levels"), set smaller and dimmer than the surrounding chrome so it reads as
889
+ * an annotation on the icon. It never carries state: the verified levels
890
+ * already appear in the model dropdown (the adapter exposes them as
891
+ * selectable efforts), so repeating them here would duplicate the real answer
892
+ * and make the label's width jump as results change.
893
+ * - a **hover/focus tooltip** carries the state and the click's purpose, the way
894
+ * Fast Mode's tooltip explains its current speed.
895
+ * - the **confirmation** is a small bubble anchored to the control, not a
896
+ * `window.confirm`. Probing spends real credit, so a confirmation stays — but
897
+ * it belongs next to the thing it acts on, sized to one line plus two small
898
+ * buttons.
899
+ *
900
+ * @module dsh-workbuddy-connect/client/probe-control
901
+ */
902
+ /**
903
+ * The card (and therefore the routes) a selected provider belongs to.
904
+ *
905
+ * The control serves both WorkBuddy providers from one seat, so the provider id
906
+ * is what selects the status and probe endpoints. Returning `undefined` for any
907
+ * other provider is what keeps the icon off every non-WorkBuddy model.
908
+ */
909
+ function cardVariantFor(provider) {
910
+ return CARD_VARIANTS.find((card) => card.id === provider);
911
+ }
912
+ /** How often the control re-checks state when the window regains focus. */
913
+ const RECONCILE_MS = 6e4;
914
+ const wrapperStyle = {
915
+ display: "inline-flex",
916
+ position: "relative",
917
+ alignItems: "center",
918
+ transform: "translateY(2px)",
919
+ marginRight: -8
920
+ };
921
+ const buttonStyle = {
922
+ display: "inline-flex",
923
+ alignItems: "center",
924
+ justifyContent: "center",
925
+ gap: 2,
926
+ height: 30,
927
+ padding: "0 6px",
928
+ border: 0,
929
+ borderRadius: 8,
930
+ background: "transparent",
931
+ color: "var(--dsw-alias-label-secondary)",
932
+ font: "inherit",
933
+ whiteSpace: "nowrap",
934
+ cursor: "pointer"
935
+ };
936
+ /**
937
+ * The inline label. Smaller and dimmer than the surrounding chrome on purpose:
938
+ * it names the feature, so it should read as an annotation attached to the icon
939
+ * rather than compete with the adjacent model selector.
940
+ */
941
+ const labelStyle = {
942
+ fontSize: 11,
943
+ lineHeight: "16px",
944
+ color: "var(--dsw-alias-label-tertiary)"
945
+ };
946
+ /** Tooltip bubble: the Fast Mode shape (nowrap, one line, above the control). */
947
+ const tooltipStyle = {
948
+ position: "absolute",
949
+ left: "50%",
950
+ bottom: "calc(100% + 8px)",
951
+ zIndex: 1e3,
952
+ transform: "translateX(-50%)",
953
+ padding: "4px 8px",
954
+ borderRadius: 6,
955
+ background: "var(--dsw-specific-tip, #1f2329)",
956
+ boxShadow: "var(--dsw-shadow-lv2)",
957
+ color: "var(--dsw-alias-label-primary, #fff)",
958
+ fontSize: 12,
959
+ lineHeight: "18px",
960
+ whiteSpace: "nowrap",
961
+ pointerEvents: "none"
962
+ };
963
+ /** Confirmation bubble: same anchor, but interactive and allowed to wrap. */
964
+ const confirmStyle = {
965
+ position: "absolute",
966
+ right: 0,
967
+ bottom: "calc(100% + 8px)",
968
+ zIndex: 1001,
969
+ display: "flex",
970
+ flexDirection: "column",
971
+ gap: 8,
972
+ width: 260,
973
+ padding: "10px 12px",
974
+ border: "1px solid var(--dsw-alias-border-l2)",
975
+ borderRadius: 8,
976
+ background: "var(--dsw-alias-bg-layer-1, #fff)",
977
+ boxShadow: "var(--dsw-shadow-lv2)",
978
+ color: "var(--dsw-alias-label-primary)",
979
+ fontSize: 12,
980
+ lineHeight: "18px"
981
+ };
982
+ const confirmRowStyle = {
983
+ display: "flex",
984
+ justifyContent: "flex-end",
985
+ gap: 8
986
+ };
987
+ const confirmButtonStyle = {
988
+ padding: "3px 10px",
989
+ border: "1px solid var(--dsw-alias-border-l2)",
990
+ borderRadius: 6,
991
+ background: "transparent",
992
+ color: "inherit",
993
+ font: "inherit",
994
+ fontSize: 12,
995
+ cursor: "pointer"
996
+ };
997
+ /**
998
+ * Primary action inside the confirmation bubble.
999
+ *
1000
+ * The fill and its text colour must come as a pair: `brand-primary` resolves to
1001
+ * a light accent in this theme, so hardcoding `color: #fff` on top of it renders
1002
+ * white-on-white. `button-primary-fill` + `label-primary-foreground` is the
1003
+ * theme's own pair for exactly this, and is what `dsh-codex-connect` uses for
1004
+ * the same job.
1005
+ */
1006
+ const primaryButtonStyle = {
1007
+ ...confirmButtonStyle,
1008
+ border: "1px solid var(--dsw-alias-button-primary-fill)",
1009
+ background: "var(--dsw-alias-button-primary-fill)",
1010
+ color: "var(--dsw-alias-label-primary-foreground)"
1011
+ };
1012
+ /**
1013
+ * Result note: a single line + a dismiss button, anchored to the control's
1014
+ * right side. Smaller than the confirmation bubble because it carries an
1015
+ * *outcome*, not a *decision* — the work is done, the user only has to read
1016
+ * and dismiss.
1017
+ */
1018
+ const noteStyle = {
1019
+ position: "absolute",
1020
+ right: 0,
1021
+ bottom: "calc(100% + 8px)",
1022
+ zIndex: 1001,
1023
+ display: "flex",
1024
+ alignItems: "center",
1025
+ gap: 12,
1026
+ padding: "6px 10px",
1027
+ border: "1px solid var(--dsw-alias-border-l2)",
1028
+ borderRadius: 8,
1029
+ background: "var(--dsw-alias-bg-layer-1)",
1030
+ boxShadow: "var(--dsw-shadow-lv2)",
1031
+ color: "var(--dsw-alias-label-primary)",
1032
+ fontSize: 12,
1033
+ lineHeight: "18px",
1034
+ whiteSpace: "nowrap"
1035
+ };
1036
+ /**
1037
+ * The note's dismiss action. Outlined rather than bare text: inside an already
1038
+ * bordered bubble, an unbordered word does not read as something you can click.
1039
+ * Matches the outlined pill convention the plugin's other secondary actions use.
1040
+ */
1041
+ const noteDismissStyle = {
1042
+ padding: "2px 8px",
1043
+ border: "1px solid var(--dsw-alias-border-l2)",
1044
+ borderRadius: 6,
1045
+ background: "transparent",
1046
+ color: "var(--dsw-alias-label-secondary)",
1047
+ font: "inherit",
1048
+ fontSize: 12,
1049
+ lineHeight: "18px",
1050
+ cursor: "pointer"
1051
+ };
1052
+ /**
1053
+ * The feature's static inline label. Deliberately not a state readout — see the
1054
+ * module comment.
1055
+ */
1056
+ function useLabel(t) {
1057
+ return t("probeLabel");
1058
+ }
1059
+ /** Pick the model's recorded observation out of the probe section. */
1060
+ function resultFor(status, model) {
1061
+ if (status.status !== "signed-in") return void 0;
1062
+ return status.probe?.results.find((result) => result.id === model);
1063
+ }
1064
+ /**
1065
+ * The one-line tooltip: current state first, then what a click does — the same
1066
+ * two-part shape Fast Mode uses.
1067
+ */
1068
+ function tooltipText(t, model, state) {
1069
+ if (state.busy) return t("probeRunning", { model });
1070
+ if (state.failed) return t("probeTooltipRetry");
1071
+ const result = state.result;
1072
+ if (result === void 0) return t("probeTooltipIdle", { model });
1073
+ if (result.validation === "validating" && result.efforts.length > 0) return t("probeTooltipVerified", { levels: result.efforts.join(" / ") });
1074
+ if (result.validation === "non-validating") return t("probeTooltipNotValidating");
1075
+ return t("probeTooltipRetry");
1076
+ }
1077
+ /** Model-independent shell: resolves the selection, then delegates per model. */
1078
+ function WorkBuddyProbeControl({ directory, t }) {
1079
+ const subscribe = (0, react.useCallback)((listener) => directory.subscribe(listener), [directory]);
1080
+ const snapshot = (0, react.useCallback)(() => directory.getSnapshot(), [directory]);
1081
+ const selection = (0, react.useSyncExternalStore)(subscribe, snapshot, snapshot).current;
1082
+ const card = selection === void 0 ? void 0 : cardVariantFor(selection.provider);
1083
+ const key = card === void 0 || selection === void 0 ? void 0 : `${card.id}:${selection.model}`;
1084
+ return card === void 0 || selection === void 0 || key === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelProbe, {
1085
+ model: selection.model,
1086
+ card,
1087
+ label: useLabel(t),
1088
+ t
1089
+ }, key);
1090
+ }
1091
+ function ModelProbe({ model, card, label, t }) {
1092
+ const [status, setStatus] = (0, react.useState)();
1093
+ const [busy, setBusy] = (0, react.useState)(false);
1094
+ const [confirming, setConfirming] = (0, react.useState)(false);
1095
+ const [tooltipVisible, setTooltipVisible] = (0, react.useState)(false);
1096
+ const [failed, setFailed] = (0, react.useState)(false);
1097
+ const [note, setNote] = (0, react.useState)();
1098
+ const inFlight = (0, react.useRef)(false);
1099
+ const mounted = (0, react.useRef)(false);
1100
+ const tooltipId = (0, react.useId)();
1101
+ const refresh = (0, react.useCallback)(async (signal) => {
1102
+ const response = await fetch(card.statusPath, {
1103
+ credentials: "same-origin",
1104
+ headers: { accept: "application/json" },
1105
+ ...signal === void 0 ? {} : { signal }
1106
+ });
1107
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1108
+ const value = await response.json();
1109
+ if (mounted.current && !signal?.aborted) setStatus(value);
1110
+ }, [card.statusPath]);
1111
+ (0, react.useEffect)(() => {
1112
+ mounted.current = true;
1113
+ const controller = new AbortController();
1114
+ const load = () => {
1115
+ refresh(controller.signal).catch(() => {});
1116
+ };
1117
+ load();
1118
+ const timer = window.setInterval(load, RECONCILE_MS);
1119
+ window.addEventListener("focus", load);
1120
+ return () => {
1121
+ mounted.current = false;
1122
+ controller.abort();
1123
+ window.clearInterval(timer);
1124
+ window.removeEventListener("focus", load);
1125
+ };
1126
+ }, [refresh]);
1127
+ const probe = status?.status === "signed-in" ? status.probe : void 0;
1128
+ const key = status?.status === "signed-in" ? status.probeKey : void 0;
1129
+ const result = status === void 0 ? void 0 : resultFor(status, model);
1130
+ const visible = probe?.candidates.includes(model) === true || result !== void 0;
1131
+ (0, react.useEffect)(() => {
1132
+ setConfirming(false);
1133
+ setNote(void 0);
1134
+ }, [model]);
1135
+ const detect = async () => {
1136
+ if (key === void 0 || inFlight.current || probe?.running === true) return;
1137
+ inFlight.current = true;
1138
+ setNote(void 0);
1139
+ setConfirming(false);
1140
+ setBusy(true);
1141
+ setFailed(false);
1142
+ try {
1143
+ const response = await fetch(card.probePath, {
1144
+ method: "POST",
1145
+ credentials: "same-origin",
1146
+ headers: {
1147
+ "Content-Type": "application/json",
1148
+ "X-WorkBuddy-Probe-Key": key
1149
+ },
1150
+ body: JSON.stringify({
1151
+ action: "probe",
1152
+ model
1153
+ })
1154
+ });
1155
+ const body = await response.json();
1156
+ 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");
1157
+ if (mounted.current) {
1158
+ const completed = {
1159
+ id: model,
1160
+ name: model,
1161
+ validation: body.validation,
1162
+ efforts: body.efforts,
1163
+ probedAt: Date.now()
1164
+ };
1165
+ setNote(completed);
1166
+ }
1167
+ refresh().catch(() => {});
1168
+ } catch {
1169
+ if (mounted.current) setFailed(true);
1170
+ } finally {
1171
+ inFlight.current = false;
1172
+ if (mounted.current) setBusy(false);
1173
+ }
1174
+ };
1175
+ if (!visible) return null;
1176
+ const text = tooltipText(t, model, {
1177
+ busy,
1178
+ result,
1179
+ failed
1180
+ });
1181
+ const disabled = busy || probe?.running === true || key === void 0;
1182
+ const showTooltip = tooltipVisible && !confirming && note === void 0;
1183
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1184
+ style: wrapperStyle,
1185
+ onMouseEnter: () => {
1186
+ setTooltipVisible(true);
1187
+ },
1188
+ onMouseLeave: () => {
1189
+ setTooltipVisible(false);
1190
+ },
1191
+ children: [
1192
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1193
+ type: "button",
1194
+ "aria-label": text,
1195
+ "aria-describedby": showTooltip ? tooltipId : void 0,
1196
+ "aria-busy": busy,
1197
+ "aria-expanded": confirming,
1198
+ disabled,
1199
+ onClick: () => {
1200
+ setConfirming(true);
1201
+ },
1202
+ onFocus: () => {
1203
+ setTooltipVisible(true);
1204
+ },
1205
+ onBlur: () => {
1206
+ setTooltipVisible(false);
1207
+ },
1208
+ style: {
1209
+ ...buttonStyle,
1210
+ opacity: disabled && !confirming ? .6 : 1,
1211
+ cursor: disabled ? "default" : "pointer"
1212
+ },
1213
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1214
+ width: "16",
1215
+ height: "16",
1216
+ viewBox: "0 0 24 24",
1217
+ fill: "none",
1218
+ stroke: "currentColor",
1219
+ strokeWidth: "1.6",
1220
+ "aria-hidden": "true",
1221
+ focusable: "false",
1222
+ children: [
1223
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1224
+ cx: "12",
1225
+ cy: "12",
1226
+ r: "9"
1227
+ }),
1228
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1229
+ cx: "12",
1230
+ cy: "12",
1231
+ r: "4"
1232
+ }),
1233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 12 20 4" }),
1234
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1235
+ cx: "12",
1236
+ cy: "12",
1237
+ r: "1"
1238
+ })
1239
+ ]
1240
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1241
+ style: labelStyle,
1242
+ children: label
1243
+ })]
1244
+ }),
1245
+ showTooltip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1246
+ id: tooltipId,
1247
+ role: "tooltip",
1248
+ style: tooltipStyle,
1249
+ children: text
1250
+ }),
1251
+ confirming && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1252
+ style: confirmStyle,
1253
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("probeBubbleBody") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1254
+ style: confirmRowStyle,
1255
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1256
+ type: "button",
1257
+ style: confirmButtonStyle,
1258
+ onClick: () => {
1259
+ setConfirming(false);
1260
+ },
1261
+ children: t("cancel")
1262
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1263
+ type: "button",
1264
+ style: primaryButtonStyle,
1265
+ onClick: () => {
1266
+ detect();
1267
+ },
1268
+ children: t("probeConfirmAction")
1269
+ })]
1270
+ })]
1271
+ }),
1272
+ note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1273
+ role: "status",
1274
+ "aria-live": "polite",
1275
+ style: noteStyle,
1276
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: noteText(t, note) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1277
+ type: "button",
1278
+ style: noteDismissStyle,
1279
+ onClick: () => {
1280
+ setNote(void 0);
1281
+ },
1282
+ children: t("probeNoteDismiss")
1283
+ })]
1284
+ })
1285
+ ]
1286
+ });
1287
+ }
1288
+ /** Compose the one-line outcome string the note bubble shows. */
1289
+ function noteText(t, result) {
1290
+ if (result.validation === "validating" && result.efforts.length > 0) return t("probeNoteVerified", { levels: result.efforts.join(" / ") });
1291
+ if (result.validation === "non-validating") return t("probeNoteNotValidating");
1292
+ return t("probeNoteUnknown");
1293
+ }
1294
+ //#endregion
415
1295
  //#region src/client/locales.ts
416
1296
  /** Plugin-card copy registered under the settings.workbuddy locale namespace. */
417
1297
  const en = {
418
1298
  title: "DSH WorkBuddy Connect",
419
1299
  intro: "Use the models in the WorkBuddy desktop app directly in DSH — zero configuration, ready out of the box.",
1300
+ titleAI: "DSH WorkBuddy AI Connect",
1301
+ introAI: "Use the models in the WorkBuddy AI international desktop app directly in DSH — zero configuration, ready out of the box.",
420
1302
  expand: "Expand",
421
1303
  collapse: "Collapse",
422
1304
  loading: "Loading account…",
423
1305
  signedOut: "Not signed in",
424
1306
  signedOutHint: "Sign in once in the WorkBuddy desktop app; this plugin follows that sign-in automatically.",
1307
+ signedOutHintAI: "Sign in once in the WorkBuddy AI desktop app; this plugin follows that sign-in automatically.",
425
1308
  signedInAs: "Signed in as {nickname}",
426
1309
  accessTokenExpires: "Access token expires {time} (refresh is automatic)",
427
1310
  creditsHeading: "Remaining credit",
1311
+ tabStatus: "Status",
1312
+ tabContext: "Context window",
1313
+ tabDetails: "Credit details",
1314
+ creditsDetailHeading: "By package",
428
1315
  creditsTotal: "Total: {total}",
429
1316
  percentRemaining: "{percent}% remaining",
430
1317
  exactRemaining: "{remain} / {size} remaining",
@@ -432,25 +1319,72 @@ window.__ModuleLoader__.load({
432
1319
  creditsError: "Credit unavailable: {message}",
433
1320
  refresh: "Refresh",
434
1321
  refreshing: "Refreshing…",
1322
+ refreshModels: "Refresh model list",
1323
+ refreshingModels: "Refreshing models…",
1324
+ catalogLive: "Model list updated {time}",
1325
+ catalogSaved: "Showing the saved model list from {time}",
1326
+ catalogFallback: "Showing the built-in model list (not yet updated from WorkBuddy)",
1327
+ catalogError: "Last update failed: {message}",
1328
+ catalogAppVersion: "App version {version}",
435
1329
  requestFailed: "Request failed",
436
1330
  accountHeading: "Account",
437
1331
  modelsHeading: "Model offers",
1332
+ contextHeading: "Context window",
1333
+ contextUpTo: "up to {size}",
438
1334
  freeModel: "Free",
439
1335
  badgeLimitedFree: "Limited-time free",
440
1336
  badgeNightDiscount: "Night discount",
441
- rate: "{rate} credits per message"
1337
+ badgeFreeNow: "Free now",
1338
+ rate: "{rate} credits per message",
1339
+ rateUnknown: "Price unavailable — refresh to update",
1340
+ probeLabel: "Reasoning levels",
1341
+ probeTooltipIdle: "Detect the reasoning levels {model} accepts",
1342
+ probeTooltipVerified: "Accepted levels: {levels} · click to detect again",
1343
+ probeTooltipNotValidating: "This model does not check the effort parameter",
1344
+ probeTooltipRetry: "Detection did not complete · click to retry",
1345
+ probeBubbleBody: "Send test requests to confirm the available reasoning levels. May consume a small amount of credit.",
1346
+ probeConfirmAction: "Confirm",
1347
+ probeNoteVerified: "Detected: {levels}",
1348
+ probeNoteNotValidating: "This model does not check the effort parameter",
1349
+ probeNoteUnknown: "Detection did not complete",
1350
+ probeNoteDismiss: "Got it",
1351
+ probeHeading: "Reasoning effort detection",
1352
+ probeResultNoLevels: "No tested levels were accepted.",
1353
+ 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.",
1354
+ probeConsentHint: "Each detection sends test requests to one model to confirm its available reasoning levels, and may consume a small amount of credit.",
1355
+ probeStart: "Detect",
1356
+ probeRedetect: "Detect again",
1357
+ probeRunning: "Detecting {model}…",
1358
+ probeRunningGeneric: "Detecting…",
1359
+ probeClear: "Clear detected results",
1360
+ probeCandidates: "Detectable models: {count}",
1361
+ probeConfirmBody: "Send test requests to {model} to confirm its available reasoning levels. May consume a small amount of credit.",
1362
+ cancel: "Cancel",
1363
+ probeResultVerified: "Verified levels: {levels}",
1364
+ probeResultNotValidating: "This model does not check the effort parameter",
1365
+ probeResultUnknown: "Detection did not complete",
1366
+ probeResultAt: "Detected {time}",
1367
+ probeResultEmpty: "No detectable models right now.",
1368
+ probeFailed: "Detection failed: {message}"
442
1369
  };
443
1370
  const zh = {
444
1371
  title: "DSH WorkBuddy Connect",
445
1372
  intro: "在 DSH 中直接使用 WorkBuddy 桌面 App 包含的模型,开箱即用,无需额外配置。",
1373
+ titleAI: "DSH WorkBuddy AI Connect",
1374
+ introAI: "在 DSH 中直接使用 WorkBuddy AI 国际版桌面 App 包含的模型,开箱即用,无需额外配置。",
446
1375
  expand: "展开",
447
1376
  collapse: "收起",
448
1377
  loading: "正在读取账号…",
449
1378
  signedOut: "未登录",
450
1379
  signedOutHint: "在 WorkBuddy 桌面 App 里登录一次即可,插件会自动跟随当前登录的账号。",
1380
+ signedOutHintAI: "在 WorkBuddy AI 国际版桌面 App 里登录一次即可,插件会自动跟随当前登录的账号。",
451
1381
  signedInAs: "已登录:{nickname}",
452
1382
  accessTokenExpires: "访问令牌 {time} 过期(自动续期)",
453
1383
  creditsHeading: "剩余积分",
1384
+ tabStatus: "状态",
1385
+ tabContext: "上下文窗口",
1386
+ tabDetails: "积分详情",
1387
+ creditsDetailHeading: "按套餐",
454
1388
  creditsTotal: "合计:{total}",
455
1389
  percentRemaining: "剩余 {percent}%",
456
1390
  exactRemaining: "剩余 {remain} / {size}",
@@ -458,13 +1392,53 @@ window.__ModuleLoader__.load({
458
1392
  creditsError: "积分查询失败:{message}",
459
1393
  refresh: "刷新",
460
1394
  refreshing: "正在刷新…",
1395
+ refreshModels: "刷新模型列表",
1396
+ refreshingModels: "正在刷新模型…",
1397
+ catalogLive: "模型列表更新于 {time}",
1398
+ catalogSaved: "当前显示已保存的模型列表,更新于 {time}",
1399
+ catalogFallback: "当前显示内置模型列表(尚未从 WorkBuddy 更新)",
1400
+ catalogError: "上次更新失败:{message}",
1401
+ catalogAppVersion: "App 版本 {version}",
461
1402
  requestFailed: "请求失败",
462
1403
  accountHeading: "账号",
463
1404
  modelsHeading: "模型优惠",
1405
+ contextHeading: "上下文窗口",
1406
+ contextUpTo: "最高 {size}",
464
1407
  freeModel: "免费",
465
1408
  badgeLimitedFree: "限时免费",
466
1409
  badgeNightDiscount: "夜间折扣",
467
- rate: "{rate} 积分/次"
1410
+ badgeFreeNow: "限时免费",
1411
+ rate: "{rate} 积分/次",
1412
+ rateUnknown: "价格未知 — 刷新后更新",
1413
+ probeLabel: "推理等级",
1414
+ probeTooltipIdle: "检测 {model} 可用的推理档位",
1415
+ probeTooltipVerified: "已接受:{levels} · 点击可重新检测",
1416
+ probeTooltipNotValidating: "该模型不校验该参数",
1417
+ probeTooltipRetry: "检测未完成 · 点击重试",
1418
+ probeBubbleBody: "发送探测请求以确认可用推理档位。可能消耗少量积分。",
1419
+ probeConfirmAction: "确认检测",
1420
+ probeNoteVerified: "已检测:{levels}",
1421
+ probeNoteNotValidating: "该模型不校验该参数",
1422
+ probeNoteUnknown: "检测未完成",
1423
+ probeNoteDismiss: "知道了",
1424
+ probeHeading: "推理档位检测",
1425
+ probeResultNoLevels: "本次测试的档位均未被接受。",
1426
+ probeIntro: "部分模型具备思考能力,但没有声明可选档位。检测会发送少量真实请求,可能消耗积分。",
1427
+ probeConsentHint: "每次检测会向该模型发送探测请求,以确认可用推理档位,可能消耗少量积分。",
1428
+ probeStart: "开始检测",
1429
+ probeRedetect: "重新检测",
1430
+ probeRunning: "正在检测 {model}…",
1431
+ probeRunningGeneric: "正在检测…",
1432
+ probeClear: "清除已探测结果",
1433
+ probeCandidates: "可检测模型:{count} 个",
1434
+ probeConfirmBody: "向 {model} 发送探测请求,以确认可用推理档位。可能消耗少量积分。",
1435
+ cancel: "取消",
1436
+ probeResultVerified: "已验证接受的档位:{levels}",
1437
+ probeResultNotValidating: "该模型不校验该参数",
1438
+ probeResultUnknown: "检测未完成",
1439
+ probeResultAt: "检测于 {time}",
1440
+ probeResultEmpty: "当前没有可检测的模型。",
1441
+ probeFailed: "检测失败:{message}"
468
1442
  };
469
1443
  //#endregion
470
1444
  //#region src/client/index.tsx
@@ -509,12 +1483,26 @@ window.__ModuleLoader__.load({
509
1483
  en
510
1484
  }), "dsh-workbuddy-connect: settings copy");
511
1485
  const t = ctx.locale.bind(namespace);
512
- ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
1486
+ for (const [index, variant] of CARD_VARIANTS.entries()) ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
513
1487
  name: "settings.plugin.item",
514
- key: "workbuddy",
515
- priority: 30,
516
- inject: () => ({ t })
1488
+ key: variant.id,
1489
+ priority: 30 - index,
1490
+ inject: () => ({
1491
+ t,
1492
+ variant
1493
+ })
517
1494
  }, WorkBuddyPluginCard));
1495
+ ctx.inject(["modelDirectories"], (scope) => {
1496
+ scope.slots.inject("conversation.input.right", () => scope.slots.register({
1497
+ name: "conversation.input.right",
1498
+ id: "workbuddy-probe",
1499
+ order: 10,
1500
+ inject: (sessionId) => ({
1501
+ directory: scope.modelDirectories.directoryFor(sessionId).store,
1502
+ t
1503
+ })
1504
+ }, WorkBuddyProbeControl));
1505
+ });
518
1506
  } catch (error) {
519
1507
  console.error("[dsh-workbuddy-connect] client card failed to load (host provider unaffected):", error);
520
1508
  }