dsh-connect-workbuddy 2.0.6 → 2.0.11

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
@@ -41,6 +41,69 @@ window.__ModuleLoader__.load({
41
41
  return `${path}?${WORKBUDDY_REGION_PARAM}=${region}`;
42
42
  }
43
43
  /**
44
+ * Narrow a settings value to the `regions` map. Accepts EITHER the whole
45
+ * settings section (the Host's resolved `Config`) OR the `regions` map itself,
46
+ * and unwraps the former. This tolerance is deliberate: passing the whole
47
+ * section where the map was expected was a real shipped bug in the sibling
48
+ * project — the lookup then read `section['cn']` (absent), so the card's
49
+ * checkbox reported `true` forever and clicking it appeared to do nothing even
50
+ * though the write succeeded.
51
+ */
52
+ function regionsMapOf(value) {
53
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
54
+ const record = value;
55
+ const nested = record["regions"];
56
+ if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
57
+ return record;
58
+ }
59
+ /** One region's stored slot as a plain object; any other shape reads as empty. */
60
+ function regionSlotOf(value, region) {
61
+ const slot = regionsMapOf(value)[region];
62
+ return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
63
+ }
64
+ /**
65
+ * Whether one region's provider is switched on. Opt-out semantics: only an
66
+ * explicit `false` disables it, so a config written before this switch existed
67
+ * (and the pre-region-split flat fields, which never carry `enabled`) keep both
68
+ * providers running exactly as before. The Host reads the same rule through
69
+ * `regionStateOf`, so card and Host can never disagree about a region's state.
70
+ *
71
+ * `value` may be the whole settings section or the `regions` map (see
72
+ * {@link regionsMapOf}).
73
+ */
74
+ function regionEnabledOf(value, region) {
75
+ return regionSlotOf(value, region)["enabled"] !== false;
76
+ }
77
+ /** Build the next `regions` settings value for a signed-in tab's save. */
78
+ function nextRegionSlots(regions, region, slot) {
79
+ return {
80
+ ...typeof regions === "object" && regions !== null && !Array.isArray(regions) ? regions : {},
81
+ [region]: slot
82
+ };
83
+ }
84
+ /**
85
+ * Build the next `regions` settings value for a provider on/off toggle. ONLY
86
+ * the target region's `enabled` flag changes: every other field of that slot
87
+ * (its directory, selection, image opt-ins, context budgets) and every other
88
+ * region's slot are carried over verbatim, so switching a provider off never
89
+ * discards the user's model picks and switching it back on restores them.
90
+ *
91
+ * This is deliberately separate from {@link nextRegionSlots}: that helper
92
+ * writes a whole slot from a signed-in tab's draft, while this one must work
93
+ * for a region that is signed OUT — which is precisely the region a user wants
94
+ * to switch off (no international install, no international account).
95
+ *
96
+ * `value` may be the whole settings section or the `regions` map; the RETURN
97
+ * value is always the `regions` map, i.e. exactly what `settingsScope.set(
98
+ * 'regions', ...)` needs.
99
+ */
100
+ function nextRegionEnabled(value, region, enabled) {
101
+ return nextRegionSlots(regionsMapOf(value), region, {
102
+ ...regionSlotOf(value, region),
103
+ enabled
104
+ });
105
+ }
106
+ /**
44
107
  * Project one card row into its persisted `lastCatalog` shape: the native
45
108
  * context window becomes the stored `contextWindow`, and the card-only
46
109
  * presentation fields (`nativeContextWindow`, `multimodal`) are removed BY
@@ -199,6 +262,12 @@ window.__ModuleLoader__.load({
199
262
  .dsm-workbuddy-tab:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
200
263
  .dsm-workbuddy-tab-active{color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-2,#232529);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2,#3a3d45)}
201
264
  .dsm-workbuddy-tab-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:baseline}
265
+ .dsm-workbuddy-tab-cell{display:flex;align-items:center;gap:2px;flex:1;min-width:0}
266
+ .dsm-workbuddy-tab-switch{display:inline-flex;align-items:center;flex:none;padding:0 8px 0 2px;cursor:pointer}
267
+ .dsm-workbuddy-tab-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
268
+ .dsm-workbuddy-tab-switch input:disabled{opacity:.4;cursor:default}
269
+ .dsm-workbuddy-tab-off{opacity:.55}
270
+ .dsm-workbuddy-tab-off-notice{color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px;margin:0 0 4px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3,#2a2c33);border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:8px}
202
271
  .dsm-workbuddy-usage-account{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:14px;background:var(--dsw-alias-bg-layer-2,#24262c)}
203
272
  .dsm-workbuddy-usage-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
204
273
  .dsm-workbuddy-usage-expiry{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
@@ -213,6 +282,17 @@ window.__ModuleLoader__.load({
213
282
  .dsm-workbuddy-usage-select:disabled{opacity:.6;cursor:default}
214
283
  .dsm-workbuddy-usage-select-wrap::after{content:"";position:absolute;top:50%;right:12px;width:7px;height:7px;transform:translateY(-65%) rotate(45deg);border-right:1.6px solid var(--dsw-alias-label-secondary,#c6c9d0);border-bottom:1.6px solid var(--dsw-alias-label-secondary,#c6c9d0);pointer-events:none}
215
284
  .dsm-workbuddy-usage-text{margin:0;font-size:14px;line-height:22px;color:var(--dsw-alias-label-secondary,#b8b8b8)}
285
+ .dsm-workbuddy-searched{margin-top:8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999)}
286
+ .dsm-workbuddy-searched>summary{cursor:pointer;color:var(--dsw-alias-label-secondary,#b8b8b8);user-select:none}
287
+ .dsm-workbuddy-searched-hint{margin:6px 0 4px;color:var(--dsw-alias-label-tertiary,#999)}
288
+ .dsm-workbuddy-searched-list{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
289
+ .dsm-workbuddy-searched-list li{display:flex;flex-direction:column;gap:1px;min-width:0}
290
+ .dsm-workbuddy-searched-list code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#b8b8b8);word-break:break-all}
291
+ .dsm-workbuddy-searched-reason{color:var(--dsw-alias-label-tertiary,#999);font-size:11px}
292
+ .dsm-workbuddy-searched-reason-encrypted{color:var(--dsw-alias-state-warning-primary,#e0a13a)}
293
+ .dsm-workbuddy-searched-reason-wrong-region{color:var(--dsw-alias-brand-primary,#5686fe)}
294
+ .dsm-workbuddy-searched-more{margin-top:6px;padding:0;border:0;background:none;color:var(--dsw-alias-brand-primary,#5686fe);font:inherit;font-size:12px;line-height:18px;cursor:pointer;text-align:left}
295
+ .dsm-workbuddy-searched-more:hover{text-decoration:underline}
216
296
  .dsm-workbuddy-usage-error{margin:0;font-size:14px;line-height:22px;color:var(--dsw-alias-state-error-primary,#ef4444)}
217
297
  .dsm-workbuddy-usage-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
218
298
  .dsm-workbuddy-usage-status{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:500;color:var(--dsw-alias-label-primary,#e6e6e6)}
@@ -272,6 +352,126 @@ window.__ModuleLoader__.load({
272
352
  .dsm-workbuddy-usage-cheer:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}
273
353
  .dsm-workbuddy-usage-cheer:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:2px}
274
354
  `;
355
+ //#endregion
356
+ //#region src/client/searched-paths.ts
357
+ /**
358
+ * Reasons that explain a failure, as opposed to merely recording an absence.
359
+ *
360
+ * `missing` is the normal state of most candidates on any machine — the app
361
+ * writes one file while the platform offers several possible directories — so
362
+ * it is the one reason that gets hidden by default. Everything else is a real
363
+ * finding: the file is there and unreadable, present but not a credential, or
364
+ * present and encrypted without a key.
365
+ *
366
+ * `encrypted` is WorkBuddy-specific and the most important of the three: the
367
+ * user is very likely signed in already, and the plugin simply cannot open the
368
+ * token fields without the desktop app present. "Sign in again" is the one
369
+ * action that cannot help, so this reason must never be hidden behind a toggle.
370
+ *
371
+ * `wrong-region` is equally a finding and equally actionable: the sign-in is
372
+ * real and readable, it just belongs to the other tab. That is why it must be
373
+ * shown up front — hidden among the absences it would look like "nothing here",
374
+ * when it is in fact the whole explanation, and the cheapest possible fix.
375
+ */
376
+ const INTERESTING_REASONS = [
377
+ "encrypted",
378
+ "invalid",
379
+ "unreadable",
380
+ "wrong-region"
381
+ ];
382
+ /**
383
+ * Partition one region's probe failures for display.
384
+ *
385
+ * Order within each group is preserved from the Host: the store reports
386
+ * candidates in probe order, which is meaningful (the live file before its
387
+ * timestamped backups).
388
+ *
389
+ * There is deliberately no `encrypted` flag here. There used to be, for a
390
+ * notice rendered inside the list; that notice is gone because
391
+ * {@link signedOutNotice} now answers the encrypted cause in the paragraph, so
392
+ * the flag would have had no consumer — and a derived field kept "just in
393
+ * case" is how the list and the paragraph drift apart again.
394
+ */
395
+ function searchedView(items) {
396
+ const interesting = items.filter((item) => INTERESTING_REASONS.includes(item.reason));
397
+ return {
398
+ interesting,
399
+ missing: items.filter((item) => !INTERESTING_REASONS.includes(item.reason)),
400
+ missingOpen: interesting.length === 0,
401
+ total: items.length
402
+ };
403
+ }
404
+ /** Every reason's locale key, so no reason can silently fall through to a wrong one. */
405
+ const REASON_KEYS = {
406
+ missing: "row.reasonMissing",
407
+ unreadable: "row.reasonUnreadable",
408
+ invalid: "row.reasonInvalid",
409
+ encrypted: "row.reasonEncrypted",
410
+ "wrong-region": "row.reasonWrongRegion"
411
+ };
412
+ /** The reason's short label; `missing`/`unreadable`/`invalid`/`encrypted`/`wrong-region`. */
413
+ function searchReasonKey(reason) {
414
+ return REASON_KEYS[reason] ?? "row.reasonInvalid";
415
+ }
416
+ /**
417
+ * One entry's cause line: which store the path belongs to, and why it failed.
418
+ *
419
+ * The source matters because the two are fixed differently — a desktop-app path
420
+ * is about the app's installation and sign-in, a plugin-owned copy is about the
421
+ * plugin's own storage.
422
+ */
423
+ function searchReasonLabel(item, t) {
424
+ return `${t(item.source === "desktop" ? "row.sourceDesktop" : "row.sourceDsh")} · ${t(searchReasonKey(item.reason))}`;
425
+ }
426
+ /**
427
+ * Choose the signed-out paragraph's copy.
428
+ *
429
+ * This exists because the card was saying the same thing twice. `resolve()`
430
+ * refuses with a message that already ENUMERATES every path it tried
431
+ * (`expected <Local>\workbuddy-desktop.info or <Roaming>\... or
432
+ * WORKBUDDY_AUTH_FILE`), and the probed-path `<details>` right below it listed
433
+ * those same paths with a per-entry reason. Both hint keys also opened with a
434
+ * variant of "no sign-in was found", so one screen carried four statements of
435
+ * one fact and the user's eye had nowhere to land.
436
+ *
437
+ * The rule: the paragraph states the situation, the list supplies the detail.
438
+ * Whenever a list is rendered, the enumeration in `message` is redundant by
439
+ * construction — the list is the same paths, with strictly better reasons — so
440
+ * the paragraph falls back to the concise hint. Measured against the real
441
+ * `resolve()` branches, the only content this drops is "or refresh an existing
442
+ * session", which is vacuous exactly here: `searched` is attached only when
443
+ * there are zero accounts, so there is no session to refresh.
444
+ *
445
+ * `selectionLost` still wins outright: there the tokens are healthy and the
446
+ * advice is to re-pick an account, which no path list can replace.
447
+ *
448
+ * `encrypted` also outranks the generic hint, and for the same reason
449
+ * `wrong-region` does: the generic copy tells the user to sign in again, which
450
+ * is precisely the action that cannot work when the credential exists but is
451
+ * encrypted. Leaving it in the collapsed list meant the headline contradicted
452
+ * the detail below it — and the headline is the only part most users read.
453
+ */
454
+ function signedOutNotice(input) {
455
+ if (input.selectionLost) return { key: "row.selectionLostMessage" };
456
+ if (input.searched.some((item) => item.reason === "wrong-region")) return { key: "row.signedOutWrongRegion" };
457
+ if (input.searched.some((item) => item.reason === "encrypted")) return { key: "row.signedOutEncrypted" };
458
+ if (input.searched.length > 0) return { key: "row.signedOutHint" };
459
+ return input.message === void 0 ? { key: "row.signedOutHint" } : {
460
+ key: "row.signedOutHint",
461
+ fallback: input.message
462
+ };
463
+ }
464
+ /**
465
+ * The paragraph's final text: the localized copy, plus the Host message only
466
+ * when {@link signedOutNotice} decided nothing supersedes it.
467
+ *
468
+ * Kept here rather than inline in the JSX so the anti-duplication guarantee can
469
+ * be asserted on the string that is actually rendered, not on its inputs.
470
+ */
471
+ function signedOutText(notice, t) {
472
+ const hint = t(notice.key);
473
+ return notice.fallback === void 0 ? hint : `${hint} (${notice.fallback})`;
474
+ }
275
475
  //#endregion
276
476
  //#region src/client/WorkBuddyCard.tsx
277
477
  /**
@@ -311,6 +511,66 @@ window.__ModuleLoader__.load({
311
511
  document.head.appendChild(styleTag);
312
512
  }
313
513
  }
514
+ /**
515
+ * One probed path, with its cause. The presentation rules — which entries are
516
+ * worth showing up front, and how a reason is labelled — live in
517
+ * `./searched-paths.ts` so they are unit-testable without a DOM.
518
+ */
519
+ function renderSearchedItem(item, t) {
520
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: item.path }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
521
+ className: `dsm-workbuddy-searched-reason${item.reason === "encrypted" ? " dsm-workbuddy-searched-reason-encrypted" : item.reason === "wrong-region" ? " dsm-workbuddy-searched-reason-wrong-region" : ""}`,
522
+ children: [searchReasonLabel(item, t), item.message === void 0 ? null : ` · ${item.message}`]
523
+ })] }, `${item.source}:${item.path}`);
524
+ }
525
+ /**
526
+ * The probed-path list behind a signed-out card.
527
+ *
528
+ * Collapsed by default because it is a diagnostic, not a headline. The
529
+ * interesting failures (encrypted / invalid / unreadable) are listed up front;
530
+ * the merely-absent candidates — most of them, on any normal machine — sit
531
+ * behind a second toggle, so the one entry that explains the failure is not
532
+ * buried under a dozen "not found" lines. When nothing interesting was found
533
+ * the absent list IS the explanation, so it opens directly.
534
+ *
535
+ * An `encrypted` failure deliberately adds NO notice of its own. It used to,
536
+ * because the paragraph above still said "sign in once in the desktop app" —
537
+ * the one action that cannot work when the credential exists but is encrypted.
538
+ * `signedOutNotice` now answers that cause in the paragraph itself, so a second
539
+ * copy here would restate it: the paragraph states the situation, this list
540
+ * supplies the detail, and the per-entry label already says what each path is.
541
+ */
542
+ function SearchedPaths({ items, t }) {
543
+ const view = searchedView(items);
544
+ const [explicitOpen, setExplicitOpen] = (0, react.useState)(void 0);
545
+ const showMissing = explicitOpen ?? view.missingOpen;
546
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
547
+ className: "dsm-workbuddy-searched",
548
+ children: [
549
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", { children: [
550
+ t("row.searchedTitle"),
551
+ " (",
552
+ view.total,
553
+ ")"
554
+ ] }),
555
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
556
+ className: "dsm-workbuddy-searched-hint",
557
+ children: t("row.searchedHint")
558
+ }),
559
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("ul", {
560
+ className: "dsm-workbuddy-searched-list",
561
+ children: [view.interesting.map((item) => renderSearchedItem(item, t)), showMissing ? view.missing.map((item) => renderSearchedItem(item, t)) : null]
562
+ }),
563
+ showMissing || view.missing.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
564
+ type: "button",
565
+ className: "dsm-workbuddy-searched-more",
566
+ onClick: () => {
567
+ setExplicitOpen(true);
568
+ },
569
+ children: t("row.searchedMore", { count: view.missing.length })
570
+ })
571
+ ]
572
+ });
573
+ }
314
574
  function formatNumber(value) {
315
575
  return new Intl.NumberFormat(void 0, {
316
576
  minimumFractionDigits: 0,
@@ -379,12 +639,12 @@ window.__ModuleLoader__.load({
379
639
  /** Save failure surfaced next to the buttons; cleared by the next attempt. */
380
640
  const [saveError, setSaveError] = (0, react.useState)(void 0);
381
641
  const [switchingAccount, setSwitchingAccount] = (0, react.useState)(false);
382
- /** Set right after a CONFIRMED Clear so the dropped choice is stated. */
383
- const [accountNote, setAccountNote] = (0, react.useState)(void 0);
384
642
  /** A refused account write (silently unpersisted settings on a locked file). */
385
643
  const [accountError, setAccountError] = (0, react.useState)(void 0);
386
644
  const [checkingIn, setCheckingIn] = (0, react.useState)(false);
387
645
  const [checkinActionError, setCheckinActionError] = (0, react.useState)(void 0);
646
+ /** Region whose on/off checkbox write is in flight, so its box can't race. */
647
+ const [togglingRegion, setTogglingRegion] = (0, react.useState)(void 0);
388
648
  const mounted = (0, react.useRef)(true);
389
649
  (0, react.useEffect)(() => {
390
650
  mounted.current = true;
@@ -485,7 +745,6 @@ window.__ModuleLoader__.load({
485
745
  const switchAccount = async (accountId) => {
486
746
  if (settingsScope === void 0) return;
487
747
  setSwitchingAccount(true);
488
- setAccountNote(void 0);
489
748
  setAccountError(void 0);
490
749
  try {
491
750
  await writeAccountSlot(settingsScope, activeRegion, accountId);
@@ -497,36 +756,40 @@ window.__ModuleLoader__.load({
497
756
  }
498
757
  };
499
758
  /**
500
- * Drop the explicit choice so the region returns to its documented default:
501
- * follow whatever the WorkBuddy app is currently signed in as. The empty
502
- * string is the settings-level sentinel for "no explicit selection" (the
503
- * store normalizes it away, and the host's legacy attribution treats it as a
504
- * deliberate clear rather than an unset key); the account id itself is never
505
- * written here.
759
+ * Whether one region's provider is switched on, read off the SAME committed
760
+ * settings document the Host reads (`regionEnabledOf` mirrors the Host's
761
+ * `regionStateOf` opt-out rule: only an explicit `false` disables). Reading
762
+ * the stored value rather than echoing local state means a rejected write,
763
+ * another window's change, or a restart all converge on the truth.
506
764
  *
507
- * The confirmation is only shown once the write is CONFIRMED. That matters
508
- * twice over: the restored default is usually the very account that was
509
- * saved, so the state line is the only feedback the user gets — and on
510
- * Windows the atomic replace of `settings.yaml` can fail outright (locked by
511
- * an antivirus scanner or a sync client), in which case claiming "cleared"
512
- * would be a lie that the old selection silently contradicts.
765
+ * The whole settings section is passed deliberately: `regionEnabledOf`
766
+ * accepts either it or the bare `regions` map, because passing the section
767
+ * where the map was expected was a shipped bug (the lookup read
768
+ * `section['cn']`, found nothing, and reported `true` forever — the checkbox
769
+ * stayed checked and clicking it appeared dead while the write succeeded).
513
770
  */
514
- const clearAccount = async () => {
515
- if (settingsScope === void 0) return;
516
- setSwitchingAccount(true);
517
- setAccountError(void 0);
771
+ const regionOn = (item) => regionEnabledOf(settingsScope?.getSnapshot().value, item);
772
+ const activeRegionOn = regionOn(activeRegion);
773
+ /**
774
+ * Switch one region's provider off or on. The write carries the region's
775
+ * whole slot through untouched — only `enabled` changes — so the user's
776
+ * directory, model picks, image opt-ins and budgets survive a round trip.
777
+ * The Host withdraws or restores the provider route on the next `onChange`,
778
+ * which is what actually removes it from DSH's model picker.
779
+ */
780
+ const toggleRegion = async (item, enabled) => {
781
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
782
+ setTogglingRegion(item);
518
783
  try {
519
- await writeAccountSlot(settingsScope, activeRegion, "");
520
- await refreshUsage(activeRegion);
521
- if (mounted.current) setAccountNote("cleared");
522
- } catch (error) {
523
- if (!mounted.current) return;
524
- setAccountNote(void 0);
525
- setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
784
+ await settingsScope.set("regions", nextRegionEnabled(settingsScope.getSnapshot().value, item, enabled));
526
785
  } finally {
527
- if (mounted.current) setSwitchingAccount(false);
786
+ if (mounted.current) setTogglingRegion(void 0);
528
787
  }
529
788
  };
789
+ /**
790
+ * Claim the daily check-in reward for the active region. The action endpoint
791
+ * is region-scoped; the response only refreshes this tab's check-in state.
792
+ */
530
793
  const claimDailyCheckin = async () => {
531
794
  setCheckingIn(true);
532
795
  setCheckinActionError(void 0);
@@ -652,6 +915,7 @@ window.__ModuleLoader__.load({
652
915
  setSaveError(void 0);
653
916
  try {
654
917
  await writeRegionModels(settingsScope, status.region, {
918
+ enabled: activeRegionOn,
655
919
  lastCatalog: visibleModels.map(toPersistedWorkBuddyModel),
656
920
  enabledModelIds: [...activeEnabledIds],
657
921
  imageModelIds: [...activeImageIds],
@@ -666,10 +930,35 @@ window.__ModuleLoader__.load({
666
930
  }
667
931
  };
668
932
  const title = t("row.title");
669
- const label = status.status === "signed-in" ? t("row.signedIn", { accountName: status.accountName }) : status.status === "error" ? t("row.requestFailed") : t("row.signedOut");
933
+ /**
934
+ * Show a name, or a placeholder when the desktop app recorded none.
935
+ *
936
+ * The Host sends `''` rather than an identifier, because a `uin`/`uid` shown
937
+ * where a name belongs reads as "the plugin does not know who this is" — the
938
+ * placeholder says the honest thing instead.
939
+ */
940
+ const nameOf = (value) => value === "" ? t("row.accountUnnamed") : value;
941
+ const label = status.status === "signed-in" ? t("row.signedIn", { accountName: nameOf(status.accountName) }) : status.status === "error" ? t("row.requestFailed") : t("row.signedOut");
670
942
  /** The saved choice no longer matches a local sign-in (tokens are fine). */
671
943
  const selectionLost = status.status === "signed-out" && status.selectionLost === true;
672
944
  /**
945
+ * The paths the Host probed, on the branch where nothing was found at all.
946
+ * Absent on the legacy card payload (an older Host), so it defaults empty
947
+ * rather than rendering an empty diagnostic.
948
+ */
949
+ const searched = status.status === "signed-out" ? status.searched ?? [] : [];
950
+ /**
951
+ * What the signed-out paragraph says, and whether to append the Host's raw
952
+ * error. The rule lives in `./searched-paths.ts` because it is the fix for a
953
+ * real duplication: `resolve()`'s message already enumerated every path, and
954
+ * the list below enumerates them again with better reasons.
955
+ */
956
+ const notice = signedOutNotice({
957
+ selectionLost,
958
+ message: status.status === "signed-out" ? status.message : void 0,
959
+ searched
960
+ });
961
+ /**
673
962
  * Whether this region runs a saved choice, per the Host. `undefined` only on
674
963
  * the error branch, which renders no picker and therefore no state line.
675
964
  */
@@ -718,21 +1007,36 @@ window.__ModuleLoader__.load({
718
1007
  "aria-label": title,
719
1008
  children: WORKBUDDY_REGIONS.map((region) => {
720
1009
  const regionStatus = statusByRegion[region];
721
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
722
- type: "button",
723
- role: "tab",
724
- "aria-selected": region === activeRegion,
725
- className: `dsm-workbuddy-tab${region === activeRegion ? " dsm-workbuddy-tab-active" : ""}`,
726
- onClick: () => {
727
- setActiveRegion(region);
728
- setAccountNote(void 0);
729
- setAccountError(void 0);
730
- },
731
- children: [regionStatus === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
732
- "aria-hidden": "true",
733
- className: "dsm-workbuddy-tab-dot",
734
- style: dotStyle(regionStatus.status)
735
- }), region === "cn" ? t("row.tabCn") : t("row.tabGlobal")]
1010
+ const regionOnState = regionOn(region);
1011
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1012
+ className: "dsm-workbuddy-tab-cell",
1013
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1014
+ type: "button",
1015
+ role: "tab",
1016
+ "aria-selected": region === activeRegion,
1017
+ className: `dsm-workbuddy-tab${region === activeRegion ? " dsm-workbuddy-tab-active" : ""}${regionOnState ? "" : " dsm-workbuddy-tab-off"}`,
1018
+ onClick: () => {
1019
+ setActiveRegion(region);
1020
+ setAccountError(void 0);
1021
+ },
1022
+ children: [regionStatus === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1023
+ "aria-hidden": "true",
1024
+ className: "dsm-workbuddy-tab-dot",
1025
+ style: dotStyle(regionStatus.status)
1026
+ }), region === "cn" ? t("row.tabCn") : t("row.tabGlobal")]
1027
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1028
+ className: "dsm-workbuddy-tab-switch",
1029
+ title: t("row.tabSwitchHint"),
1030
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1031
+ type: "checkbox",
1032
+ checked: regionOnState,
1033
+ disabled: togglingRegion === region || settingsScope?.getSnapshot().writable !== true,
1034
+ "aria-label": t("row.tabSwitchAria", { region: region === "cn" ? t("row.tabCn") : t("row.tabGlobal") }),
1035
+ onChange: (event) => {
1036
+ toggleRegion(region, event.target.checked);
1037
+ }
1038
+ })
1039
+ })]
736
1040
  }, region);
737
1041
  })
738
1042
  }),
@@ -740,6 +1044,10 @@ window.__ModuleLoader__.load({
740
1044
  className: "dsm-workbuddy-models-summary",
741
1045
  children: t("row.tabHint")
742
1046
  }),
1047
+ !activeRegionOn ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1048
+ className: "dsm-workbuddy-tab-off-notice",
1049
+ children: t("row.tabOffNotice")
1050
+ }) : null,
743
1051
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
744
1052
  className: "dsm-workbuddy-usage-account",
745
1053
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -758,7 +1066,7 @@ window.__ModuleLoader__.load({
758
1066
  className: "dsm-workbuddy-usage-expiry",
759
1067
  children: t("row.tokenExpiry", { expiresAt: formatDateTime(status.tokenExpiresAtMs) })
760
1068
  }) : null,
761
- status.status === "error" || status.status === "signed-in" && status.creditsError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1069
+ status.status === "error" || status.status === "signed-in" && status.creditsError !== void 0 ? status.status === "signed-in" && status.credentialRejected === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
762
1070
  className: "dsm-workbuddy-usage-hint",
763
1071
  children: t("row.reloginHint")
764
1072
  }) : null,
@@ -796,23 +1104,14 @@ window.__ModuleLoader__.load({
796
1104
  children: t("row.accountNoneInEffect")
797
1105
  }), status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
798
1106
  value: account.id,
799
- children: [account.accountName, account.domain === "" ? "" : ` · ${account.domain}`]
1107
+ children: [nameOf(account.accountName), account.domain === "" ? "" : ` · ${account.domain}`]
800
1108
  }, account.id))]
