dsh-connect-trae 2.10.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -6,6 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
+ let react_dom = require("react-dom");
9
10
  //#region src/status-paths.ts
10
11
  /** Plugin-owned usage endpoint consumed by its browser half. */
11
12
  const TRAE_USAGE_PATH = "/plugins/dsh-connect-trae/usage";
@@ -21,6 +22,17 @@ window.__ModuleLoader__.load({
21
22
  /** Plugin-owned local account rescan endpoint. */
22
23
  const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
23
24
  /**
25
+ * General balance of EVERY account in a region, for the composer panel's table.
26
+ *
27
+ * Separate from the usage route on purpose: the usage document backs the
28
+ * 5-minute readout, and multiplying its upstream reads by the account count
29
+ * would charge a healthy account for something it never looks at — the same
30
+ * principle that keeps `creditAlternatives` firing only when a balance hits
31
+ * zero. This one is paid when the panel is actually opened, which is the only
32
+ * place the table is shown.
33
+ */
34
+ const TRAE_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-trae/account-credits";
35
+ /**
24
36
  * Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
25
37
  * route in this plugin that changes upstream account state.
26
38
  */
@@ -73,6 +85,38 @@ window.__ModuleLoader__.load({
73
85
  return out;
74
86
  }
75
87
  /**
88
+ * Read the per-region account selections out of a settings value.
89
+ *
90
+ * SHARED because `accounts` now has TWO writers: the card, and the composer
91
+ * panel's account table (2.14.0). Two writers of one field must use one merge
92
+ * rule, or they drift — and the rule is not obvious.
93
+ *
94
+ * Deep-unwraps first: on DSH 0.1.7 a volatile field arrives as a `{get(): T}`
95
+ * live reference, which passes the `typeof === 'object'` check and would be
96
+ * returned as if it were the map. Every lookup on it is then `undefined`, and
97
+ * the merge (`{ ...configured, [region]: id }`) would spread a reference into
98
+ * `{get: <function>}` — dropping the other region's selection and leaking a
99
+ * function into the settings document.
100
+ */
101
+ function configuredAccountsOf(configured) {
102
+ const accounts = unwrapVolatileDeep(configured)?.accounts;
103
+ return typeof accounts === "object" && accounts !== null ? accounts : {};
104
+ }
105
+ /**
106
+ * Whether the host-side "follow the Trae app's sign-in" switch is on (issue
107
+ * #29). Absent reads as OFF, so an existing configuration keeps the strict
108
+ * bind it shipped with and the switch is an opt-in — the same opt-out
109
+ * semantics as {@link regionEnabledOf}, for the same reason: a config written
110
+ * before this field existed cannot ask for behavior it never declared.
111
+ *
112
+ * Reads the whole settings section (or the bare field); deep-unwraps first
113
+ * because on DSH 0.1.7 a volatile field arrives as a `{get(): T}` live
114
+ * reference, whose `typeof === 'object'` would otherwise pass a naive check.
115
+ */
116
+ function followAppLoginEnabled(configured) {
117
+ return unwrapVolatileDeep(configured)?.followAppLogin === true;
118
+ }
119
+ /**
76
120
  * Build the next `regions` settings value for the card's save. The write
77
121
  * targets ONLY the signed-in account's region slot; every other region's slot
78
122
  * is carried over untouched, so switching accounts never clobbers the other
@@ -203,6 +247,10 @@ window.__ModuleLoader__.load({
203
247
  .dsm-btn-outline:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed);background:rgba(255,255,255,.04)}
204
248
  .dsm-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
205
249
  .dsm-btn-primary:hover:not(:disabled){opacity:.9}
250
+ .dsm-trae-mainui-switch{display:flex;justify-content:flex-end;padding:0 2px 8px}
251
+ .dsm-trae-mainui-switch-label{display:inline-flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0);white-space:nowrap;cursor:pointer}
252
+ .dsm-trae-mainui-switch-label input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
253
+ .dsm-trae-mainui-switch-label:has(input:disabled){opacity:.6;cursor:default}
206
254
  .dsm-trae-usage{display:flex;flex-direction:column;gap:16px;margin:0;padding:16px 0 4px}
207
255
  .dsm-trae-tabs{display:flex;gap:6px;padding:4px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;background:var(--dsw-alias-bg-layer-3,#2a2c33)}
208
256
  .dsm-trae-tab-cell{display:flex;align-items:center;gap:2px;flex:1;min-width:0}
@@ -267,10 +315,6 @@ window.__ModuleLoader__.load({
267
315
  row at the top and the copy beside the model actions (2.9.3). Without it the
268
316
  checkbox rendered unstyled and read as absent — the whole reason the switch
269
317
  looked missing. */
270
- .dsm-trae-mainui-switch{padding:2px 0 0}
271
- .dsm-trae-mainui-switch-label{display:flex;align-items:center;gap:9px;font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:pointer;white-space:nowrap}
272
- .dsm-trae-mainui-switch-label input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
273
- .dsm-trae-mainui-switch-label:has(input:disabled){opacity:.6;cursor:default}
274
318
  .dsm-trae-models-actions{display:flex;align-items:center;gap:8px;flex:none}
275
319
  }
276
320
  .dsm-trae-usage-alternative{display:flex;align-items:flex-start;gap:10px;margin-top:10px;padding:10px 12px;border:1px solid var(--dsw-alias-state-warning-border,#7a5b12);border-radius:10px;background:var(--dsw-alias-state-warning-bg,#2a2313)}
@@ -320,22 +364,127 @@ window.__ModuleLoader__.load({
320
364
  .dsm-trae-checkin-note{margin:8px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
321
365
  `;
322
366
  /**
323
- * Sidebar credit line styles, injected by the sidebar component itself.
367
+ * Composer-row readout styles, injected by the readout itself.
368
+ *
369
+ * SEPARATE from `TRAE_CARD_CSS`, and that is not tidiness: the card's CSS is
370
+ * only injected when the settings card module loads, so putting the composer's
371
+ * rules in it means the readout renders UNSTYLED in the composer toolbar —
372
+ * which is exactly what shipped in 2.13.0–2.13.4. The trigger fell back to the
373
+ * browser's default button box, and the panel, without `position: fixed`, had
374
+ * its inline left/top ignored and rendered nowhere useful.
324
375
  *
325
- * Separate from the card bundle because the line lives in the shell's sidebar,
326
- * not inside the plugin card; a card that is closed still shows this.
376
+ * `tests/composer-styles.spec.tsx` now asserts that every class this component
377
+ * renders has a rule here, so a rule cannot go missing without a test failing.
327
378
  */
328
- const TRAE_SIDEBAR_CSS = `
329
- .dsm-trae-sidebar-points{display:flex;align-items:center;gap:8px;padding:4px 2px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
330
- .dsm-trae-sidebar-points-line{display:flex;align-items:center;gap:6px;min-width:0;flex:1 1 auto}
331
- .dsm-trae-sidebar-points-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
332
- .dsm-trae-sidebar-points-failed{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:11px;line-height:16px;white-space:nowrap}
333
- .dsm-trae-sidebar-points-hide{appearance:none;flex:none;font:inherit;font-size:14px;line-height:1;padding:2px 6px;border:0;border-radius:6px;color:var(--dsw-alias-label-tertiary,#999);background:transparent;cursor:pointer}
334
- .dsm-trae-sidebar-points-hide:hover{color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-3,#2c2f36)}
335
- .dsm-trae-sidebar-points-refresh{appearance:none;flex:none;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:7px;color:var(--dsw-alias-label-secondary,#c6c9d0);background:transparent;cursor:pointer}
336
- .dsm-trae-sidebar-points-refresh:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e6e6e6);border-color:var(--dsw-alias-label-dimmed,#777)}
337
- .dsm-trae-sidebar-points-refresh:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
338
- .dsm-trae-sidebar-points-refresh:disabled{opacity:.5;cursor:default}
379
+ const COMPOSER_POINTS_CSS = `
380
+ /* The readout is plain composer text — no border, no fill at rest. The padding
381
+ is only the click target, invisible until the pointer arrives. */
382
+ .dsm-trae-composer-points{display:inline-flex;align-items:center;min-width:0}
383
+ .dsm-trae-composer-points-trigger{
384
+ appearance:none;display:inline-flex;align-items:center;align-self:stretch;
385
+ padding:2px 6px;border:0;border-radius:6px;background:transparent;
386
+ /* EXPLICIT, not inherit. The composer row's own font and body colour are
387
+ noticeably larger and darker than the neighbouring controls — a credit
388
+ readout is secondary information and must not shout. 12px matches this
389
+ plugin's original line; label-tertiary matches the shell's own composer-row
390
+ control (ContextMeter), which is the sibling this reads alongside. */
391
+ font-size:12px;line-height:18px;
392
+ color:var(--dsw-alias-label-tertiary,#999);
393
+ white-space:nowrap;cursor:pointer;
394
+ font-variant-numeric:tabular-nums;
395
+ }
396
+ .dsm-trae-composer-points-trigger:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}
397
+ .dsm-trae-composer-points-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
398
+ /* The panel is portaled to document.body and positioned from the trigger, so it
399
+ MUST be fixed — otherwise the left/top it measures are ignored and it lands in
400
+ document flow. All material comes from shell tokens so it follows the theme. */
401
+ .dsm-trae-composer-panel{
402
+ position:fixed;z-index:1100;box-sizing:border-box;
403
+ width:min(248px,calc(100vw - 24px));padding:12px;
404
+ border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.28));
405
+ border-radius:var(--dsw-radius-lg,14px);
406
+ background:var(--dsw-specific-menu,Canvas);
407
+ box-shadow:var(--dsw-elevation-prominent,0 8px 32px rgba(0,0,0,.22));
408
+ backdrop-filter:var(--dsw-menu-backdrop-filter,none);
409
+ font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#444);
410
+ }
411
+ .dsm-trae-composer-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}
412
+ .dsm-trae-composer-panel-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,CanvasText)}
413
+ /* Self-contained: the dsm-btn primitive lives in TRAE_CARD_CSS, which the
414
+ composer never loads, so the refresh button must style itself. */
415
+ .dsm-trae-composer-panel-refresh{
416
+ appearance:none;flex:none;margin-left:auto;padding:3px 10px;
417
+ border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.34));
418
+ border-radius:7px;background:transparent;
419
+ color:var(--dsw-alias-label-secondary,#444);
420
+ font:inherit;font-size:11px;line-height:16px;cursor:pointer;
421
+ }
422
+ .dsm-trae-composer-panel-refresh:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed,rgba(127,127,127,.6));color:var(--dsw-alias-label-primary,CanvasText)}
423
+ .dsm-trae-composer-panel-refresh:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
424
+ .dsm-trae-composer-panel-refresh:disabled{opacity:.5;cursor:default}
425
+ /* The panel is a TABLE: one row per account in the region, one figure (the
426
+ general balance) per row, and a row click switches accounts.
427
+
428
+ Rhythm is deliberate: the header sits on a hairline, the switch button owns
429
+ ALL horizontal padding (and pulls back by the same amount so its label lines
430
+ up with the header), and every row is the same height. A misaligned first
431
+ column is what makes a two-column popover read as broken. */
432
+ .dsm-trae-composer-panel-table{
433
+ width:100%;border-collapse:collapse;
434
+ font-size:12px;line-height:18px;
435
+ }
436
+ .dsm-trae-composer-panel-table thead th{
437
+ padding:0 0 7px;font-weight:500;text-align:left;font-size:11px;
438
+ color:var(--dsw-alias-label-tertiary,GrayText);
439
+ border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));
440
+ }
441
+ .dsm-trae-composer-panel-num{
442
+ text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;
443
+ }
444
+ .dsm-trae-composer-panel-table thead th.dsm-trae-composer-panel-num{padding-right:0}
445
+ .dsm-trae-composer-panel-table tbody th{
446
+ padding:0;font-weight:400;text-align:left;vertical-align:middle;
447
+ }
448
+ .dsm-trae-composer-panel-table tbody td{
449
+ padding:4px 0;vertical-align:middle;
450
+ color:var(--dsw-alias-label-primary,CanvasText);
451
+ }
452
+ /* The current account reads as current without shouting: a filled dot and a
453
+ slightly heavier weight. Disabled — pressing it would write a selection that
454
+ has not changed. */
455
+ .dsm-trae-composer-panel-table tbody tr.dsm-trae-composer-panel-row-current td{font-weight:600}
456
+ .dsm-trae-composer-panel-switch{
457
+ appearance:none;display:flex;align-items:center;gap:7px;
458
+ width:100%;padding:4px 6px;margin-left:-6px;
459
+ border:0;border-radius:6px;background:transparent;
460
+ color:inherit;font:inherit;font-size:12px;text-align:left;cursor:pointer;
461
+ white-space:nowrap;overflow:hidden;
462
+ }
463
+ .dsm-trae-composer-panel-switch>span:last-child{overflow:hidden;text-overflow:ellipsis}
464
+ /* Hover belongs to the switch, not the row: the number cell is not clickable,
465
+ so lighting it up would promise an action it cannot perform. */
466
+ .dsm-trae-composer-panel-switch:hover:not(:disabled){
467
+ background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14));
468
+ }
469
+ .dsm-trae-composer-panel-switch:focus-visible{
470
+ outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:-1px;
471
+ }
472
+ .dsm-trae-composer-panel-switch:disabled{cursor:default}
473
+ .dsm-trae-composer-panel-dot{
474
+ flex:none;width:7px;height:7px;border-radius:50%;
475
+ background:var(--dsw-alias-label-dimmed,rgba(127,127,127,.5));
476
+ }
477
+ .dsm-trae-composer-panel-table tr.dsm-trae-composer-panel-row-current .dsm-trae-composer-panel-dot{
478
+ background:var(--dsw-state-success,#22a06b);
479
+ }
480
+ .dsm-trae-composer-panel-foot{
481
+ display:flex;justify-content:space-between;gap:8px;
482
+ margin-top:9px;padding-top:7px;
483
+ border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));
484
+ font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,GrayText);
485
+ }
486
+ .dsm-trae-composer-panel-empty{margin:0;color:var(--dsw-alias-label-tertiary,GrayText)}
487
+ .dsm-trae-composer-panel-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#d92d20);font-size:11px;line-height:16px}
339
488
  `;
340
489
  //#endregion
341
490
  //#region src/client/TraeUsageCard.tsx
@@ -348,20 +497,6 @@ window.__ModuleLoader__.load({
348
497
  const POLL_INTERVAL_MS = 6e4;
349
498
  const TRAE_GITHUB_URL = "https://github.com/dingminhua/dsh-connect-trae";
350
499
  /**
351
- * Read the per-region account selections out of the settings snapshot.
352
- *
353
- * Deep-unwraps first: on DSH 0.1.7 a volatile field arrives as a `{get(): T}`
354
- * live reference, which passes the `typeof === 'object'` check below and would
355
- * be returned as if it were the map. Every lookup on it is then `undefined`,
356
- * and the card's merge (`{ ...configured, [region]: id }`) would spread a
357
- * reference into `{get: <function>}` — dropping the other region's selection
358
- * and leaking a function into the settings document.
359
- */
360
- function configuredAccountsOf(configured) {
361
- const accounts = unwrapVolatileDeep(configured)?.accounts;
362
- return typeof accounts === "object" && accounts !== null ? accounts : {};
363
- }
364
- /**
365
500
  * Write one settings field and confirm the value actually landed.
366
501
  *
367
502
  * `configForms.set()` resolving is NOT proof that anything was stored: a write
@@ -401,7 +536,7 @@ window.__ModuleLoader__.load({
401
536
  document.head.appendChild(styleTag);
402
537
  }
403
538
  }
404
- function formatNumber$1(value) {
539
+ function formatNumber(value) {
405
540
  return new Intl.NumberFormat(void 0, {
406
541
  minimumFractionDigits: 0,
407
542
  maximumFractionDigits: 0
@@ -420,7 +555,7 @@ window.__ModuleLoader__.load({
420
555
  if (value === void 0) return unknown;
421
556
  if (value >= 1e6 && value % 1e6 === 0) return `${value / 1e6}M`;
422
557
  if (value >= 1e3 && value % 1e3 === 0) return `${value / 1e3}K`;
423
- return formatNumber$1(value);
558
+ return formatNumber(value);
424
559
  }
425
560
  /**
426
561
  * The subscription row's verdict.
@@ -512,24 +647,6 @@ window.__ModuleLoader__.load({
512
647
  */
513
648
  const [claiming, setClaiming] = (0, react.useState)(false);
514
649
  /** Last claim refusal, shown until the next successful refresh. */
515
- /**
516
- * Toggle the global "show credits at the DSH sidebar foot" switch. Unlike the
517
- * per-region provider switch this is one top-level boolean shared by both
518
- * regions; only that field is written, and the read-back must show the new
519
- * value before success is reported.
520
- */
521
- const toggleShowPointsInMainUi = async (enabled) => {
522
- if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
523
- setTogglingMainUi(true);
524
- setWriteError(void 0);
525
- try {
526
- await writeSettingsField(settingsScope, "showPointsInMainUi", enabled, (readBack) => unwrapVolatileDeep(readBack)?.showPointsInMainUi === enabled);
527
- } catch (error) {
528
- if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
529
- } finally {
530
- if (mounted.current) setTogglingMainUi(false);
531
- }
532
- };
533
650
  const [claimError, setClaimError] = (0, react.useState)(void 0);
534
651
  /**
535
652
  * Outcome of the last "refresh accounts" press (issue #25).
@@ -540,7 +657,6 @@ window.__ModuleLoader__.load({
540
657
  * failure. Reporting the outcome is the difference between "it did nothing"
541
658
  * and "it found the same 2 accounts, still on X".
542
659
  */
543
- const [togglingMainUi, setTogglingMainUi] = (0, react.useState)(false);
544
660
  const [rescanNote, setRescanNote] = (0, react.useState)(void 0);
545
661
  /**
546
662
  * Whether this MACHINE has spent today's check-in while the selected account
@@ -556,6 +672,8 @@ window.__ModuleLoader__.load({
556
672
  * the control would flip and then quietly snap back on the next snapshot.
557
673
  */
558
674
  const [writeError, setWriteError] = (0, react.useState)(void 0);
675
+ /** True while the composer-credit switch write is in flight. */
676
+ const [togglingCredits, setTogglingCredits] = (0, react.useState)(false);
559
677
  const mounted = (0, react.useRef)(true);
560
678
  (0, react.useEffect)(() => {
561
679
  mounted.current = true;
@@ -563,6 +681,24 @@ window.__ModuleLoader__.load({
563
681
  mounted.current = false;
564
682
  };
565
683
  }, []);
684
+ /**
685
+ * Toggle the composer credit readout (`showPointsInMainUi`). The card's
686
+ * checkbox is the ONLY writer of this field — the readout itself carries no
687
+ * control — so there is exactly one path that can change it, and the write is
688
+ * read-back-checked like every other settings write in this card.
689
+ */
690
+ const toggleShowPointsInMainUi = async (enabled) => {
691
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
692
+ setTogglingCredits(true);
693
+ setWriteError(void 0);
694
+ try {
695
+ await writeSettingsField(settingsScope, "showPointsInMainUi", enabled, (readBack) => unwrapVolatileDeep(readBack)?.showPointsInMainUi === enabled);
696
+ } catch (error) {
697
+ if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
698
+ } finally {
699
+ if (mounted.current) setTogglingCredits(false);
700
+ }
701
+ };
566
702
  (0, react.useEffect)(() => settingsScope?.subscribe(() => {
567
703
  setSettingsRevision((value) => value + 1);
568
704
  }), [settingsScope]);
@@ -700,6 +836,36 @@ window.__ModuleLoader__.load({
700
836
  }
701
837
  };
702
838
  /**
839
+ * Follow the Trae app's current sign-in from the hint row (issue #29).
840
+ *
841
+ * One click, and it goes through the SAME `switchAccount` write the account
842
+ * dropdown uses — so the card has exactly one writer for `accounts`, the Host
843
+ * sees the change through `loader/volatile-update` like every other card
844
+ * write, and there is no second settings path to keep in sync. The app's
845
+ * sign-in is `status.appLogin`, which the Host only reports when it differs
846
+ * from the bound account, so a click here always moves the binding to what
847
+ * the app is actually logged into.
848
+ */
849
+ const followAppLogin = async () => {
850
+ if (status.status !== "signed-in" || status.appLogin === void 0) return;
851
+ await switchAccount(status.appLogin.id);
852
+ };
853
+ /**
854
+ * Toggle the follow-the-app switch (`followAppLogin`, issue #29): the Host's
855
+ * automatic rebind follows this field, so the card only commits it. OFF by
856
+ * default in the schema; an existing config keeps the strict bind it shipped
857
+ * with until the user ticks this.
858
+ */
859
+ const toggleFollowAppLogin = async (enabled) => {
860
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
861
+ setWriteError(void 0);
862
+ try {
863
+ await writeSettingsField(settingsScope, "followAppLogin", enabled, (readBack) => followAppLoginEnabled(readBack) === enabled);
864
+ } catch (error) {
865
+ if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
866
+ }
867
+ };
868
+ /**
703
869
  * Claim today's check-in reward for the active tab's region.
704
870
  *
705
871
  * The Host route owns every guard that matters — POST only, loopback origin
@@ -1039,21 +1205,6 @@ window.__ModuleLoader__.load({
1039
1205
  children: open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1040
1206
  className: "dsm-trae-usage",
1041
1207
  children: [
1042
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1043
- className: "dsm-trae-mainui-switch",
1044
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1045
- className: "dsm-trae-mainui-switch-label",
1046
- title: t("row.showPointsInMainUiHint"),
1047
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1048
- type: "checkbox",
1049
- checked: settingsValue?.showPointsInMainUi === true,
1050
- disabled: settingsScope?.getSnapshot().writable !== true || togglingMainUi,
1051
- onChange: (event) => {
1052
- toggleShowPointsInMainUi(event.currentTarget.checked);
1053
- }
1054
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.showPointsInMainUi") })]
1055
- })
1056
- }),
1057
1208
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1058
1209
  className: "dsm-trae-tabs",
1059
1210
  role: "tablist",
@@ -1107,6 +1258,36 @@ window.__ModuleLoader__.load({
1107
1258
  role: "status",
1108
1259
  children: t("row.tabOffNotice")
1109
1260
  }),
1261
+ activeRegion !== "cn" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1262
+ className: "dsm-trae-mainui-switch",
1263
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1264
+ className: "dsm-trae-mainui-switch-label",
1265
+ title: t("row.showPointsInMainUiHint"),
1266
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1267
+ type: "checkbox",
1268
+ checked: settingsValue?.showPointsInMainUi === true,
1269
+ disabled: settingsScope?.getSnapshot().writable !== true || togglingCredits,
1270
+ onChange: (event) => {
1271
+ toggleShowPointsInMainUi(event.currentTarget.checked);
1272
+ }
1273
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.showPointsInMainUi") })]
1274
+ })
1275
+ }),
1276
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1277
+ className: "dsm-trae-mainui-switch",
1278
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1279
+ className: "dsm-trae-mainui-switch-label",
1280
+ title: t("row.followAppLoginHint"),
1281
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1282
+ type: "checkbox",
1283
+ checked: followAppLoginEnabled(settingsValue),
1284
+ disabled: settingsScope?.getSnapshot().writable !== true,
1285
+ onChange: (event) => {
1286
+ toggleFollowAppLogin(event.currentTarget.checked);
1287
+ }
1288
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.followAppLogin") })]
1289
+ })
1290
+ }),
1110
1291
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1111
1292
  className: "dsm-trae-usage-account",
1112
1293
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1166,7 +1347,7 @@ window.__ModuleLoader__.load({
1166
1347
  children: [
1167
1348
  t("row.creditsExhausted", { account: status.accountName }),
1168
1349
  " ",
1169
- t("row.creditsAlternative", { accounts: status.alternatives.map((alt) => `${alt.accountName}(${t(`row.edition${alt.edition === "cn" ? "Cn" : alt.edition === "sg" ? "Sg" : alt.edition === "solo" ? "Solo" : "SoloSg"}`)}:${formatNumber$1(alt.workAvailable + alt.generalAvailable)})`).join("、") })
1350
+ t("row.creditsAlternative", { accounts: status.alternatives.map((alt) => `${alt.accountName}(${t(`row.edition${alt.edition === "cn" ? "Cn" : alt.edition === "sg" ? "Sg" : alt.edition === "solo" ? "Solo" : "SoloSg"}`)}:${formatNumber(alt.workAvailable + alt.generalAvailable)})`).join("、") })
1170
1351
  ]
1171
1352
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1172
1353
  type: "button",
@@ -1179,6 +1360,22 @@ window.__ModuleLoader__.load({
1179
1360
  children: t("row.creditsAlternativeSwitch")
1180
1361
  })]
1181
1362
  }) : null,
1363
+ status.status === "signed-in" && status.appLogin !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1364
+ className: "dsm-trae-usage-alternative",
1365
+ role: "status",
1366
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1367
+ className: "dsm-trae-usage-alternative-text",
1368
+ children: t("row.appLoginMismatch", { account: status.appLogin.accountName })
1369
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1370
+ type: "button",
1371
+ className: "dsm-trae-usage-switch-button",
1372
+ disabled: switchingAccount || settingsScope?.getSnapshot()?.writable !== true,
1373
+ onClick: () => {
1374
+ followAppLogin();
1375
+ },
1376
+ children: t("row.appLoginFollow")
1377
+ })]
1378
+ }) : null,
1182
1379
  rescanNote === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1183
1380
  className: "dsm-trae-usage-note",
1184
1381
  role: "status",
@@ -1244,7 +1441,7 @@ window.__ModuleLoader__.load({
1244
1441
  }),
1245
1442
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1246
1443
  className: "dsm-trae-usage-stat-value dsm-trae-usage-stat-value-work",
1247
- children: formatNumber$1(status.credits.workAvailable)
1444
+ children: formatNumber(status.credits.workAvailable)
1248
1445
  }),
1249
1446
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1250
1447
  className: "dsm-trae-usage-stat-hint",
@@ -1266,7 +1463,7 @@ window.__ModuleLoader__.load({
1266
1463
  }),
1267
1464
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1268
1465
  className: "dsm-trae-usage-stat-value dsm-trae-usage-stat-value-general",
1269
- children: formatNumber$1(status.credits.generalAvailable)
1466
+ children: formatNumber(status.credits.generalAvailable)
1270
1467
  }),
1271
1468
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1272
1469
  className: "dsm-trae-usage-stat-hint",
@@ -1283,8 +1480,8 @@ window.__ModuleLoader__.load({
1283
1480
  status.checkin === void 0 ? null : (() => {
1284
1481
  const accountClaimed = status.checkin.checkedIn;
1285
1482
  const deviceSpent = !accountClaimed && (status.checkin.didCheckedIn || deviceSpentNotice);
1286
- const reward = formatNumber$1(status.checkin.credits);
1287
- const bonus = status.checkin.extraCredits === void 0 ? "" : ` + ${formatNumber$1(status.checkin.extraCredits)}`;
1483
+ const reward = formatNumber(status.checkin.credits);
1484
+ const bonus = status.checkin.extraCredits === void 0 ? "" : ` + ${formatNumber(status.checkin.extraCredits)}`;
1288
1485
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1289
1486
  className: "dsm-trae-checkin",
1290
1487
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
@@ -1338,42 +1535,27 @@ window.__ModuleLoader__.load({
1338
1535
  children: t("row.modelsSummary", { count: activeEnabledIds.size })
1339
1536
  })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1340
1537
  className: "dsm-trae-models-actions",
1341
- children: [
1342
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1343
- className: "dsm-trae-mainui-switch-label",
1344
- title: t("row.showPointsInMainUiHint"),
1345
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1346
- type: "checkbox",
1347
- checked: settingsValue?.showPointsInMainUi === true,
1348
- disabled: settingsScope?.getSnapshot().writable !== true || togglingMainUi,
1349
- onChange: (event) => {
1350
- toggleShowPointsInMainUi(event.currentTarget.checked);
1351
- }
1352
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.showPointsInMainUi") })]
1353
- }),
1354
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1355
- type: "button",
1356
- className: "dsm-btn dsm-btn-outline",
1357
- disabled: busy,
1358
- onClick: () => {
1359
- refreshModels();
1360
- },
1361
- children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
1362
- }),
1363
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1364
- type: "button",
1365
- className: "dsm-btn dsm-btn-outline",
1366
- disabled: busy || testing !== void 0 || activeEnabledIds.size === 0,
1367
- title: t("row.modelsTestHint"),
1368
- onClick: () => {
1369
- testEnabledModels();
1370
- },
1371
- children: testing === void 0 ? t("row.modelsTestEnabled") : t("row.modelsTesting", {
1372
- done: testing.done,
1373
- total: testing.total
1374
- })
1538
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1539
+ type: "button",
1540
+ className: "dsm-btn dsm-btn-outline",
1541
+ disabled: busy,
1542
+ onClick: () => {
1543
+ refreshModels();
1544
+ },
1545
+ children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
1546
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1547
+ type: "button",
1548
+ className: "dsm-btn dsm-btn-outline",
1549
+ disabled: busy || testing !== void 0 || activeEnabledIds.size === 0,
1550
+ title: t("row.modelsTestHint"),
1551
+ onClick: () => {
1552
+ testEnabledModels();
1553
+ },
1554
+ children: testing === void 0 ? t("row.modelsTestEnabled") : t("row.modelsTesting", {
1555
+ done: testing.done,
1556
+ total: testing.total
1375
1557
  })
1376
- ]
1558
+ })]
1377
1559
  })]
1378
1560
  }),
1379
1561
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -1540,233 +1722,544 @@ window.__ModuleLoader__.load({
1540
1722
  });
1541
1723
  }
1542
1724
  //#endregion
1543
- //#region src/client/SidebarPoints.tsx
1725
+ //#region src/client/popover.ts
1726
+ /**
1727
+ * The two shell behaviours the composer credit panel needs: hanging the panel
1728
+ * off its trigger, and dismissing it on an outside press.
1729
+ *
1730
+ * Implemented locally rather than imported from
1731
+ * `@deepseek-ai/dsh-client-ui-primitives`. The shell DOES register that package
1732
+ * for plugin bundles, so importing it is legal — this is a deliberate choice,
1733
+ * not a workaround: the two behaviours are ~40 lines of DOM geometry, and owning
1734
+ * them keeps this component renderable in the Node test suite without mocking a
1735
+ * package that itself reaches `*.module.css` and further DSH dependencies.
1736
+ *
1737
+ * NOTE ON A MISDIAGNOSIS, kept because it is the kind of thing that gets
1738
+ * re-litigated: the 2.12.0/2.13.0 client load failure was first attributed to
1739
+ * importing the primitives package. It was not that. The real cause was
1740
+ * `import { createPortal } from 'react-dom'` being BUNDLED rather than
1741
+ * externalized — the inlined development build opens with
1742
+ * `process.env.NODE_ENV` and threw `ReferenceError: process is not defined` in
1743
+ * the browser. See `tsdown.config.ts` for the fix and
1744
+ * `tests/client-runtime-imports.spec.ts` for the rule that now prevents it.
1745
+ *
1746
+ * Behaviour is intentionally identical to the shell's implementations: the same
1747
+ * top/bottom placement, the same viewport clamping, the same re-measure on
1748
+ * scroll (capture, so nested scrollers count), resize and the panel's own size
1749
+ * changes, and the same "a pointerdown outside both the trigger and the panel
1750
+ * closes it" rule.
1751
+ */
1752
+ /**
1753
+ * Clearance to keep from the top of the viewport.
1754
+ *
1755
+ * The shell reserves a strip at the top of the frame (`--dsh-frame-top-clearance`)
1756
+ * for the window chrome, so a panel clamped to `margin` alone can still land
1757
+ * under it. Absent variable or no `data-fullscreen` distinction is handled the
1758
+ * same way the shell does: fall back to `min`.
1759
+ */
1760
+ function overlayTopMargin(min) {
1761
+ if (typeof document === "undefined") return min;
1762
+ const root = document.documentElement;
1763
+ const clearance = Number.parseFloat(getComputedStyle(root).getPropertyValue("--dsh-frame-top-clearance"));
1764
+ if (Number.isNaN(clearance)) return min;
1765
+ return Math.max(min, (root.hasAttribute("data-fullscreen") ? 0 : clearance) + 20);
1766
+ }
1767
+ /** Keep a `position: fixed` floating element anchored to a trigger. */
1768
+ function useAnchoredPosition(options) {
1769
+ const { open, anchorRef, panelRef, side = "bottom", align = "start", gap, margin } = options;
1770
+ const [position, setPosition] = (0, react.useState)(null);
1771
+ (0, react.useLayoutEffect)(() => {
1772
+ if (!open) {
1773
+ setPosition(null);
1774
+ return;
1775
+ }
1776
+ const place = () => {
1777
+ const rect = anchorRef.current?.getBoundingClientRect();
1778
+ if (rect === void 0) return;
1779
+ const panel = panelRef.current;
1780
+ const width = panel?.offsetWidth ?? 0;
1781
+ const height = panel?.offsetHeight ?? 0;
1782
+ let left = align === "end" ? rect.right - width : rect.left;
1783
+ let top = side === "top" ? rect.top - gap - height : rect.bottom + gap;
1784
+ if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin);
1785
+ if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin)), window.innerHeight - height - margin);
1786
+ setPosition({
1787
+ left,
1788
+ top
1789
+ });
1790
+ };
1791
+ place();
1792
+ window.addEventListener("scroll", place, true);
1793
+ window.addEventListener("resize", place);
1794
+ const panel = panelRef.current;
1795
+ let observer = null;
1796
+ if (typeof ResizeObserver !== "undefined" && panel !== null) {
1797
+ observer = new ResizeObserver(place);
1798
+ observer.observe(panel);
1799
+ }
1800
+ return () => {
1801
+ observer?.disconnect();
1802
+ window.removeEventListener("scroll", place, true);
1803
+ window.removeEventListener("resize", place);
1804
+ };
1805
+ }, [
1806
+ open,
1807
+ anchorRef,
1808
+ panelRef,
1809
+ side,
1810
+ align,
1811
+ gap,
1812
+ margin
1813
+ ]);
1814
+ return position;
1815
+ }
1816
+ /**
1817
+ * Close an open popover when a pointerdown lands outside it.
1818
+ *
1819
+ * `portal` counts as inside: the panel is portaled to `document.body`, so it is
1820
+ * not a descendant of `root` and a click on the panel itself would otherwise
1821
+ * read as "outside" and dismiss the popover the user is trying to use.
1822
+ */
1823
+ function useDismissOnOutsidePointer(root, open, setOpen, portal) {
1824
+ (0, react.useEffect)(() => {
1825
+ if (!open) return void 0;
1826
+ const closeOutside = (event) => {
1827
+ if (event.target instanceof Node && root.current?.contains(event.target) !== true && portal?.current?.contains(event.target) !== true) setOpen(false);
1828
+ };
1829
+ document.addEventListener("pointerdown", closeOutside);
1830
+ return () => {
1831
+ document.removeEventListener("pointerdown", closeOutside);
1832
+ };
1833
+ }, [
1834
+ root,
1835
+ open,
1836
+ setOpen,
1837
+ portal
1838
+ ]);
1839
+ }
1840
+ //#endregion
1841
+ //#region src/client/ComposerPoints.tsx
1544
1842
  /**
1545
- * Compact credit line contributed to the DSH main sidebar foot
1546
- * (`sidebar.footer.action`).
1843
+ * Credit readout for the composer tool row (`conversation.input.left`).
1844
+ *
1845
+ * Shows the bound CN account's general credit balance, but ONLY while the
1846
+ * session's selected model belongs to this plugin. That condition is the whole
1847
+ * point: the sidebar foot is a single shared row, so two connector plugins each
1848
+ * parking a line there end up fighting for the same space. The composer row is
1849
+ * per-session and provider-scoped, so exactly one plugin's credits are relevant
1850
+ * at a time and nothing overlaps.
1851
+ *
1852
+ * The row itself is a bare clickable readout — `Trae CN · 657` — with NO
1853
+ * refresh control. Clicking it opens an anchored panel: a TABLE with one row
1854
+ * per account in the region and that account's general balance, where a row
1855
+ * click switches to it. The manual refresh button lives in the panel too.
1856
+ *
1857
+ * Two deliberate choices in that panel:
1547
1858
  *
1548
- * Mounted only while the plugin-card switch `showPointsInMainUi` is on, so the
1549
- * slot renders nothing when the feature is off. On mount it fetches the bound
1550
- * CN account's remaining credits ONCE and only then starts the periodic
1551
- * refresh timer; the timer is cleared on unmount (switch off / plugin unload),
1552
- * so timers never stack.
1859
+ * - GENERAL balance only, not Work credits. The table's one figure is the
1860
+ * bucket the SOLO chat actually spends; two columns of numbers in a
1861
+ * five-slot popover is noise, and the card is where the breakdown belongs.
1862
+ * - Per-account figures are fetched ONLY while the panel is open. The
1863
+ * 5-minute readout must not multiply its upstream reads by the account
1864
+ * count — a healthy balance should not pay for a table nobody is looking at.
1865
+ * This is the same principle that keeps `creditAlternatives` firing only
1866
+ * when a balance hits zero.
1553
1867
  *
1554
- * A failed refresh keeps the last known value and surfaces a short failure
1555
- * note; an in-flight refresh ignores further clicks (debounce).
1868
+ * The provider comes from the Host's own `modelSelection` projection rather
1869
+ * than from anything this plugin tracks: the projection is what the model
1870
+ * picker writes and what the next request will use, so the readout cannot
1871
+ * disagree with the model actually in effect.
1556
1872
  */
1557
1873
  /**
1558
- * Automatic refresh period. The first fetch happens immediately on enable;
1559
- * this is the gap between subsequent automatic fetches.
1874
+ * Inject the readout's styles into the document head, once, on module load.
1560
1875
  *
1561
- * Five minutes, not the card's 60s: a credit balance moves on the scale of
1562
- * model calls, not seconds, and this line is a glanceable reminder rather than
1563
- * a live meter. Each poll is two read-only upstream calls, so a tighter period
1564
- * would buy nothing a user can act on.
1876
+ * Same idempotent pattern as the card's injection, and for the same reason: the
1877
+ * classes are attached to DOM this component owns, and `.dsm-card-*` lives in
1878
+ * `TRAE_CARD_CSS` — which only loads when the settings card does. Before this
1879
+ * existed (2.13.0–2.13.4) the trigger rendered as the browser's default button
1880
+ * box and the panel, with no `position: fixed`, had its measured left/top
1881
+ * ignored and appeared nowhere.
1565
1882
  */
1566
- const SIDEBAR_POINTS_REFRESH_INTERVAL_MS = 3e5;
1567
- /** Inject the sidebar-line CSS once. */
1568
1883
  if (typeof document !== "undefined") {
1569
- const cssId = "dsh-connect-trae/sidebar.css";
1884
+ const cssId = "dsh-connect-trae/composer-points.css";
1570
1885
  if (!document.querySelector(`style[data-plugin-css="${cssId}"]`)) {
1571
1886
  const styleTag = document.createElement("style");
1572
1887
  styleTag.dataset.plugin = "dsh-connect-trae";
1573
1888
  styleTag.dataset.pluginCss = cssId;
1574
- styleTag.textContent = TRAE_SIDEBAR_CSS;
1889
+ styleTag.textContent = COMPOSER_POINTS_CSS;
1575
1890
  document.head.appendChild(styleTag);
1576
1891
  }
1577
1892
  }
1578
- function formatNumber(value) {
1579
- return new Intl.NumberFormat(void 0, { maximumFractionDigits: 0 }).format(value);
1580
- }
1581
- function formatClock(value) {
1582
- return new Intl.DateTimeFormat(void 0, {
1583
- hour: "2-digit",
1584
- minute: "2-digit",
1585
- second: "2-digit"
1586
- }).format(new Date(value));
1587
- }
1588
1893
  /**
1589
- * The GENERAL credit balance out of one usage-route document.
1894
+ * Automatic refresh period. Five minutes, matching the sidebar line: a credit
1895
+ * balance moves on the scale of model calls, not seconds, and each read is two
1896
+ * read-only upstream calls.
1897
+ */
1898
+ const COMPOSER_POINTS_REFRESH_INTERVAL_MS = 3e5;
1899
+ /**
1900
+ * Provider routes whose credit balance this readout can show, mapped to the
1901
+ * region to read it from.
1902
+ *
1903
+ * CN ONLY, and the restriction is the data's, not a preference: the Host builds
1904
+ * the international region's usage document from `payStatus` and never sets
1905
+ * `credits` on it (`webStatusFor` returns early for `region === 'ai'`), because
1906
+ * that side is subscription-based and has no comparable balance. Mapping
1907
+ * `trae-global` here would render the hardcoded "Trae CN" label next to a dash
1908
+ * — a number-slot with no number — while an international model is selected.
1590
1909
  *
1591
- * Deliberately `generalAvailable`, NOT `credits.available`. The latter is the
1592
- * upstream aggregate (`summary.totalAmount - consumedAmount`), while the card
1593
- * shows two buckets derived from the entitlement packs; the aggregate is not
1594
- * guaranteed to equal the buckets, and a sidebar number the user cannot match
1595
- * against the card is worse than no number at all. The general bucket is the
1596
- * one the SOLO chat path spends, so it is the one worth watching.
1910
+ * Mirrors the CN half of `TRAE_PROVIDERS` on the host side; duplicated here
1911
+ * because the client bundle must not import the host entry.
1597
1912
  */
1913
+ const TRAE_COMPOSER_PROVIDERS = { "trae": "cn" };
1914
+ /** The general credit balance out of one usage document, or undefined. */
1598
1915
  function generalCreditsOf(usage) {
1599
1916
  if (usage.status !== "signed-in") return void 0;
1600
1917
  return usage.credits?.generalAvailable;
1601
1918
  }
1602
- /** One fetch+render cycle. Returns false when the request failed. */
1603
- function SidebarPoints({ t, onHide }) {
1604
- if (t === void 0) throw new Error("Sidebar points line requires its translation function");
1605
- const [points, setPoints] = (0, react.useState)(void 0);
1606
- const [signedIn, setSignedIn] = (0, react.useState)(true);
1919
+ /** Group the digits so a four-figure balance stays readable in a narrow row. */
1920
+ function formatCredits(value) {
1921
+ return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
1922
+ }
1923
+ function formatClock(value) {
1924
+ return new Date(value).toLocaleTimeString("zh-CN", {
1925
+ hour: "2-digit",
1926
+ minute: "2-digit"
1927
+ });
1928
+ }
1929
+ function ComposerPoints(props) {
1930
+ const { t, provider, region, settingsScope } = props;
1931
+ if (t === void 0) throw new Error("Composer points readout requires its translation function");
1932
+ const owned = provider === void 0 ? void 0 : TRAE_COMPOSER_PROVIDERS[provider];
1933
+ const activeRegion = region ?? owned;
1934
+ const [usage, setUsage] = (0, react.useState)(void 0);
1935
+ const [accountCredits, setAccountCredits] = (0, react.useState)(void 0);
1607
1936
  const [busy, setBusy] = (0, react.useState)(false);
1937
+ const [switchingId, setSwitchingId] = (0, react.useState)(void 0);
1608
1938
  const [failed, setFailed] = (0, react.useState)(false);
1609
1939
  const [lastRefresh, setLastRefresh] = (0, react.useState)(void 0);
1610
- /** True while a request is in flight; debounces repeated refresh clicks. */
1940
+ const [open, setOpen] = (0, react.useState)(false);
1611
1941
  const inFlight = (0, react.useRef)(false);
1612
1942
  const mounted = (0, react.useRef)(true);
1943
+ const rootRef = (0, react.useRef)(null);
1944
+ const panelRef = (0, react.useRef)(null);
1613
1945
  (0, react.useEffect)(() => {
1614
1946
  mounted.current = true;
1615
1947
  return () => {
1616
1948
  mounted.current = false;
1617
1949
  };
1618
1950
  }, []);
1619
- const fetchPoints = (0, react.useCallback)(async (signal) => {
1620
- if (inFlight.current) return false;
1951
+ const position = useAnchoredPosition({
1952
+ open: open && activeRegion !== void 0,
1953
+ anchorRef: rootRef,
1954
+ panelRef,
1955
+ side: "top",
1956
+ gap: 8,
1957
+ margin: 12
1958
+ });
1959
+ useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef);
1960
+ const fetchUsage = (0, react.useCallback)(async (signal) => {
1961
+ if (inFlight.current || activeRegion === void 0) return;
1621
1962
  inFlight.current = true;
1622
1963
  if (mounted.current) setBusy(true);
1623
1964
  try {
1624
- const response = await fetch(withTraeRegion(TRAE_USAGE_PATH, "cn"), {
1965
+ const response = await fetch(withTraeRegion(TRAE_USAGE_PATH, activeRegion), {
1625
1966
  headers: { accept: "application/json" },
1626
1967
  credentials: "same-origin",
1627
1968
  ...signal === void 0 ? {} : { signal }
1628
1969
  });
1629
1970
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
1630
1971
  const value = await response.json();
1631
- if (!mounted.current || signal?.aborted === true) return true;
1632
- const general = generalCreditsOf(value);
1633
- if (general !== void 0) {
1634
- setPoints(general);
1635
- setSignedIn(true);
1636
- } else setSignedIn(value.status === "signed-in");
1637
- setFailed(false);
1972
+ if (!mounted.current || signal?.aborted === true) return;
1973
+ setUsage(value);
1638
1974
  setLastRefresh(Date.now());
1639
- return true;
1975
+ setFailed(false);
1640
1976
  } catch {
1641
1977
  if (mounted.current && signal?.aborted !== true) setFailed(true);
1642
- return false;
1643
1978
  } finally {
1644
1979
  inFlight.current = false;
1645
1980
  if (mounted.current) setBusy(false);
1646
1981
  }
1647
- }, []);
1982
+ }, [activeRegion]);
1983
+ /**
1984
+ * Every account's general balance — one upstream read per account.
1985
+ *
1986
+ * Paid only while the panel is open (see the note at the top of this file).
1987
+ * A failure keeps whatever we already have rather than clearing it: a number
1988
+ * that went stale is better than a switch that vanished.
1989
+ */
1990
+ const fetchAccountCredits = (0, react.useCallback)(async (signal) => {
1991
+ if (activeRegion === void 0) return;
1992
+ try {
1993
+ const response = await fetch(withTraeRegion(TRAE_ACCOUNT_CREDITS_PATH, activeRegion), {
1994
+ headers: { accept: "application/json" },
1995
+ credentials: "same-origin",
1996
+ ...signal === void 0 ? {} : { signal }
1997
+ });
1998
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1999
+ const data = await response.json();
2000
+ if (!mounted.current || signal?.aborted === true) return;
2001
+ if (Array.isArray(data.accounts)) setAccountCredits(data.accounts);
2002
+ } catch {}
2003
+ }, [activeRegion]);
1648
2004
  (0, react.useEffect)(() => {
2005
+ if (owned === void 0) return void 0;
1649
2006
  let cancelled = false;
1650
2007
  let timer;
1651
2008
  const controller = new AbortController();
1652
- fetchPoints(controller.signal).finally(() => {
2009
+ fetchUsage(controller.signal).finally(() => {
1653
2010
  if (cancelled) return;
1654
2011
  timer = window.setInterval(() => {
1655
- fetchPoints(controller.signal);
1656
- }, SIDEBAR_POINTS_REFRESH_INTERVAL_MS);
2012
+ fetchUsage(controller.signal);
2013
+ }, COMPOSER_POINTS_REFRESH_INTERVAL_MS);
1657
2014
  });
1658
2015
  return () => {
1659
2016
  cancelled = true;
1660
2017
  if (timer !== void 0) window.clearInterval(timer);
1661
2018
  controller.abort();
1662
2019
  };
1663
- }, [fetchPoints]);
1664
- const titleText = lastRefresh === void 0 ? void 0 : t("sidebar.lastRefresh", { time: formatClock(lastRefresh) });
1665
- const valueText = !signedIn ? t("sidebar.signedOut") : points === void 0 ? "—" : formatNumber(points);
1666
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1667
- className: "dsm-trae-sidebar-points",
1668
- title: titleText,
1669
- children: [
1670
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1671
- className: "dsm-trae-sidebar-points-line",
1672
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1673
- className: "dsm-trae-sidebar-points-text",
1674
- "aria-live": "polite",
1675
- children: [
1676
- t("sidebar.points"),
1677
- ":",
1678
- valueText
1679
- ]
1680
- }), failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1681
- className: "dsm-trae-sidebar-points-failed",
2020
+ }, [owned, fetchUsage]);
2021
+ (0, react.useEffect)(() => {
2022
+ if (!open || activeRegion === void 0) return void 0;
2023
+ const controller = new AbortController();
2024
+ fetchAccountCredits(controller.signal);
2025
+ return () => {
2026
+ controller.abort();
2027
+ };
2028
+ }, [
2029
+ open,
2030
+ activeRegion,
2031
+ fetchAccountCredits
2032
+ ]);
2033
+ (0, react.useEffect)(() => {
2034
+ if (!open) return void 0;
2035
+ const onKeyDown = (event) => {
2036
+ if (event.key === "Escape") setOpen(false);
2037
+ };
2038
+ document.addEventListener("keydown", onKeyDown);
2039
+ return () => {
2040
+ document.removeEventListener("keydown", onKeyDown);
2041
+ };
2042
+ }, [open]);
2043
+ /**
2044
+ * Switch to another account, then re-read both the usage document and the
2045
+ * table so the marker, the readout, and the panel all move together.
2046
+ *
2047
+ * The merge goes through the shared `configuredAccountsOf` helper (the card
2048
+ * uses the same one): writing only `[region]: id` would drop the other
2049
+ * region's selection. The write is read-back checked — `set()` resolving is
2050
+ * not proof it landed — so a refused write reports failure instead of
2051
+ * showing a marker on an account that was never selected.
2052
+ */
2053
+ const switchTo = async (accountId) => {
2054
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
2055
+ if (activeRegion === void 0 || switchingId !== void 0) return;
2056
+ if (accountId === (accountCredits?.find((account) => account.selected)?.id ?? (usage?.status === "signed-in" ? usage.accountId : void 0))) return;
2057
+ setSwitchingId(accountId);
2058
+ setFailed(false);
2059
+ try {
2060
+ const configured = configuredAccountsOf(settingsScope.getSnapshot().value);
2061
+ const accepted = await settingsScope.set("accounts", {
2062
+ ...configured,
2063
+ [activeRegion]: accountId
2064
+ });
2065
+ const readBack = configuredAccountsOf(settingsScope.getSnapshot().value);
2066
+ if (accepted === false || readBack[activeRegion] !== accountId) throw new Error("the Host did not persist the switch");
2067
+ await fetchUsage();
2068
+ await fetchAccountCredits();
2069
+ } catch {
2070
+ if (mounted.current) setFailed(true);
2071
+ } finally {
2072
+ if (mounted.current) setSwitchingId(void 0);
2073
+ }
2074
+ };
2075
+ if (owned === void 0) return null;
2076
+ const signedIn = usage?.status === "signed-in" ? usage : void 0;
2077
+ const general = signedIn === void 0 ? void 0 : generalCreditsOf(signedIn);
2078
+ const signedOut = usage !== void 0 && usage.status === "signed-out";
2079
+ const valueText = general === void 0 ? "—" : formatCredits(general);
2080
+ const label = t("composer.points");
2081
+ /**
2082
+ * The table's rows: every account in this region, with its general balance.
2083
+ *
2084
+ * The endpoint's answer is authoritative (it carries the figures). Before it
2085
+ * lands — or if it fails — fall back to the usage document's account list so
2086
+ * the switches are reachable immediately, showing `—` for the figure rather
2087
+ * than a spinner the user has to wait on.
2088
+ */
2089
+ const selectedId = accountCredits?.find((account) => account.selected)?.id ?? (signedIn === void 0 ? void 0 : signedIn.accountId);
2090
+ const rows = (accountCredits ?? (signedIn === void 0 ? [] : signedIn.accounts.filter((account) => account.region === activeRegion).map((account) => ({
2091
+ id: account.id,
2092
+ accountName: account.accountName,
2093
+ selected: account.selected
2094
+ })))).map((row) => {
2095
+ if (row.id !== selectedId || row.generalAvailable !== void 0 || general === void 0) return row;
2096
+ return {
2097
+ ...row,
2098
+ generalAvailable: general
2099
+ };
2100
+ });
2101
+ const canSwitch = settingsScope !== void 0 && settingsScope.getSnapshot().writable === true;
2102
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2103
+ ref: rootRef,
2104
+ className: "dsm-trae-composer-points",
2105
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2106
+ type: "button",
2107
+ className: "dsm-trae-composer-points-trigger",
2108
+ "aria-haspopup": "dialog",
2109
+ "aria-expanded": open,
2110
+ onClick: () => {
2111
+ setOpen(!open);
2112
+ },
2113
+ children: [
2114
+ label,
2115
+ " · ",
2116
+ valueText
2117
+ ]
2118
+ }), open && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2119
+ ref: panelRef,
2120
+ className: "dsm-trae-composer-panel",
2121
+ style: position ?? {
2122
+ visibility: "hidden",
2123
+ left: 0,
2124
+ top: 0
2125
+ },
2126
+ role: "dialog",
2127
+ "aria-label": t("composer.panelTitle"),
2128
+ children: [
2129
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2130
+ className: "dsm-trae-composer-panel-head",
2131
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2132
+ className: "dsm-trae-composer-panel-title",
2133
+ children: t("composer.panelTitle")
2134
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2135
+ type: "button",
2136
+ className: "dsm-trae-composer-panel-refresh",
2137
+ disabled: busy || switchingId !== void 0,
2138
+ onClick: () => {
2139
+ fetchUsage();
2140
+ },
2141
+ children: busy ? t("composer.refreshing") : t("composer.refresh")
2142
+ })]
2143
+ }),
2144
+ signedOut ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2145
+ className: "dsm-trae-composer-panel-empty",
2146
+ children: t("composer.signedOut")
2147
+ }) : rows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2148
+ className: "dsm-trae-composer-panel-empty",
2149
+ children: t("composer.noAccounts")
2150
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
2151
+ className: "dsm-trae-composer-panel-table",
2152
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
2153
+ scope: "col",
2154
+ children: t("composer.account")
2155
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
2156
+ scope: "col",
2157
+ className: "dsm-trae-composer-panel-num",
2158
+ children: t("composer.generalCredits")
2159
+ })] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: rows.map((row) => {
2160
+ const current = row.id === selectedId;
2161
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", {
2162
+ className: current ? "dsm-trae-composer-panel-row-current" : void 0,
2163
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
2164
+ scope: "row",
2165
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2166
+ type: "button",
2167
+ className: "dsm-trae-composer-panel-switch",
2168
+ disabled: !canSwitch || switchingId !== void 0 || current,
2169
+ "aria-label": t("composer.switchTo", { account: row.accountName }),
2170
+ onClick: () => {
2171
+ switchTo(row.id);
2172
+ },
2173
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2174
+ className: "dsm-trae-composer-panel-dot",
2175
+ "aria-hidden": "true"
2176
+ }), row.accountName]
2177
+ })
2178
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
2179
+ className: "dsm-trae-composer-panel-num",
2180
+ children: row.generalAvailable === void 0 ? "—" : formatCredits(row.generalAvailable)
2181
+ })]
2182
+ }, row.id);
2183
+ }) })]
2184
+ }),
2185
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2186
+ className: "dsm-trae-composer-panel-foot",
2187
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2188
+ t("composer.lastRefresh"),
2189
+ " ",
2190
+ lastRefresh === void 0 ? "—" : formatClock(lastRefresh)
2191
+ ] })
2192
+ }),
2193
+ failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2194
+ className: "dsm-trae-composer-panel-error",
1682
2195
  role: "status",
1683
- children: t("sidebar.refreshFailed")
1684
- }) : null]
1685
- }),
1686
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1687
- type: "button",
1688
- className: "dsm-trae-sidebar-points-refresh",
1689
- disabled: busy,
1690
- "aria-label": busy ? t("sidebar.refreshing") : t("sidebar.refresh"),
1691
- title: busy ? t("sidebar.refreshing") : t("sidebar.refresh"),
1692
- onClick: () => {
1693
- fetchPoints();
1694
- },
1695
- children: busy ? t("sidebar.refreshing") : t("sidebar.refresh")
1696
- }),
1697
- onHide === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1698
- type: "button",
1699
- className: "dsm-trae-sidebar-points-hide",
1700
- "aria-label": t("sidebar.hide"),
1701
- title: t("sidebar.hide"),
1702
- onClick: onHide,
1703
- children: "×"
1704
- })
1705
- ]
2196
+ children: t("composer.refreshFailed")
2197
+ }) : null
2198
+ ]
2199
+ }), document.body)]
1706
2200
  });
1707
2201
  }
1708
2202
  //#endregion
1709
- //#region src/client/SidebarPointsGate.tsx
2203
+ //#region src/client/ComposerPointsGate.tsx
1710
2204
  /**
1711
- * Gate between the `sidebar.footer.action` slot and the compact credit line.
1712
- *
1713
- * Kept in its own module (rather than inside the browser-plugin entry) so it
1714
- * can be imported from the Node/jsdom test suite: the entry imports
1715
- * browser-only DSH packages the test environment cannot load, but this gate
1716
- * depends only on React, the card's settings-scope shape and the line
1717
- * component.
2205
+ * Decides whether the composer credit readout belongs in this session, by
2206
+ * reading the Host's own `modelSelection` projection and the plugin's
2207
+ * `showPointsInMainUi` switch.
1718
2208
  *
1719
- * TWO states:
2209
+ * Kept separate from {@link ComposerPoints} (and from the browser-plugin entry)
2210
+ * so the decision can be unit-tested without a Host: the entry imports
2211
+ * browser-only DSH packages the test environment cannot load, while this module
2212
+ * depends only on React and a narrow projection shape.
1720
2213
  *
1721
- * - on → {@link SidebarPoints}, which owns the fetch loop and its timer;
1722
- * - off → NOTHING. The row is absent from the DOM, not hidden.
2214
+ * WHY the projection rather than this plugin's own state: the projection is
2215
+ * what the model picker writes and what the next request will use, so the
2216
+ * readout cannot disagree with the model actually in effect. `next` outranks
2217
+ * `lastUsed` because a selection that has been made but not yet sent is still
2218
+ * what the composer shows the user.
1723
2219
  *
1724
- * Off used to leave a small "积分" chip behind, on the theory that a switch you
1725
- * cannot find is no switch at all (2.9.1). That safety net is no longer needed:
1726
- * the card carries the same checkbox twice — at the top of the card and beside
1727
- * the model actions — so the way back is always where the feature is
1728
- * configured. What the user asked for is for the row to be gone, and a leftover
1729
- * chip still occupies the footer they wanted cleared.
2220
+ * The switch is read the same way the retired sidebar gate read it: absent or
2221
+ * false means OFF, and only an explicit `true` shows the readout. The composer
2222
+ * row is shared with the shell's own permission/agent/model controls, so the
2223
+ * readout must be asked for, not imposed — 2.13.0 rendered it unconditionally
2224
+ * and 2.13.3 restored the opt-in after the user went looking for the switch.
2225
+ */
2226
+ /**
2227
+ * The provider route the session is currently pointed at.
1730
2228
  *
1731
- * The collapsed rail also renders nothing: the shell squeezes the footer to a
1732
- * 56px strip rather than hiding it, and the line does not read well there — see
1733
- * the bail-out below.
2229
+ * `next` wins over `lastUsed`: a switch that has landed but not yet been sent
2230
+ * is still the selection the composer displays.
1734
2231
  */