801
1109
  })
802
1110
  }),
803
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
804
- type: "button",
805
- className: "dsm-btn dsm-btn-outline",
806
- disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
807
- onClick: () => {
808
- clearAccount();
809
- },
810
- children: t("row.accountsFollowApp")
811
- }),
812
1111
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
813
1112
  className: "dsm-workbuddy-account-state",
814
1113
  role: "status",
815
- children: accountNote === "cleared" ? t("row.accountsCleared") : selectionExplicit === false ? t("row.accountsFollowingApp") : selectionExplicit === true ? t("row.accountsSavedChoice") : ""
1114
+ children: selectionExplicit === true ? t("row.accountsSavedChoice") : ""
816
1115
  }),
817
1116
  accountError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
818
1117
  className: "dsm-workbuddy-account-error",
@@ -894,6 +1193,25 @@ window.__ModuleLoader__.load({
894
1193
  })]
895
1194
  });
896
1195
  })(),
1196
+ status.credentialRejected === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1197
+ className: "dsm-workbuddy-usage-error",
1198
+ role: "alert",
1199
+ children: [
1200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("row.credentialRejectedTitle") }),
1201
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.credentialRejectedIntro", { accountName: nameOf(status.accountName) }) }),
1202
+ status.recovery?.usableAccount !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.credentialRejectedSwitch", { accountName: nameOf(status.recovery.usableAccount.accountName) }) }) : status.recovery?.reloginRequired === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.credentialRejectedRelogin") }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.credentialRejectedChoose") }),
1203
+ status.recovery?.usableAccount === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1204
+ type: "button",
1205
+ className: "dsm-btn dsm-btn-outline",
1206
+ disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
1207
+ onClick: () => {
1208
+ const target = status.recovery?.usableAccount;
1209
+ if (target !== void 0) switchAccount(target.accountId);
1210
+ },
1211
+ children: switchingAccount ? t("row.accountsScanning") : t("row.credentialRejectedSwitchAction", { accountName: nameOf(status.recovery.usableAccount.accountName) })
1212
+ })
1213
+ ]
1214
+ }) : null,
897
1215
  status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