2232
+ function selectedProviderOf(projection) {
2233
+ const provider = (projection?.next ?? projection?.lastUsed)?.provider;
2234
+ return typeof provider === "string" && provider !== "" ? provider : void 0;
2235
+ }
1735
2236
  /**
1736
- * Read the sidebar-credit switch out of a committed settings snapshot.
2237
+ * Read the composer-readout switch out of a committed settings snapshot.
1737
2238
  *
1738
- * Absent means ON: the field defaults to true on the host, and a settings
1739
- * scope that has not populated yet must not blink the line away. Only an
1740
- * explicit `false` (the user turned it off, from the line or the card) hides
1741
- * the balance.
2239
+ * Absent means OFF, matching the host default (`false`): a scope that has not
2240
+ * populated yet, or a host too old to serve the field, must not put a row in
2241
+ * the composer toolbar that the user never asked for.
1742
2242
  */
1743
- function sidebarPointsEnabledOf(scope) {
1744
- if (scope === void 0) return true;
1745
- return unwrapVolatileDeep(scope.getSnapshot().value)?.showPointsInMainUi !== false;
2243
+ function composerPointsEnabledOf(scope) {
2244
+ if (scope === void 0) return false;
2245
+ return unwrapVolatileDeep(scope.getSnapshot().value)?.showPointsInMainUi === true;
1746
2246
  }
1747
- function SidebarPointsGate(props) {
1748
- const { t, settingsScope, wide } = props;
1749
- const [enabled, setEnabled] = (0, react.useState)(() => sidebarPointsEnabledOf(settingsScope));
2247
+ function ComposerPointsGate(props) {
2248
+ const { t, settingsScope, useProjection } = props;
2249
+ const [enabled, setEnabled] = (0, react.useState)(() => composerPointsEnabledOf(settingsScope));
1750
2250
  (0, react.useEffect)(() => settingsScope?.subscribe(() => {
1751
- setEnabled(sidebarPointsEnabledOf(settingsScope));
2251
+ setEnabled(composerPointsEnabledOf(settingsScope));
1752
2252
  }), [settingsScope]);
1753
- /**
1754
- * Write the switch back through the same field the card writes, so the two
1755
- * controls can never disagree. The read-back is not awaited for its value:
1756
- * the subscription above re-renders when the committed snapshot changes, and
1757
- * an unwritable scope simply leaves the current state alone.
1758
- */
1759
- const setShown = (0, react.useCallback)((next) => {
1760
- if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
1761
- settingsScope.set("showPointsInMainUi", next).catch(() => {});
1762
- }, [settingsScope]);
1763
- if (wide === false) return null;
2253
+ const projection = useProjection === void 0 ? void 0 : useProjection("modelSelection");
1764
2254
  if (!enabled) return null;
1765
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SidebarPoints, {
2255
+ const provider = selectedProviderOf(projection);
2256
+ const region = provider === void 0 ? void 0 : TRAE_COMPOSER_PROVIDERS[provider];
2257
+ if (region === void 0) return null;
2258
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ComposerPoints, {
1766
2259
  t,
1767
- onHide: () => {
1768
- setShown(false);
1769
- }
2260
+ provider,
2261
+ region,
2262
+ ...settingsScope === void 0 ? {} : { settingsScope }
1770
2263
  });
1771
2264
  }
1772
2265
  //#endregion
@@ -1843,15 +2336,19 @@ window.__ModuleLoader__.load({
1843
2336
  "row.modelsTestEnabled": "Test enabled models",
1844
2337
  "row.modelsTesting": "Testing {done}/{total}…",
1845
2338
  "row.modelsTestHint": "Sends one minimal message per enabled model to see which ones actually answer. Costs a small amount of quota.",
1846
- "row.showPointsInMainUi": "Show credits in the DSH main UI",
1847
- "row.showPointsInMainUiHint": "Keep a general-credit line with a refresh button at the bottom of the DSH sidebar (hidden while the sidebar is collapsed; refreshes every 5 minutes).",
1848
- "sidebar.points": "Trae credits",
1849
- "sidebar.refresh": "Refresh",
1850
- "sidebar.refreshing": "Refreshing…",
1851
- "sidebar.lastRefresh": "Last refreshed {time}",
1852
- "sidebar.refreshFailed": "Refresh failed",
1853
- "sidebar.signedOut": "Not signed in",
1854
- "sidebar.hide": "Hide credits",
2339
+ "row.showPointsInMainUi": "Show credits in the composer row",
2340
+ "row.showPointsInMainUiHint": "Show a clickable Trae CN · <balance> readout in the composer tool row, only while the selected model belongs to this plugin. Click it for the account and balance panel.",
2341
+ "composer.switchTo": "Switch to {account}",
2342
+ "composer.noAccounts": "No account in this region",
2343
+ "composer.panelTitle": "Trae credits",
2344
+ "composer.account": "Account",
2345
+ "composer.generalCredits": "General credits",
2346
+ "composer.lastRefresh": "Last refreshed",
2347
+ "composer.signedOut": "Not signed in",
2348
+ "composer.points": "Trae CN",
2349
+ "composer.refresh": "Refresh credits",
2350
+ "composer.refreshing": "Refreshing credits…",
2351
+ "composer.refreshFailed": "Could not refresh credits",
1855
2352
  "row.accountsRefreshed": "Accounts refreshed: {count} found{selected}",
1856
2353
  "row.accountsKept": "still bound to",
1857
2354
  "row.accountsRefreshedNone": "Accounts refreshed: no usable Trae account found",
@@ -1863,6 +2360,10 @@ window.__ModuleLoader__.load({
1863
2360
  "row.creditsExhausted": "The bound account ({account}) has run out of credit",
1864
2361
  "row.creditsAlternative": "Another account on this machine still has credit: {accounts}",
1865
2362
  "row.creditsAlternativeSwitch": "Switch to it",
2363
+ "row.appLoginMismatch": "Trae is signed in to {account}, but this plugin is still bound to the previous account",
2364
+ "row.appLoginFollow": "Switch to it",
2365
+ "row.followAppLogin": "Follow the account Trae is signed in to",
2366
+ "row.followAppLoginHint": "When you switch accounts in the Trae desktop app, rebind to the new sign-in automatically instead of staying on the previous one. Off by default: an existing binding is kept until you tick this.",
1866
2367
  "row.modelsTestEmpty": "Enable at least one model first.",
1867
2368
  "row.modelsTestError": "Test failed: {message}",
1868
2369
  "row.modelsTestDone": "{available} of {total} answered.",
@@ -1955,15 +2456,19 @@ window.__ModuleLoader__.load({
1955
2456
  "row.modelsTestEnabled": "一键测试启用模型",
1956
2457
  "row.modelsTesting": "正在测试 {done}/{total}…",
1957
2458
  "row.modelsTestHint": "给每个已启用模型发一条最小消息,看哪些真的能应答。会消耗少量额度。",
1958
- "row.showPointsInMainUi": "在DSH主界面显示积分状态",
1959
- "row.showPointsInMainUiHint": "在 DSH 侧边栏底部常驻一行通用积分与刷新按钮(收起侧边栏时不显示,每 5 分钟自动刷新)。",
1960
- "sidebar.points": "Trae通用积分",
1961
- "sidebar.refresh": "刷新",
1962
- "sidebar.refreshing": "刷新中…",
1963
- "sidebar.lastRefresh": "最近刷新 {time}",
1964
- "sidebar.refreshFailed": "刷新失败",
1965
- "sidebar.signedOut": "未登录",
1966
- "sidebar.hide": "隐藏积分",
2459
+ "row.showPointsInMainUi": "在输入框工具栏显示积分读数",
2460
+ "row.showPointsInMainUiHint": "在输入框工具栏显示可点击的「Trae CN · 余额」读数,仅当选中的模型属于本插件时出现;点击可查看账号与积分明细。",
2461
+ "composer.switchTo": "切换到 {account}",
2462
+ "composer.noAccounts": "该区域暂无账号",
2463
+ "composer.panelTitle": "Trae 积分",
2464
+ "composer.account": "账号",
2465
+ "composer.generalCredits": "通用积分",
2466
+ "composer.lastRefresh": "最近刷新",
2467
+ "composer.signedOut": "未登录",
2468
+ "composer.points": "Trae CN",
2469
+ "composer.refresh": "刷新积分",
2470
+ "composer.refreshing": "正在刷新积分…",
2471
+ "composer.refreshFailed": "积分刷新失败",
1967
2472
  "row.accountsRefreshed": "已刷新账号:找到 {count} 个账号{selected}",
1968
2473
  "row.accountsKept": "仍绑定",
1969
2474
  "row.accountsRefreshedNone": "已刷新账号:未找到可用的 Trae 账号",
@@ -1975,6 +2480,10 @@ window.__ModuleLoader__.load({
1975
2480
  "row.creditsExhausted": "当前绑定账号({account})额度已用尽",
1976
2481
  "row.creditsAlternative": "本机还有可用账号:{accounts}",
1977
2482
  "row.creditsAlternativeSwitch": "切换到它",
2483
+ "row.appLoginMismatch": "Trae 当前登录的是 {account},但本插件仍绑定在上一个账号",
2484
+ "row.appLoginFollow": "切换过去",
2485
+ "row.followAppLogin": "跟随 Trae 当前登录的账号",
2486
+ "row.followAppLoginHint": "在 Trae 桌面端切换登录后,自动换绑到新登录的账号,而不是停留在旧账号。默认关闭:已有绑定保持不变,直到你勾选它。",
1978
2487
  "row.modelsTestEmpty": "请先启用至少一个模型。",
1979
2488
  "row.modelsTestError": "测试失败:{message}",
1980
2489
  "row.modelsTestDone": "{total} 个里有 {available} 个正常应答。",
@@ -2125,31 +2634,37 @@ window.__ModuleLoader__.load({
2125
2634
  registerCard("plugins.bundle.config", "dsh-connect-trae");
2126
2635
  registerCard("plugins.row.config", "dsh-connect-trae#dsh-connect-trae");
2127
2636
  /**
2128
- * General-credit line at the sidebar foot (`sidebar.footer.action`, a
2129
- * root-scope list slot), contributed by JiewiW (issue #26).
2637
+ * Credit readout in the composer tool row (`conversation.input.left`, a
2638
+ * session-scope list slot).
2639
+ *
2640
+ * Provider-scoped on purpose: it renders only while the session's selected
2641
+ * model belongs to this plugin. That is what keeps two connector plugins
2642
+ * from competing for one shared row — the sidebar foot, where this line used
2643
+ * to live, is a single root row, and the observed result beside
2644
+ * dsh-connect-workbuddy's two rows was this plugin's line squeezed down to a
2645
+ * clipped "Tr".
2130
2646
  *
2131
- * Registration is unconditional for the slot's lifetime; the component
2132
- * renders nothing until the card switch is on, and nothing again while the
2133
- * sidebar is collapsed — so an off switch (or a collapsed rail) leaves zero
2134
- * DOM and no fetch loop.
2647
+ * Registration is unconditional; the gate reads the Host's modelSelection
2648
+ * projection and returns null for every other provider, so nothing is drawn
2649
+ * and no fetch loop starts while another provider is selected.
2135
2650
  */
2136
- const registerSidebarPoints = () => {
2651
+ const registerComposerPoints = () => {
2137
2652
  try {
2138
- ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
2139
- name: "sidebar.footer.action",
2140
- id: "dsh-connect-trae-points",
2653
+ ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
2654
+ name: "conversation.input.left",
2655
+ id: "dsh-connect-trae-composer-points",
2141
2656
  order: 4,
2142
2657
  locale: namespace,
2143
2658
  inject: () => ({
2144
2659
  t,
2145
2660
  settingsScope
2146
2661
  })
2147
- }, SidebarPointsGate));
2662
+ }, ComposerPointsGate));
2148
2663
  } catch (error) {
2149
- console.error("[dsh-connect-trae] sidebar points slot failed to register (host provider unaffected):", error);
2664
+ console.error("[dsh-connect-trae] composer points slot failed to register (host provider unaffected):", error);
2150
2665
  }
2151
2666
  };
2152
- registerSidebarPoints();
2667
+ registerComposerPoints();
2153
2668
  } catch (error) {
2154
2669
  console.error("[dsh-connect-trae] client card failed to load (host provider unaffected):", error);
2155
2670
  }