898
1216
  className: "dsm-workbuddy-usage-error",
899
1217
  children: t("row.creditsError", { message: status.creditsError })
@@ -1043,10 +1361,13 @@ window.__ModuleLoader__.load({
1043
1361
  ]
1044
1362
  })
1045
1363
  ] }) : null,
1046
- status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1364
+ status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1047
1365
  className: "dsm-workbuddy-usage-text",
1048
- children: selectionLost ? t("row.selectionLostMessage") : status.message ?? t("row.signedOutHint")
1049
- }) : null,
1366
+ children: signedOutText(notice, t)
1367
+ }), searched.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SearchedPaths, {
1368
+ items: searched,
1369
+ t
1370
+ }) : null] }) : null,
1050
1371
  status.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1051
1372
  className: "dsm-workbuddy-usage-error",
1052
1373
  children: status.message
@@ -1077,11 +1398,32 @@ window.__ModuleLoader__.load({
1077
1398
  "row.tabCn": "Domestic",
1078
1399
  "row.tabGlobal": "Global",
1079
1400
  "row.tabHint": "Each tab is a separate provider (workbuddy / workbuddy-global) with its own account, credits, and models. Both sides are live at once: different sessions can pick from either side, and changes on one tab never touch the other.",
1401
+ "row.tabSwitchHint": "Uncheck to switch this provider off. Its models disappear from the DSH model picker; your account, credits, and model settings are kept and return when you check it again.",
1402
+ "row.tabSwitchAria": "Offer the {region} provider to DSH",
1403
+ "row.tabOffNotice": "This provider is switched off: none of its models are offered to DSH. Your account, credits, and model settings are kept — check the box above the tab to bring it back.",
1080
1404
  "row.signedOut": "Not signed in",
1081
1405
  "row.signedOutHint": "Sign in once in the WorkBuddy desktop app; this plugin follows that sign-in automatically.",
1406
+ "row.signedOutWrongRegion": "Your WorkBuddy sign-in was found, but it belongs to the other region tab. Switch tabs above to use it — there is nothing to sign in again.",
1407
+ "row.signedOutEncrypted": "Your WorkBuddy sign-in is present, but its token fields are encrypted and the plugin could not read the key. Install the WorkBuddy desktop app, or set WORKBUDDY_APP_EXECUTABLE to its executable — signing in again will not change this.",
1408
+ "row.searchedTitle": "Paths checked",
1409
+ "row.searchedHint": "If your WorkBuddy keeps its login somewhere else, point the plugin at it with the WORKBUDDY_AUTH_FILE environment variable.",
1410
+ "row.reasonMissing": "not found",
1411
+ "row.reasonUnreadable": "unreadable",
1412
+ "row.reasonInvalid": "no usable token",
1413
+ "row.reasonEncrypted": "encrypted — needs the desktop app",
1414
+ "row.reasonWrongRegion": "belongs to the other region",
1415
+ "row.sourceDesktop": "desktop app",
1416
+ "row.sourceDsh": "plugin copy",
1417
+ "row.searchedMore": "Show the {count} path(s) that were not found",
1082
1418
  "row.signedIn": "Signed in: {accountName}",
1083
1419
  "row.tokenExpiry": "Access token expires {expiresAt} (auto-renewal)",
1084
1420
  "row.reloginHint": "If errors occur, sign back in to the WorkBuddy app.",
1421
+ "row.credentialRejectedTitle": "WorkBuddy refused this account's token",
1422
+ "row.credentialRejectedIntro": "The token for \"{accountName}\" is no longer accepted by the upstream, so credits and check-in are unavailable. Signing in again only helps if the problem is the token itself — here is what actually applies:",
1423
+ "row.credentialRejectedSwitch": "Another sign-in on this machine answered the upstream: \"{accountName}\". Switching to it fixes this — no need to sign in again.",
1424
+ "row.credentialRejectedSwitchAction": "Switch to \"{accountName}\"",
1425
+ "row.credentialRejectedRelogin": "No other local sign-in exists to switch to. Sign in again in the WorkBuddy desktop app.",
1426
+ "row.credentialRejectedChoose": "Other local sign-ins exist but none of them answered the upstream just now. Try switching to one above; if none works, sign in again in the desktop app.",
1085
1427
  "row.requestFailed": "Request failed",
1086
1428
  "row.creditsTotalLabel": "Total remaining credits",
1087
1429
  "row.creditsMonthlyRemain": "Remaining {remain} / {size} · Refreshes {at}",
@@ -1095,17 +1437,15 @@ window.__ModuleLoader__.load({
1095
1437
  "row.refresh": "Refresh",
1096
1438
  "row.refreshing": "Refreshing…",
1097
1439
  "row.accountsTitle": "WorkBuddy account",
1440
+ "row.accountUnnamed": "Unnamed account",
1098
1441
  "row.accountsHint": "Choose from locally detected sign-ins. Tokens are never shown or saved in DSH settings.",
1099
1442
  "row.accountsRescan": "Detect accounts again",
1100
1443
  "row.accountsScanning": "Detecting…",
1101
- "row.accountsFollowApp": "Follow the app's sign-in",
1102
1444
  "row.accountNoneInEffect": "No account in effect",
1103
1445
  "row.accountsSavedChoice": "Using the account saved for this region.",
1104
- "row.accountsFollowingApp": "Following the app's current sign-in — no account is saved for this region.",
1105
- "row.accountsCleared": "Saved choice cleared. This region follows the app's current sign-in again.",
1106
1446
  "row.accountsWriteFailed": "The account change was not saved: {message} — the settings file could not be updated (on Windows it may be locked by an antivirus scanner or a sync client). Your previous choice is still in effect; close whatever holds settings.yaml and try again.",
1107
1447
  "row.selectionLostHint": "The saved account no longer exists locally.",
1108
- "row.selectionLostMessage": "The account saved for this region is no longer among the local sign-ins (WorkBuddy replaced its login or cleaned up backups). Your other sign-ins are fine — pick one above, or choose \"Follow the app's sign-in\" to clear the saved choice. Signing in again in the desktop app will not fix this.",
1448
+ "row.selectionLostMessage": "The account saved for this region is no longer among the local sign-ins (WorkBuddy replaced its login or cleaned up backups). Your other sign-ins are fine — pick a current one from the list above. Signing in again in the desktop app will not fix this.",
1109
1449
  "row.modelsTitle": "Models",
1110
1450
  "row.modelsSummary": "{count} enabled",
1111
1451
  "row.modelsRefresh": "Refresh from WorkBuddy",
@@ -1133,11 +1473,32 @@ window.__ModuleLoader__.load({
1133
1473
  "row.tabCn": "国内版",
1134
1474
  "row.tabGlobal": "国际版",
1135
1475
  "row.tabHint": "每个 tab 是一个独立供应商(workbuddy / workbuddy-global),各有自己的账号、积分与模型。两边同时生效:不同会话可各选一边,一侧的改动不影响另一侧。",
1476
+ "row.tabSwitchHint": "取消勾选即关闭该供应商:它的模型不再出现在 DSH 模型选择器里;账号、积分与模型设置都会保留,重新勾选即恢复。",
1477
+ "row.tabSwitchAria": "是否向 DSH 提供{region}供应商",
1478
+ "row.tabOffNotice": "该供应商已关闭:它的模型不会提供给 DSH。账号、积分与模型设置均已保留——勾选上方复选框即可恢复。",
1136
1479
  "row.signedOut": "未登录",
1137
1480
  "row.signedOutHint": "在 WorkBuddy 桌面 App 里登录一次即可,插件会自动跟随当前登录的账号。",
1481
+ "row.signedOutWrongRegion": "找到了你的 WorkBuddy 登录信息,但它属于另一个地区的标签页。切换到上方对应标签页即可使用——不需要重新登录。",
1482
+ "row.signedOutEncrypted": "你的 WorkBuddy 登录信息在,但其中的 token 字段是加密的,插件拿不到密钥。请安装 WorkBuddy 桌面 App,或用环境变量 WORKBUDDY_APP_EXECUTABLE 指定它的可执行文件——重新登录无法解决这个问题。",
1483
+ "row.searchedTitle": "已检查的路径",
1484
+ "row.searchedHint": "如果你的 WorkBuddy 把登录信息存在别处,可以用环境变量 WORKBUDDY_AUTH_FILE 指定。",
1485
+ "row.reasonMissing": "不存在",
1486
+ "row.reasonUnreadable": "无法读取",
1487
+ "row.reasonInvalid": "没有可用的 token",
1488
+ "row.reasonEncrypted": "已加密——需要桌面 App",
1489
+ "row.reasonWrongRegion": "属于另一个地区",
1490
+ "row.sourceDesktop": "桌面 App",
1491
+ "row.sourceDsh": "插件副本",
1492
+ "row.searchedMore": "显示另外 {count} 条「不存在」的路径",
1138
1493
  "row.signedIn": "已登录:{accountName}",
1139
1494
  "row.tokenExpiry": "访问令牌 {expiresAt} 过期(自动续期)",
1140
1495
  "row.reloginHint": "出现错误,重新登录 WorkBuddy APP 即可。",
1496
+ "row.credentialRejectedTitle": "WorkBuddy 拒绝了这个账号的 token",
1497
+ "row.credentialRejectedIntro": "「{accountName}」的 token 已不被上游接受,所以积分与签到都用不了。重新登录只在「确实是 token 的问题」时才有用——当前实际适用的是下面这一条:",
1498
+ "row.credentialRejectedSwitch": "本机另一个账号「{accountName}」刚刚实测可用,切换到它即可恢复,不必重新登录。",
1499
+ "row.credentialRejectedSwitchAction": "切换到「{accountName}」",
1500
+ "row.credentialRejectedRelogin": "本机没有其他账号可切换。请在 WorkBuddy 桌面端重新登录。",
1501
+ "row.credentialRejectedChoose": "本机还有其他账号,但刚才都没能通过上游校验。可以在上方切换一个试试;如果都不行,请在桌面端重新登录。",
1141
1502
  "row.requestFailed": "请求失败",
1142
1503
  "row.creditsTotalLabel": "总剩余积分",
1143
1504
  "row.creditsMonthlyRemain": "剩余 {remain} / {size} · {at} 刷新",
@@ -1151,17 +1512,15 @@ window.__ModuleLoader__.load({
1151
1512
  "row.refresh": "刷新",
1152
1513
  "row.refreshing": "正在刷新…",
1153
1514
  "row.accountsTitle": "WorkBuddy 账号",
1515
+ "row.accountUnnamed": "未命名账号",
1154
1516
  "row.accountsHint": "选择本机检测到的登录账号;Token 不会显示,也不会保存到 DSH 设置。",
1155
1517
  "row.accountsRescan": "重新检测账号",
1156
1518
  "row.accountsScanning": "正在检测…",
1157
- "row.accountsFollowApp": "跟随 App 当前登录",
1158
1519
  "row.accountNoneInEffect": "当前没有生效的账号",
1159
1520
  "row.accountsSavedChoice": "正在使用该区域保存的账号。",
1160
- "row.accountsFollowingApp": "正在跟随 App 当前登录——该区域没有保存账号。",
1161
- "row.accountsCleared": "已清除保存的选择,该区域重新跟随 App 当前登录。",
1162
1521
  "row.accountsWriteFailed": "账号改动未保存:{message} —— 设置文件写入失败(Windows 上可能被杀毒软件或同步盘占用)。当前仍是原来保存的选择,请先关闭占用 settings.yaml 的程序再重试。",
1163
1522
  "row.selectionLostHint": "已保存的账号在本机已不存在。",
1164
- "row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方选择一个,或点「跟随 App 当前登录」清除已保存的选择。重新登录桌面端 App 无法修复此问题。",
1523
+ "row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方列表中重新选择一个当前账号。重新登录桌面端 App 无法修复此问题。",
1165
1524
  "row.modelsTitle": "模型",
1166
1525
  "row.modelsSummary": "已启用 {count} 个",
1167
1526
  "row.modelsRefresh": "从 WorkBuddy 刷新",