dsh-connect-workbuddy 4.8.0 → 4.8.2

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
@@ -690,7 +690,8 @@ window.__ModuleLoader__.load({
690
690
  const slot = {
691
691
  enabled: preferences.enabled,
692
692
  targetModelId: preferences.targetModelId,
693
- memberAccountIds: preferences.memberAccountIds
693
+ memberAccountIds: preferences.memberAccountIds,
694
+ orderAccountIds: preferences.orderAccountIds
694
695
  };
695
696
  if (preferences.probeInputTokens !== void 0) slot["probeInputTokens"] = preferences.probeInputTokens;
696
697
  const next = nextRegionPool(scope.getSnapshot().value, region, slot)[region];
@@ -704,10 +705,73 @@ window.__ModuleLoader__.load({
704
705
  if (!Array.isArray(stored) || stored.length !== preferences.memberAccountIds.length) return false;
705
706
  const wanted = new Set(preferences.memberAccountIds);
706
707
  if (!stored.every((id) => typeof id === "string" && wanted.has(id))) return false;
708
+ const storedOrder = pool["orderAccountIds"];
709
+ if (!Array.isArray(storedOrder) || storedOrder.length !== preferences.orderAccountIds.length) return false;
710
+ if (!storedOrder.every((id, index) => id === preferences.orderAccountIds[index])) return false;
707
711
  return true;
708
712
  });
709
713
  }
710
714
  //#endregion
715
+ //#region src/client/manual-order.ts
716
+ /**
717
+ * Manual priority order, shared by every surface that lists member accounts.
718
+ *
719
+ * The defect this exists to prevent: the pool card sorts its rows by
720
+ * `orderAccountIds` while the composer credits panel listed the same accounts
721
+ * in whatever order the Host returned. Two views of one pool, disagreeing — the
722
+ * user sorts the pool, opens the credit panel, and sees a different order, so
723
+ * neither list looks authoritative. A user cannot tell which one is "the" order,
724
+ * and the manual sorting feature looks broken even though it saved correctly.
725
+ *
726
+ * So the order lives here, once, and both lists call it. The alternative —
727
+ * each component sorting however it likes — is exactly what drifted.
728
+ */
729
+ /**
730
+ * The saved manual order out of a settings snapshot, or an empty array.
731
+ *
732
+ * Reads defensively: the snapshot is Host-supplied JSON, so `regions` may be
733
+ * absent (a profile that never used this feature), `pool` may be absent, and
734
+ * `orderAccountIds` may be anything at all. Every shape that is not a list of
735
+ * strings means "no manual order", which is also an older profile's spelling —
736
+ * never a crash.
737
+ */
738
+ function readOrderAccountIds(value, region) {
739
+ if (region === void 0) return [];
740
+ const regions = value?.regions;
741
+ if (typeof regions !== "object" || regions === null) return [];
742
+ const slot = regions[region];
743
+ if (typeof slot !== "object" || slot === null) return [];
744
+ const order = slot.pool;
745
+ if (typeof order !== "object" || order === null) return [];
746
+ const ids = order.orderAccountIds;
747
+ if (!Array.isArray(ids)) return [];
748
+ return ids.filter((id) => typeof id === "string");
749
+ }
750
+ /**
751
+ * Reorder `accounts` to follow `orderIds`, leaving unordered accounts in place
752
+ * AFTER the ordered ones, and preserving their original relative order.
753
+ *
754
+ * Returns a new array; never mutates the input, because the caller's array is
755
+ * usually a state value that must not change identity behind React's back.
756
+ *
757
+ * Ids in `orderIds` that no longer name an account are simply ignored: the pool
758
+ * can drop a member while an order still mentions it, and that must degrade to
759
+ * "that position is empty" rather than hide a real account.
760
+ */
761
+ function applyManualOrder(accounts, orderIds) {
762
+ if (orderIds.length === 0) return [...accounts];
763
+ const rank = /* @__PURE__ */ new Map();
764
+ orderIds.forEach((id, index) => {
765
+ if (!rank.has(id)) rank.set(id, index);
766
+ });
767
+ const ordered = [];
768
+ const rest = [];
769
+ for (const account of accounts) if (rank.has(account.id)) ordered.push(account);
770
+ else rest.push(account);
771
+ ordered.sort((left, right) => (rank.get(left.id) ?? 0) - (rank.get(right.id) ?? 0));
772
+ return [...ordered, ...rest];
773
+ }
774
+ //#endregion
711
775
  //#region src/client/exclusion-text.ts
712
776
  /** The exclusion label for one account, or undefined when it is usable. */
713
777
  function exclusionText(t, excludedBy) {
@@ -724,6 +788,449 @@ window.__ModuleLoader__.load({
724
788
  }
725
789
  }
726
790
  //#endregion
791
+ //#region src/account-pool.ts
792
+ /**
793
+ * How long an account stays out of the pool when the upstream stated no time.
794
+ *
795
+ * A conservative FALLBACK, not a measurement: it applies whenever a failed
796
+ * outcome carries no `retryAtMs`, which is every rate limit that came without a
797
+ * reset sentence AND every transient failure (a transport error, a 5xx). In both
798
+ * cases "when can I use this again" has no honest answer.
799
+ *
800
+ * The two failure directions it avoids: keeping the account in the pool would
801
+ * re-bill it immediately and re-trip the same limit; dropping it FOREVER would
802
+ * discard a working account over an event that said nothing about it. A short,
803
+ * stated cooldown is the least-wrong of the two — and it is what lets the pool
804
+ * heal itself, since nothing else re-probes an excluded member.
805
+ */
806
+ const POOL_UNKNOWN_COOLDOWN_MS = 18e5;
807
+ /**
808
+ * Whether one account can be billed at `nowMs`.
809
+ *
810
+ * Returns the exclusion reason, or undefined when the account is a candidate.
811
+ * The order matters and is NOT arbitrary:
812
+ *
813
+ * - A `credential-rejected` account is excluded regardless of any cooldown: its
814
+ * token is not accepted, so waiting cannot help and the user must re-auth.
815
+ * - A limited, unreachable or failed outcome is checked against its stated (or
816
+ * fallback) cooldown, so an account whose limit has already reset — or whose
817
+ * gateway blipped once — is back in the pool without needing another test
818
+ * first.
819
+ * - An `ok` measurement, and no measurement at all, are both candidates. The
820
+ * latter matters: a freshly discovered account has never been tested and must
821
+ * not be invisible.
822
+ *
823
+ * WHY TRANSIENT OUTCOMES GET A COOLDOWN. They are not statements about the
824
+ * ACCOUNT. A transport failure can be a DNS blip, and — before the shim learned
825
+ * to skip reporting an aborted request — it was routinely the user closing the
826
+ * panel mid-flight. A 5xx is the gateway's afternoon, not this account's health.
827
+ * Excluding them permanently meant one transient event idled a perfectly good
828
+ * account until the user happened to press "test" again, and with every member
829
+ * so idled the pool had no candidate at all: failover then had nowhere to go and
830
+ * the raw upstream error was reported as-is. The cooldown is what makes the pool
831
+ * RECOVER instead of staying broken.
832
+ *
833
+ * `not-found` and `credential-rejected` deliberately get none: one describes a
834
+ * model the region no longer offers (a catalog fact, not a transient one) and
835
+ * the other needs the user to sign in again. Neither is fixed by waiting, and
836
+ * pretending otherwise would re-bill a token the upstream has already refused.
837
+ *
838
+ * Each unusable outcome keeps its OWN value rather than being folded into
839
+ * `credential-rejected`. The fold made one row state two contradictory things —
840
+ * the name column said "被拒绝" (sign in again) while the probe column, reading
841
+ * the same measurement, said "连不上上游——这是网络问题,不是模型问题" — and it
842
+ * told a user whose upstream had merely blipped that their sign-in was bad.
843
+ * (That label has since been made cause-NEUTRAL: it used to assert "not the
844
+ * model" for a bucket that also holds gateway 5xx and request timeouts, where
845
+ * the claim is simply unfounded. The recorded `message` now carries the cause.)
846
+ * The distinctions are already computed upstream in `outcomeOfFailure`
847
+ * (`src/probe.ts:290-299`); this only stops discarding them.
848
+ */
849
+ function exclusionOf(probe, nowMs) {
850
+ if (probe === void 0) return void 0;
851
+ switch (probe.outcome) {
852
+ case "ok": return;
853
+ case "credential-rejected": return "credential-rejected";
854
+ case "not-found": return "not-found";
855
+ case "unavailable": return retryDue(probe, nowMs) ? void 0 : "unavailable";
856
+ case "failed": return retryDue(probe, nowMs) ? void 0 : "failed";
857
+ case "rate-limited": return retryDue(probe, nowMs) ? void 0 : "rate-limited";
858
+ case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
859
+ case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
860
+ case "policy-rejected": return;
861
+ default: return "unusable";
862
+ }
863
+ }
864
+ /**
865
+ * Whether an account's cooldown has elapsed (or was never stated).
866
+ *
867
+ * The fallback is the ONLY thing a transient outcome relies on: `cooldownOf`
868
+ * attaches `retryAtMs` for a rate limit or a drained quota, never for a
869
+ * transport failure, so `atMs + POOL_UNKNOWN_COOLDOWN_MS` is what brings those
870
+ * back. That is deliberate — the alternative is a permanent exclusion decided by
871
+ * an event that said nothing about the account.
872
+ *
873
+ * `fallbackMs` lets one outcome carry a SHORTER clock than the generic one: the
874
+ * model-scoped throttle is upstream's own "about a minute", and applying the
875
+ * half-hour account fallback to it is issue #56. It is a parameter rather than a
876
+ * second function so every outcome keeps sharing one definition of "due".
877
+ */
878
+ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
879
+ return nowMs >= (probe.retryAtMs ?? probe.atMs + fallbackMs);
880
+ }
881
+ /**
882
+ * The checked ids that still resolve to a local sign-in, in the caller's order.
883
+ *
884
+ * This is THE definition of "effective members", and it exists because the same
885
+ * predicate was written five separate times: the status document
886
+ * (`web-status.ts`), the batch route's empty-pool guard, the Host dependency the
887
+ * card reads, `poolMemberAccounts` and the browser helper. Five copies of one
888
+ * rule is this project's single most productive bug shape ("fixed one end,
889
+ * missed the other"), so the rule now has one home and every site delegates to
890
+ * it.
891
+ *
892
+ * ORDER IS THE CALLER'S, and comes from `orderedIds`: the answer preserves the
893
+ * order of THAT argument. Callers pass whichever list's order they want to show
894
+ * — the saved list for the user's own order, the store's roster for the account
895
+ * table's. Neither is silently reshuffled by the filter. (The first parameter was
896
+ * once named `saved`, which read as "this must be the saved list" even though
897
+ * `web-status.ts` legitimately passes the roster for store order; the name was
898
+ * the trap, so it now describes the ROLE instead of the source.)
899
+ *
900
+ * Pure, so it is unit-testable without a store, and it never mutates its input.
901
+ */
902
+ function effectiveMembersOf(orderedIds, listedIds) {
903
+ return orderedIds.filter((id) => listedIds.has(id));
904
+ }
905
+ /**
906
+ * A latest-wins guard for one region's asynchronous work.
907
+ *
908
+ * Exists because `applyRotation` awaits (it fetches credits per member), so two
909
+ * overlapping calls can interleave: a slow call that read "rotation is ON" can
910
+ * resume AFTER a newer call cleared the override for "rotation is OFF" and write
911
+ * the stale account back — leaving the card showing the pool switched off while
912
+ * a rotated account is still billed.
913
+ *
914
+ * `begin()` claims a new generation and returns a probe; the caller must check
915
+ * it after EVERY await and bail when it reports stale. Extracted as a pure
916
+ * factory rather than inlined so the rule is unit-testable: an inlined token is
917
+ * only observable by reproducing a real interleaving, which is slow and easy to
918
+ * get subtly wrong (a harness that never actually overlaps passes either way).
919
+ */
920
+ function createLatestWins() {
921
+ const generations = {};
922
+ return { begin(key) {
923
+ const generation = (generations[key] ?? 0) + 1;
924
+ generations[key] = generation;
925
+ return () => generations[key] !== generation;
926
+ } };
927
+ }
928
+ //#endregion
929
+ //#region src/client/remaining.ts
930
+ const MINUTE_MS$1 = 6e4;
931
+ const HOUR_MS$1 = 60 * MINUTE_MS$1;
932
+ const DAY_MS$1 = 24 * HOUR_MS$1;
933
+ /**
934
+ * How far out hours stop being the useful unit.
935
+ *
936
+ * Below this, hours; at or above it, days. The cut is at TWO days rather than
937
+ * one so that 30 hours reads as "约 30 小时后" instead of rounding to "约 1 天后",
938
+ * which would understate it by six hours.
939
+ */
940
+ const DAYS_AT_MS = 2 * DAY_MS$1;
941
+ /**
942
+ * A short "约 N 分钟后 / 约 N 小时后 / 约 N 天后", or `''` once the moment passed.
943
+ *
944
+ * The empty string is the contract callers depend on: an elapsed cooldown means
945
+ * the account is already back, so appending "in 0 minutes" would assert the
946
+ * opposite of the state the card is showing. Callers append this only when it is
947
+ * non-empty.
948
+ *
949
+ * Rounding is deliberately coarse — "约 1 小时后" for 69 minutes is what a reader
950
+ * wants, and a precision that changes every minute would be noise rather than
951
+ * information. The absolute instant sits right beside it for anyone who needs it.
952
+ */
953
+ function remainingText(t, untilMs, nowMs) {
954
+ const left = untilMs - nowMs;
955
+ if (left <= 0) return "";
956
+ if (left < MINUTE_MS$1) return t("row.remainingImminent");
957
+ if (left < HOUR_MS$1) return t("row.remainingMinutes", { count: Math.round(left / MINUTE_MS$1) });
958
+ if (left < DAYS_AT_MS) return t("row.remainingHours", { count: Math.round(left / HOUR_MS$1) });
959
+ return t("row.remainingDays", { count: Math.round(left / DAY_MS$1) });
960
+ }
961
+ //#endregion
962
+ //#region src/client/account-state.ts
963
+ /** Whether a probe outcome means the model answered. */
964
+ function outcomeOk(outcome) {
965
+ return outcome === "ok";
966
+ }
967
+ /** A short, localizable label for one probe outcome. */
968
+ function outcomeText(t, outcome) {
969
+ switch (outcome) {
970
+ case "ok": return t("row.probeOk");
971
+ case "rate-limited": return t("row.poolExcludedRateLimited");
972
+ case "model-busy": return t("row.poolExcludedModelBusy");
973
+ case "out-of-credit": return t("row.poolExcludedOutOfCredit");
974
+ case "credential-rejected": return t("row.probeCredentialRejected");
975
+ case "not-found": return t("row.probeNotFound");
976
+ case "unavailable": return t("row.probeUnavailable");
977
+ default: return t("row.probeFailed");
978
+ }
979
+ }
980
+ /**
981
+ * `MM/DD, HH:mm` on a 24-hour clock.
982
+ *
983
+ * `hourCycle: 'h23'` is REQUIRED, not decorative: without it `Intl` follows the
984
+ * browser locale's preference, and en-US renders `09/30, 01:23 PM`. A wall-clock
985
+ * glued to "AM/PM" reads as 12-hour no matter what the surrounding UI assumes,
986
+ * and it is the one thing a Chinese user cannot scan past — the card otherwise
987
+ * speaks 24-hour everywhere (see `formatClock`).
988
+ *
989
+ * `h23` rather than `hour12: false`, because the latter maps to `h24` in some
990
+ * engines and would print midnight as `24:00`. `h23` pins 00–23 exactly, which
991
+ * is also the cycle DSH's own timestamps use.
992
+ */
993
+ function formatShortClock(value) {
994
+ return new Intl.DateTimeFormat(void 0, {
995
+ month: "2-digit",
996
+ day: "2-digit",
997
+ hour: "2-digit",
998
+ minute: "2-digit",
999
+ hourCycle: "h23"
1000
+ }).format(new Date(value));
1001
+ }
1002
+ /**
1003
+ * The "why can't this serve, and until when" sentence for one account.
1004
+ *
1005
+ * Returns `undefined` when there is nothing to say — an account with no
1006
+ * measurement and no exclusion is simply unknown, and inventing "可用" for it
1007
+ * would be a claim nobody made.
1008
+ *
1009
+ * The retry wording has three states, matching the card's three:
1010
+ * - a stated instant → "被限流 · 10/19 01:32 之后可再用"
1011
+ * - no instant but a timed → "被限流 · 未提供恢复时间"
1012
+ * exclusion
1013
+ * - any other outcome → the bare outcome ("连不上上游")
1014
+ *
1015
+ * That third case is deliberate: "未提供恢复时间" beside a credential rejection
1016
+ * would imply waiting helps, when the only fix is signing in again.
1017
+ */
1018
+ function availabilityLine(t, account, nowMs = Date.now()) {
1019
+ const probe = account.probe;
1020
+ if (probe !== void 0) {
1021
+ if (probe.retryAtMs !== void 0) {
1022
+ const left = remainingText(t, probe.retryAtMs, nowMs);
1023
+ const at = t("row.poolRetryAt", { at: formatShortClock(probe.retryAtMs) });
1024
+ return `${outcomeText(t, probe.outcome)} · ${at}${left === "" ? "" : ` · ${left}`}`;
1025
+ }
1026
+ if (probe.outcome === "rate-limited" || probe.outcome === "model-busy" || probe.outcome === "out-of-credit") return `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}`;
1027
+ return outcomeText(t, probe.outcome);
1028
+ }
1029
+ if (account.excludedBy !== void 0) return exclusionLabel(t, account.excludedBy);
1030
+ }
1031
+ /**
1032
+ * The SHORT form of the same fact: why the account cannot serve, and how long
1033
+ * until it can — without the absolute instant.
1034
+ *
1035
+ * This exists because the composer's balance panel prints one line per account
1036
+ * and the absolute date made every limited row read like a log entry
1037
+ * ("被限流 · 10/19, 01:32 之后可再用 · 约 6 小时后") beside a number the row is
1038
+ * actually about. The panel keeps the reason and the wait; the pool card keeps
1039
+ * the full sentence from {@link availabilityLine}, which is the one place a
1040
+ * reader can afford the exact instant.
1041
+ *
1042
+ * It is a sibling rather than a parameter so the two surfaces cannot half-adopt
1043
+ * a flag: the card has no way to reach this function, and the panel has no way
1044
+ * to lose the wait.
1045
+ */
1046
+ function availabilityMark(t, account, nowMs = Date.now()) {
1047
+ const probe = account.probe;
1048
+ if (probe !== void 0) {
1049
+ if (probe.retryAtMs !== void 0) {
1050
+ const left = remainingText(t, probe.retryAtMs, nowMs);
1051
+ return left === "" ? outcomeText(t, probe.outcome) : `${outcomeText(t, probe.outcome)} · ${left}`;
1052
+ }
1053
+ if (probe.outcome === "rate-limited" || probe.outcome === "model-busy" || probe.outcome === "out-of-credit") return `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}`;
1054
+ return outcomeText(t, probe.outcome);
1055
+ }
1056
+ if (account.excludedBy !== void 0) return exclusionLabel(t, account.excludedBy);
1057
+ }
1058
+ /**
1059
+ * The exclusion's own label, for the case where no probe backs it.
1060
+ *
1061
+ * Kept private: callers that have a probe must go through `availabilityLine` or
1062
+ * `availabilityMark`, so neither surface can invent its own vocabulary for the
1063
+ * same measurement.
1064
+ */
1065
+ function exclusionLabel(t, excludedBy) {
1066
+ switch (excludedBy) {
1067
+ case "rate-limited": return t("row.poolExcludedRateLimited");
1068
+ case "model-busy": return t("row.poolExcludedModelBusy");
1069
+ case "out-of-credit": return t("row.poolExcludedOutOfCredit");
1070
+ case "credential-rejected": return t("row.poolExcludedRejected");
1071
+ case "not-found": return t("row.probeNotFound");
1072
+ case "unavailable": return t("row.probeUnavailable");
1073
+ case "failed": return t("row.probeFailed");
1074
+ case "unusable": return t("row.poolExcludedUnusable");
1075
+ default: return t("row.poolExcludedUnusable");
1076
+ }
1077
+ }
1078
+ /**
1079
+ * The dot's state as a class suffix, so CSS can colour it without inline styles.
1080
+ *
1081
+ * `error` is separate from `warn` because the advice differs: one means "wait",
1082
+ * the other means "do something".
1083
+ */
1084
+ function stateTone(account) {
1085
+ if (account.excludedBy !== void 0) return account.excludedBy === "rate-limited" || account.excludedBy === "model-busy" || account.excludedBy === "unavailable" ? "warn" : "error";
1086
+ if (account.probe === void 0) return "unknown";
1087
+ return outcomeOk(account.probe.outcome) ? "ok" : "warn";
1088
+ }
1089
+ /**
1090
+ * The tone as a CSS colour variable.
1091
+ *
1092
+ * Derived from `stateTone` rather than repeating its branches: an inlined copy
1093
+ * would be a second definition of the rule, free to drift the first time one is
1094
+ * edited. The pool table draws a dot from a computed colour, so it needs a value
1095
+ * rather than a class name.
1096
+ */
1097
+ function stateColor(account) {
1098
+ switch (stateTone(account)) {
1099
+ case "ok": return "var(--dsw-alias-state-success-primary, #22a06b)";
1100
+ case "warn": return "var(--dsw-alias-state-warn-primary, #f59e0b)";
1101
+ case "error": return "var(--dsw-alias-state-error-primary, #ef4444)";
1102
+ default: return "var(--dsw-alias-label-dimmed, #9aa0a6)";
1103
+ }
1104
+ }
1105
+ /**
1106
+ * Read one line of the test batch's stream as a test row.
1107
+ *
1108
+ * The stream is `{ action, modelId }`, then one `{ row }` per account, then
1109
+ * `{ done: true }` — or `{ reason, error }` when the batch itself broke. Only the
1110
+ * row shape is recognized here; the rest is the caller's business.
1111
+ *
1112
+ * Every field is validated rather than trusted. This parses a wire the panel did
1113
+ * not write, and a half-shaped row reaching the component's state would render
1114
+ * as a measurement nobody took — worse than ignoring the line, because the
1115
+ * subsequent re-read is what would have quietly corrected it.
1116
+ */
1117
+ function testRowOf(line) {
1118
+ const raw = line["row"];
1119
+ if (typeof raw !== "object" || raw === null) return void 0;
1120
+ const row = raw;
1121
+ const accountId = row["accountId"];
1122
+ const name = row["accountName"];
1123
+ const rawResult = row["result"];
1124
+ if (typeof accountId !== "string" || accountId === "") return void 0;
1125
+ if (typeof rawResult !== "object" || rawResult === null) return void 0;
1126
+ const result = probeResultOf(rawResult);
1127
+ if (result === void 0) return void 0;
1128
+ return {
1129
+ accountId,
1130
+ accountName: typeof name === "string" && name !== "" ? name : accountId,
1131
+ result
1132
+ };
1133
+ }
1134
+ /** The probe result inside a test row, when it is complete enough to render. */
1135
+ function probeResultOf(raw) {
1136
+ const outcome = raw["outcome"];
1137
+ const modelId = raw["modelId"];
1138
+ if (typeof outcome !== "string" || !isProbeOutcome(outcome)) return void 0;
1139
+ if (typeof modelId !== "string") return void 0;
1140
+ const elapsedMs = raw["elapsedMs"];
1141
+ const status = raw["status"];
1142
+ const message = raw["message"];
1143
+ const retryAtMs = raw["retryAtMs"];
1144
+ const retrySource = raw["retrySource"];
1145
+ return {
1146
+ modelId,
1147
+ outcome,
1148
+ ...typeof elapsedMs === "number" ? { elapsedMs } : {},
1149
+ ...typeof status === "number" ? { status } : {},
1150
+ ...typeof message === "string" ? { message } : {},
1151
+ ...typeof retryAtMs === "number" ? { retryAtMs } : {},
1152
+ ...isRetrySource(retrySource) ? { retrySource } : {}
1153
+ };
1154
+ }
1155
+ /** The outcome vocabulary, as a value — the type alone cannot be tested against a wire. */
1156
+ const PROBE_OUTCOMES = [
1157
+ "ok",
1158
+ "rate-limited",
1159
+ "model-busy",
1160
+ "out-of-credit",
1161
+ "credential-rejected",
1162
+ "policy-rejected",
1163
+ "unavailable",
1164
+ "not-found",
1165
+ "failed"
1166
+ ];
1167
+ function isProbeOutcome(value) {
1168
+ return PROBE_OUTCOMES.includes(value);
1169
+ }
1170
+ function isRetrySource(value) {
1171
+ return value === "retry-after" || value === "upstream-message" || value === "quota-refresh" || value === "upstream-tips";
1172
+ }
1173
+ /**
1174
+ * Fold ONE streamed test row into the usage document the panel is rendering.
1175
+ *
1176
+ * Why fold at all, when the batch is followed by a re-read: the batch is SERIAL
1177
+ * and a stuck member can take seconds, so a panel that changes nothing until the
1178
+ * LAST account answers is exactly the dead-looking button the streamed batch was
1179
+ * built to avoid. Folding each row as it lands makes the row change the moment
1180
+ * its measurement exists; the re-read afterwards then replaces the fold with the
1181
+ * same facts, this time from the Host's own store.
1182
+ *
1183
+ * The exclusion is RE-DERIVED here rather than left as it was: it is derived
1184
+ * state (`exclusionOf`), so copying the previous one forward would keep a
1185
+ * just-measured-good account red until the re-read — and would keep a
1186
+ * just-measured-limited one green, which is the more expensive error.
1187
+ */
1188
+ function withTestRow(usage, row, nowMs) {
1189
+ if (!("pool" in usage) || usage.pool === void 0) return usage;
1190
+ const pool = usage.pool;
1191
+ const index = indexOfTestedAccount(pool.accounts, row);
1192
+ if (index === void 0) return usage;
1193
+ const measured = pool.accounts[index];
1194
+ if (measured === void 0) return usage;
1195
+ const { excludedBy: _previous, ...account } = measured;
1196
+ const probe = {
1197
+ outcome: row.result.outcome,
1198
+ atMs: nowMs,
1199
+ source: "test-batch",
1200
+ modelId: row.result.modelId,
1201
+ ...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs },
1202
+ ...row.result.message === void 0 ? {} : { message: row.result.message }
1203
+ };
1204
+ const excludedBy = exclusionOf(probe, nowMs);
1205
+ const folded = {
1206
+ ...account,
1207
+ probe,
1208
+ ...excludedBy === void 0 ? {} : { excludedBy }
1209
+ };
1210
+ const accounts = pool.accounts.map((candidate, at) => at === index ? folded : candidate);
1211
+ return {
1212
+ ...usage,
1213
+ pool: {
1214
+ ...pool,
1215
+ accounts
1216
+ }
1217
+ };
1218
+ }
1219
+ /**
1220
+ * Which listed account a test row measured, or `undefined` when it measured none
1221
+ * of them.
1222
+ *
1223
+ * Matched by ID first. The NAME is only a fallback for a row whose id this
1224
+ * document no longer lists, and only when exactly one listed account carries it:
1225
+ * two accounts sharing a name must never have one measurement written onto both.
1226
+ */
1227
+ function indexOfTestedAccount(accounts, row) {
1228
+ const byId = accounts.findIndex((account) => account.accountId === row.accountId);
1229
+ if (byId >= 0) return byId;
1230
+ const matches = accounts.flatMap((account, at) => account.accountName === row.accountName ? [at] : []);
1231
+ return matches.length === 1 ? matches[0] : void 0;
1232
+ }
1233
+ //#endregion
727
1234
  //#region src/client/credit-format.ts
728
1235
  /**
729
1236
  * How a credit balance is written on screen.
@@ -936,9 +1443,9 @@ window.__ModuleLoader__.load({
936
1443
  }
937
1444
  //#endregion
938
1445
  //#region src/client/probe-age.ts
939
- const MINUTE_MS$1 = 6e4;
940
- const HOUR_MS$1 = 60 * MINUTE_MS$1;
941
- const DAY_MS$1 = 24 * HOUR_MS$1;
1446
+ const MINUTE_MS = 6e4;
1447
+ const HOUR_MS = 60 * MINUTE_MS;
1448
+ const DAY_MS = 24 * HOUR_MS;
942
1449
  /**
943
1450
  * How long ago a measurement was taken, coarsely: "刚刚", "N 分钟前",
944
1451
  * "N 小时前", "N 天前".
@@ -954,10 +1461,10 @@ window.__ModuleLoader__.load({
954
1461
  */
955
1462
  function probeAgeText(t, atMs, nowMs) {
956
1463
  const age = nowMs - atMs;
957
- if (age < MINUTE_MS$1) return t("row.poolProbeJustNow");
958
- if (age < HOUR_MS$1) return t("row.poolProbeMinutesAgo", { count: Math.round(age / MINUTE_MS$1) });
959
- if (age < 2 * DAY_MS$1) return t("row.poolProbeHoursAgo", { count: Math.round(age / HOUR_MS$1) });
960
- return t("row.poolProbeDaysAgo", { count: Math.round(age / DAY_MS$1) });
1464
+ if (age < MINUTE_MS) return t("row.poolProbeJustNow");
1465
+ if (age < HOUR_MS) return t("row.poolProbeMinutesAgo", { count: Math.round(age / MINUTE_MS) });
1466
+ if (age < 2 * DAY_MS) return t("row.poolProbeHoursAgo", { count: Math.round(age / HOUR_MS) });
1467
+ return t("row.poolProbeDaysAgo", { count: Math.round(age / DAY_MS) });
961
1468
  }
962
1469
  //#endregion
963
1470
  //#region src/probe.ts
@@ -993,88 +1500,6 @@ window.__ModuleLoader__.load({
993
1500
  new RegExp(`将在?\\s*${RESET_STAMP}[\\s\\S]*?重置`, "u");
994
1501
  new RegExp(`(?:reset|resets|refresh|refreshes)\\s+(?:at|on)\\s+${RESET_STAMP}`, "iu");
995
1502
  //#endregion
996
- //#region src/client/remaining.ts
997
- const MINUTE_MS = 6e4;
998
- const HOUR_MS = 60 * MINUTE_MS;
999
- const DAY_MS = 24 * HOUR_MS;
1000
- /**
1001
- * How far out hours stop being the useful unit.
1002
- *
1003
- * Below this, hours; at or above it, days. The cut is at TWO days rather than
1004
- * one so that 30 hours reads as "约 30 小时后" instead of rounding to "约 1 天后",
1005
- * which would understate it by six hours.
1006
- */
1007
- const DAYS_AT_MS = 2 * DAY_MS;
1008
- /**
1009
- * A short "约 N 分钟后 / 约 N 小时后 / 约 N 天后", or `''` once the moment passed.
1010
- *
1011
- * The empty string is the contract callers depend on: an elapsed cooldown means
1012
- * the account is already back, so appending "in 0 minutes" would assert the
1013
- * opposite of the state the card is showing. Callers append this only when it is
1014
- * non-empty.
1015
- *
1016
- * Rounding is deliberately coarse — "约 1 小时后" for 69 minutes is what a reader
1017
- * wants, and a precision that changes every minute would be noise rather than
1018
- * information. The absolute instant sits right beside it for anyone who needs it.
1019
- */
1020
- function remainingText(t, untilMs, nowMs) {
1021
- const left = untilMs - nowMs;
1022
- if (left <= 0) return "";
1023
- if (left < MINUTE_MS) return t("row.remainingImminent");
1024
- if (left < HOUR_MS) return t("row.remainingMinutes", { count: Math.round(left / MINUTE_MS) });
1025
- if (left < DAYS_AT_MS) return t("row.remainingHours", { count: Math.round(left / HOUR_MS) });
1026
- return t("row.remainingDays", { count: Math.round(left / DAY_MS) });
1027
- }
1028
- //#endregion
1029
- //#region src/account-pool.ts
1030
- /**
1031
- * The checked ids that still resolve to a local sign-in, in the caller's order.
1032
- *
1033
- * This is THE definition of "effective members", and it exists because the same
1034
- * predicate was written five separate times: the status document
1035
- * (`web-status.ts`), the batch route's empty-pool guard, the Host dependency the
1036
- * card reads, `poolMemberAccounts` and the browser helper. Five copies of one
1037
- * rule is this project's single most productive bug shape ("fixed one end,
1038
- * missed the other"), so the rule now has one home and every site delegates to
1039
- * it.
1040
- *
1041
- * ORDER IS THE CALLER'S, and comes from `orderedIds`: the answer preserves the
1042
- * order of THAT argument. Callers pass whichever list's order they want to show
1043
- * — the saved list for the user's own order, the store's roster for the account
1044
- * table's. Neither is silently reshuffled by the filter. (The first parameter was
1045
- * once named `saved`, which read as "this must be the saved list" even though
1046
- * `web-status.ts` legitimately passes the roster for store order; the name was
1047
- * the trap, so it now describes the ROLE instead of the source.)
1048
- *
1049
- * Pure, so it is unit-testable without a store, and it never mutates its input.
1050
- */
1051
- function effectiveMembersOf(orderedIds, listedIds) {
1052
- return orderedIds.filter((id) => listedIds.has(id));
1053
- }
1054
- /**
1055
- * A latest-wins guard for one region's asynchronous work.
1056
- *
1057
- * Exists because `applyRotation` awaits (it fetches credits per member), so two
1058
- * overlapping calls can interleave: a slow call that read "rotation is ON" can
1059
- * resume AFTER a newer call cleared the override for "rotation is OFF" and write
1060
- * the stale account back — leaving the card showing the pool switched off while
1061
- * a rotated account is still billed.
1062
- *
1063
- * `begin()` claims a new generation and returns a probe; the caller must check
1064
- * it after EVERY await and bail when it reports stale. Extracted as a pure
1065
- * factory rather than inlined so the rule is unit-testable: an inlined token is
1066
- * only observable by reproducing a real interleaving, which is slow and easy to
1067
- * get subtly wrong (a harness that never actually overlaps passes either way).
1068
- */
1069
- function createLatestWins() {
1070
- const generations = {};
1071
- return { begin(key) {
1072
- const generation = (generations[key] ?? 0) + 1;
1073
- generations[key] = generation;
1074
- return () => generations[key] !== generation;
1075
- } };
1076
- }
1077
- //#endregion
1078
1503
  //#region src/client/pool-state.ts
1079
1504
  /**
1080
1505
  * Pure decision helpers for the account pool's card section.
@@ -1339,47 +1764,19 @@ window.__ModuleLoader__.load({
1339
1764
  const wanted = new Set(right);
1340
1765
  return left.every((id) => wanted.has(id));
1341
1766
  }
1342
- /** Whether a probe outcome means the model answered. */
1343
- function outcomeOk(outcome) {
1344
- return outcome === "ok";
1345
- }
1346
- /** A short, localizable label for one probe outcome. */
1347
- function outcomeText(t, outcome) {
1348
- switch (outcome) {
1349
- case "ok": return t("row.probeOk");
1350
- case "rate-limited": return t("row.poolExcludedRateLimited");
1351
- case "model-busy": return t("row.poolExcludedModelBusy");
1352
- case "out-of-credit": return t("row.poolExcludedOutOfCredit");
1353
- case "credential-rejected": return t("row.probeCredentialRejected");
1354
- case "not-found": return t("row.probeNotFound");
1355
- case "unavailable": return t("row.probeUnavailable");
1356
- default: return t("row.probeFailed");
1357
- }
1358
- }
1359
- function formatNumber$1(value) {
1360
- return formatCredits(value);
1361
- }
1362
1767
  /**
1363
- * `MM/DD, HH:mm` on a 24-hour clock.
1364
- *
1365
- * `hourCycle: 'h23'` is REQUIRED, not decorative: without it `Intl` follows the
1366
- * browser locale's preference, and en-US renders `09/30, 01:23 PM`. A wall-clock
1367
- * glued to "AM/PM" reads as 12-hour no matter what the surrounding UI assumes,
1368
- * and it is the one thing a Chinese user cannot scan past — the card otherwise
1369
- * speaks 24-hour everywhere (see `formatClock`).
1768
+ * Whether two id lists are equal IN ORDER.
1370
1769
  *
1371
- * `h23` rather than `hour12: false`, because the latter maps to `h24` in some
1372
- * engines and would print midnight as `24:00`. `h23` pins 00–23 exactly, which
1373
- * is also the cycle DSH's own timestamps use.
1770
+ * Unlike `sameIds`, position matters: manual priority is order-sensitive, so a
1771
+ * swapped pair is a real change and must light up "unsaved changes". A set
1772
+ * comparison here would treat reordering as a no-op and drop the user's sort.
1374
1773
  */
1375
- function formatShort(value) {
1376
- return new Intl.DateTimeFormat(void 0, {
1377
- month: "2-digit",
1378
- day: "2-digit",
1379
- hour: "2-digit",
1380
- minute: "2-digit",
1381
- hourCycle: "h23"
1382
- }).format(new Date(value));
1774
+ function sameOrder(left, right) {
1775
+ if (left.length !== right.length) return false;
1776
+ return left.every((id, index) => id === right[index]);
1777
+ }
1778
+ function formatNumber$1(value) {
1779
+ return formatCredits(value);
1383
1780
  }
1384
1781
  function formatClock$1(value) {
1385
1782
  return new Intl.DateTimeFormat(void 0, {
@@ -1389,12 +1786,6 @@ window.__ModuleLoader__.load({
1389
1786
  hourCycle: "h23"
1390
1787
  }).format(new Date(value));
1391
1788
  }
1392
- /** A dot colour for one account's state. */
1393
- function stateColor(account) {
1394
- if (account.excludedBy !== void 0) return account.excludedBy === "rate-limited" || account.excludedBy === "model-busy" || account.excludedBy === "unavailable" ? "var(--dsw-alias-state-warn-primary, #f59e0b)" : "var(--dsw-alias-state-error-primary, #ef4444)";
1395
- if (account.probe === void 0) return "var(--dsw-alias-label-dimmed, #9aa0a6)";
1396
- return outcomeOk(account.probe.outcome) ? "var(--dsw-alias-state-success-primary, #22a06b)" : "var(--dsw-alias-state-warn-primary, #f59e0b)";
1397
- }
1398
1789
  /**
1399
1790
  * The pool section.
1400
1791
  *
@@ -1403,7 +1794,7 @@ window.__ModuleLoader__.load({
1403
1794
  * section implying the feature exists.
1404
1795
  */
1405
1796
  function AccountPool(props) {
1406
- const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools, headerExtra } = props;
1797
+ const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools } = props;
1407
1798
  const [draft, setDraft] = (0, react.useState)(null);
1408
1799
  const [saving, setSaving] = (0, react.useState)(false);
1409
1800
  const [saveError, setSaveError] = (0, react.useState)(void 0);
@@ -1414,6 +1805,7 @@ window.__ModuleLoader__.load({
1414
1805
  setLog(logStore.get(region) ?? []);
1415
1806
  }, [region]);
1416
1807
  const [draftRegion, setDraftRegion] = (0, react.useState)(void 0);
1808
+ const [orderRulesOpen, setOrderRulesOpen] = (0, react.useState)(false);
1417
1809
  const mounted = (0, react.useRef)(true);
1418
1810
  (0, react.useEffect)(() => {
1419
1811
  mounted.current = true;
@@ -1425,7 +1817,8 @@ window.__ModuleLoader__.load({
1425
1817
  enabled: pool?.enabled ?? false,
1426
1818
  targetModelId: pool?.targetModelId || pool?.staleTargetModelId || "",
1427
1819
  probeInputTokens: pool?.probeInputTokens ?? 0,
1428
- memberAccountIds: pool?.memberAccountIds ?? []
1820
+ memberAccountIds: pool?.memberAccountIds ?? [],
1821
+ orderAccountIds: pool?.orderAccountIds ?? []
1429
1822
  };
1430
1823
  const active = draftRegion === region && draft !== null ? draft : saved;
1431
1824
  /**
@@ -1446,7 +1839,7 @@ window.__ModuleLoader__.load({
1446
1839
  * the pending edit.
1447
1840
  */
1448
1841
  const poolRunning = saved.enabled;
1449
- const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1842
+ const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds) || !sameOrder(draft.orderAccountIds, saved.orderAccountIds));
1450
1843
  /**
1451
1844
  * Membership that actually resolves to a local sign-in — what a batch runs on.
1452
1845
  *
@@ -1460,6 +1853,13 @@ window.__ModuleLoader__.load({
1460
1853
  * the field.
1461
1854
  */
1462
1855
  const listedIds = new Set((pool?.accounts ?? []).map((account) => account.accountId));
1856
+ const orderIds = (() => {
1857
+ const members = active.memberAccountIds.filter((id) => listedIds.has(id));
1858
+ const explicit = active.orderAccountIds.filter((id) => members.includes(id));
1859
+ const rest = members.filter((id) => !explicit.includes(id));
1860
+ return [...explicit, ...rest];
1861
+ })();
1862
+ const orderIsManual = active.orderAccountIds.some((id) => active.memberAccountIds.includes(id));
1463
1863
  const effectiveMembers = effectiveMemberIds({
1464
1864
  savedEffective: pool?.effectiveMemberAccountIds ?? [],
1465
1865
  draftMembers: active.memberAccountIds,
@@ -1607,6 +2007,36 @@ window.__ModuleLoader__.load({
1607
2007
  };
1608
2008
  });
1609
2009
  }, [editDraft]);
2010
+ /**
2011
+ * Move one member one step up (`-1`) or down (`+1`) in the manual priority.
2012
+ *
2013
+ * The FIRST click is "the user starts sorting manually": it persists the WHOLE
2014
+ * currently-visible order into the draft, not just the one swapped pair. A
2015
+ * click that wrote only the moved id would leave the other rows unsorted (and
2016
+ * `orderIsManual` false), so the draft and the tables they power stay in sync.
2017
+ * `delta` is restricted to ±1 — a single swap per click — which is what makes
2018
+ * the first/last rows' extra buttons (rather than fields) correct.
2019
+ */
2020
+ const moveMember = (0, react.useCallback)((accountId, delta) => {
2021
+ editDraft((current) => {
2022
+ const listed = new Set((pool?.accounts ?? []).map((account) => account.accountId));
2023
+ const members = current.memberAccountIds.filter((id) => listed.has(id));
2024
+ const explicit = current.orderAccountIds.filter((id) => members.includes(id));
2025
+ const rest = members.filter((id) => !explicit.includes(id));
2026
+ const visible = [...explicit, ...rest];
2027
+ const index = visible.indexOf(accountId);
2028
+ if (index < 0) return current;
2029
+ const to = index + delta;
2030
+ if (to < 0 || to >= visible.length) return current;
2031
+ const next = [...visible];
2032
+ next.splice(index, 1);
2033
+ next.splice(to, 0, accountId);
2034
+ return {
2035
+ ...current,
2036
+ orderAccountIds: next
2037
+ };
2038
+ });
2039
+ }, [editDraft, pool]);
1610
2040
  /** Check every listed account, or none of them. */
1611
2041
  const selectAll = (0, react.useCallback)((next) => {
1612
2042
  editDraft((current) => ({
@@ -1863,7 +2293,7 @@ window.__ModuleLoader__.load({
1863
2293
  visible: true,
1864
2294
  message: t("row.poolSaving"),
1865
2295
  hint: t("row.veilHintList")
1866
- }) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), headerExtra === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-head-extra" }, headerExtra), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
2296
+ }) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
1867
2297
  className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
1868
2298
  value: currentAccount?.accountId ?? "",
1869
2299
  disabled: selectingAccount === true,
@@ -1915,23 +2345,50 @@ window.__ModuleLoader__.load({
1915
2345
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1916
2346
  disabled: effectiveMembers.length === 0 || !canEditPool,
1917
2347
  onClick: () => selectAll(false)
1918
- }, t("row.poolSelectNone")))), (0, react.createElement)("div", { className: `dsm-workbuddy-pool-table${pool.checkinSupported ? " dsm-workbuddy-pool-table-checkin" : ""}` }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null, (0, react.createElement)("span", null)), ...pool.accounts.map((account) => renderAccountRow({
1919
- t,
1920
- account,
1921
- checked: active.memberAccountIds.includes(account.accountId),
1922
- canEdit: canEditPool,
1923
- checkinSupported: pool.checkinSupported,
1924
- onToggle: (next) => toggleMember(account.accountId, next),
1925
- busy: rowBusyAccountId !== void 0,
1926
- testDisabled: pool.targetModelSource === "none" || pool.targetModelSource === "stale",
1927
- targetHint: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
1928
- onTest: () => {
1929
- runRowTest(account.accountId);
1930
- },
1931
- onRemove: () => {
1932
- runRowRemove(account.accountId, account.accountName);
1933
- }
1934
- }))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
2348
+ }, t("row.poolSelectNone")))), (0, react.createElement)("details", {
2349
+ className: "dsm-workbuddy-pool-order-rules",
2350
+ open: orderRulesOpen,
2351
+ onToggle: (event) => setOrderRulesOpen(event.currentTarget.open)
2352
+ }, (0, react.createElement)("summary", { className: "dsm-workbuddy-pool-order-rules-summary" }, t("row.poolOrderRulesTitle")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-order-rules-body" }, (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolOrderRulesIntro")), (0, react.createElement)("ol", { className: "dsm-workbuddy-pool-order-rules-list" }, ...ORDER_RULE_KEYS.map((key) => (0, react.createElement)("li", { key }, t(key)))), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-order-rules-why" }, t("row.poolOrderWhy")), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolOrderBadgeNote")))), (0, react.createElement)("div", { className: `dsm-workbuddy-pool-table${pool.checkinSupported ? " dsm-workbuddy-pool-table-checkin" : ""}` }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null, (0, react.createElement)("span", null)), ...(() => {
2353
+ const ordered = sortAccountsByOrder(pool.accounts, orderIds);
2354
+ let memberSeen = 0;
2355
+ let memberTotal = 0;
2356
+ for (const account of ordered) if (active.memberAccountIds.includes(account.accountId)) memberTotal += 1;
2357
+ return ordered.map((account) => {
2358
+ const isMember = active.memberAccountIds.includes(account.accountId);
2359
+ const left = isMember ? memberSeen : -1;
2360
+ const right = isMember ? memberTotal - 1 - memberSeen : -1;
2361
+ if (isMember) memberSeen += 1;
2362
+ return renderAccountRow({
2363
+ t,
2364
+ account,
2365
+ checked: isMember,
2366
+ canEdit: canEditPool,
2367
+ checkinSupported: pool.checkinSupported,
2368
+ onToggle: (next) => toggleMember(account.accountId, next),
2369
+ busy: rowBusyAccountId !== void 0,
2370
+ testDisabled: pool.targetModelSource === "none" || pool.targetModelSource === "stale",
2371
+ targetHint: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
2372
+ onTest: () => {
2373
+ runRowTest(account.accountId);
2374
+ },
2375
+ onRemove: () => {
2376
+ runRowRemove(account.accountId, account.accountName);
2377
+ },
2378
+ orderIndex: isMember && orderIsManual ? left + 1 : void 0,
2379
+ orderIsManual,
2380
+ moveEnabled: pool.enabled === true && isMember && canEditPool,
2381
+ onMoveUp: () => {
2382
+ moveMember(account.accountId, -1);
2383
+ },
2384
+ onMoveDown: () => {
2385
+ moveMember(account.accountId, 1);
2386
+ },
2387
+ moveUpDisabled: left <= 0,
2388
+ moveDownDisabled: right <= 0
2389
+ });
2390
+ });
2391
+ })()), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
1935
2392
  className: "dsm-workbuddy-pool-warn",
1936
2393
  role: "status"
1937
2394
  }, `${t("row.poolNoneSelected")} ${t("row.poolNoneSelectedHint")}`) : null, ghostMembers$1.length === 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-ghosts" }, (0, react.createElement)("p", {
@@ -1997,12 +2454,11 @@ window.__ModuleLoader__.load({
1997
2454
  }
1998
2455
  /** One account's row. */
1999
2456
  function renderAccountRow(input) {
2000
- const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove } = input;
2457
+ const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove, orderIndex, orderIsManual, moveEnabled, onMoveUp, onMoveDown, moveUpDisabled, moveDownDisabled } = input;
2001
2458
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
2002
2459
  const excluded = exclusionText(t, account.excludedBy);
2003
2460
  const probe = account.probe;
2004
- const outcomeLine = probe === void 0 ? t("row.poolNeverTested") : probe.retryAtMs !== void 0 ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryAt", { at: formatShort(probe.retryAtMs) })}` : probe.outcome === "rate-limited" || probe.outcome === "model-busy" || probe.outcome === "out-of-credit" ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}` : outcomeText(t, probe.outcome);
2005
- const remaining = probe?.retryAtMs === void 0 ? "" : remainingText(t, probe.retryAtMs, Date.now());
2461
+ const outcomeLine = availabilityLine(t, account) ?? t("row.poolNeverTested");
2006
2462
  const reason = probe === void 0 ? void 0 : inlineProbeReason(probe.outcome, probe.message);
2007
2463
  const age = probe === void 0 ? void 0 : probeAgeText(t, probe.atMs, Date.now());
2008
2464
  const probeLabel = probe === void 0 ? void 0 : probe.source === void 0 ? {
@@ -2015,11 +2471,7 @@ window.__ModuleLoader__.load({
2015
2471
  text: t("row.poolProbeSourceTest"),
2016
2472
  className: "dsm-workbuddy-pool-prov-tag"
2017
2473
  };
2018
- const probeLine = [
2019
- outcomeLine,
2020
- remaining,
2021
- reason
2022
- ].filter((part) => part !== void 0 && part !== "").join(" · ");
2474
+ const probeLine = [outcomeLine, reason].filter((part) => part !== void 0 && part !== "").join(" · ");
2023
2475
  return (0, react.createElement)("div", {
2024
2476
  className: `dsm-workbuddy-pool-row${account.current ? " dsm-workbuddy-pool-row-current" : ""}`,
2025
2477
  key: account.accountId
@@ -2032,7 +2484,27 @@ window.__ModuleLoader__.load({
2032
2484
  })), (0, react.createElement)("span", {
2033
2485
  className: "dsm-workbuddy-usage-dot",
2034
2486
  style: { background: stateColor(account) }
2035
- }), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShort(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShort(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
2487
+ }), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), checked ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-order" }, orderIsManual ? (0, react.createElement)("span", {
2488
+ className: "dsm-workbuddy-pool-rank",
2489
+ title: t("row.poolRankManualTitle")
2490
+ }, t("row.poolRankLabel", { rank: orderIndex ?? 0 })) : (0, react.createElement)("span", {
2491
+ className: "dsm-workbuddy-pool-rank dsm-workbuddy-pool-rank-auto",
2492
+ title: t("row.poolRankAutoTitle")
2493
+ }, t("row.poolRankAuto")), moveEnabled ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-move" }, (0, react.createElement)("button", {
2494
+ type: "button",
2495
+ className: "dsm-workbuddy-pool-move-btn",
2496
+ title: t("row.poolMoveUpTitle"),
2497
+ "aria-label": t("row.poolMoveUpAria", { accountName: name }),
2498
+ disabled: moveUpDisabled,
2499
+ onClick: onMoveUp
2500
+ }, "↑"), (0, react.createElement)("button", {
2501
+ type: "button",
2502
+ className: "dsm-workbuddy-pool-move-btn",
2503
+ title: t("row.poolMoveDownTitle"),
2504
+ "aria-label": t("row.poolMoveDownAria", { accountName: name }),
2505
+ disabled: moveDownDisabled,
2506
+ onClick: onMoveDown
2507
+ }, "↓")) : null) : null, account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShortClock(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShortClock(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
2036
2508
  className: "dsm-workbuddy-pool-probe",
2037
2509
  ...probe?.message === void 0 ? {} : { title: probe.message }
2038
2510
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age), probe === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-model" }, probe.modelId === void 0 ? t("row.poolProbeModelUnknown") : t("row.poolProbeModel", { model: probe.modelId })))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
@@ -2134,6 +2606,61 @@ window.__ModuleLoader__.load({
2134
2606
  key: `${entry.atMs}-${index}`
2135
2607
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-log-time" }, formatClock$1(entry.atMs)), (0, react.createElement)("span", { className: `dsm-workbuddy-pool-log-text dsm-workbuddy-pool-log-${entry.tone}` }, entry.text)))));
2136
2608
  }
2609
+ /**
2610
+ * Sort accounts by the manual priority order, without losing stability.
2611
+ *
2612
+ * Helpers/rows are rendered in this order so a click on ↑/↓ reorders the row
2613
+ * immediately (client-side), before any save + host round-trip. Ids in `orderIds`
2614
+ * that are not actually members are skipped; members not listed fall back to the
2615
+ * END in their original relative order.
2616
+ *
2617
+ * The sort is STABLE: two ids with the same priority rank keep their original
2618
+ * array order. A `rank` lookup returns `orderIds.length` as the sentinel for
2619
+ * "not ordered", which is larger than any real rank, so unlisted members sort
2620
+ * after every listed one. An EMPTY `orderIds` short-circuits: the input order is
2621
+ * already the answer, and we avoid building the rank map / comparator for the
2622
+ * common (never-sorted) case.
2623
+ *
2624
+ * TWO subtleties that a naive rank comparator gets wrong, both of which would
2625
+ * make the table visibly wrong:
2626
+ *
2627
+ * - DUPLICATE ids keep the FIRST occurrence's rank (`if (!rank.has(id))`).
2628
+ * Overwriting instead would let a stray duplicate silently push the account
2629
+ * it names to the duplicate's later position, so the row would drift away
2630
+ * from the badge that claims to describe it.
2631
+ * - The sort key is the PAIR (rank, original index), not rank alone. Ranks are
2632
+ * unique per id, but the `Infinity` fallback is shared by every unlisted
2633
+ * account, and `Array.prototype.sort` is NOT guaranteed stable across engines
2634
+ * for arbitrary inputs; relying on it would let the unlisted tail reshuffle
2635
+ * between renders. Breaking the tie on the original index makes the result a
2636
+ * pure function of the input, which is what stops the table from jittering
2637
+ * when the same snapshot arrives twice.
2638
+ */
2639
+ /**
2640
+ * The ordering rules, in EXACTLY the comparator's priority order.
2641
+ *
2642
+ * Kept as one table rather than inline literals so the order is a single thing
2643
+ * a reader can compare against `src/account-pool.ts` — the rule text and the
2644
+ * code must never drift, and a flat list makes that diff trivial. Typed as
2645
+ * `WorkBuddySettingsKey` so a renamed or removed locale key is a compile error
2646
+ * instead of a raw key rendered to the user.
2647
+ */
2648
+ const ORDER_RULE_KEYS = [
2649
+ "row.poolOrderRuleUsable",
2650
+ "row.poolOrderRuleExpiring",
2651
+ "row.poolOrderRuleManual",
2652
+ "row.poolOrderRuleNearestExpiry",
2653
+ "row.poolOrderRuleExpiringAmount",
2654
+ "row.poolOrderRuleTotal",
2655
+ "row.poolOrderRuleFreshness",
2656
+ "row.poolOrderRuleId"
2657
+ ];
2658
+ function sortAccountsByOrder(accounts, orderIds) {
2659
+ return applyManualOrder(accounts.map((account) => ({
2660
+ id: account.accountId,
2661
+ account
2662
+ })), orderIds).map((entry) => entry.account);
2663
+ }
2137
2664
  //#endregion
2138
2665
  //#region src/client/OAuthSignIn.tsx
2139
2666
  /**
@@ -3047,33 +3574,105 @@ window.__ModuleLoader__.load({
3047
3574
  .dsm-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
3048
3575
  .dsm-btn-primary:hover:not(:disabled){opacity:.9}
3049
3576
  .dsm-workbuddy-usage{display:flex;flex-direction:column;gap:16px;margin:0;padding:16px 0 4px}
3050
- .dsm-workbuddy-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)}
3051
- .dsm-workbuddy-tab{appearance:none;font:inherit;cursor:pointer;flex:1;border:0;border-radius:7px;padding:7px 10px;color:var(--dsw-alias-label-tertiary,#999);font-size:13px;font-weight:500;line-height:18px;background:transparent;transition:color .15s,background .15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3577
+ /* The provider bar: one CARD per region, side by side.
3578
+ *
3579
+ * COPIED FROM dsh-connect-trae, rule for rule, because an approximation of it
3580
+ * read as a different component. An earlier revision marked the selected card
3581
+ * with 1px border PLUS an inset 1.5px hairline in the contrast colour, and
3582
+ * that is 2.5 CSS px of near-black on a 2x display — five device pixels of
3583
+ * frame against the reference's one. What the reference does instead:
3584
+ *
3585
+ * - selected = the accent COLOUR on the same 1px border, plus a slightly
3586
+ * darker fill. The LIGHTNESS carries the state, not the thickness.
3587
+ * - the switch is separated from the label by a hairline RULE inside the
3588
+ * card (border-left in border-l1), not by dead space. That rule is what
3589
+ * makes the switch read as a second control in the same box rather than
3590
+ * as a floating square at the card's right edge.
3591
+ * - the cell owns its padding (8px 12px) and the tab button has none, so
3592
+ * the label's text sits at a predictable inset no matter how the button's
3593
+ * focus ring draws.
3594
+ * - 10px corner radius, matching the reference's card shape.
3595
+ *
3596
+ * The row stays what it was semantically: role="tablist" with one button per
3597
+ * region, and the checkbox inside each card is a SEPARATE control (switch this
3598
+ * provider on/off) — never merged into the tab action. Both regions can be on
3599
+ * at once, so this must not become a radio group just because the cards look
3600
+ * like one.
3601
+ *
3602
+ * The checkbox is the PLATFORM'S OWN, styled only with accent-color — the
3603
+ * same three declarations the reference card has, no more. An earlier revision
3604
+ * drew it here (appearance:none, 15px square, 3px radius, a tick made of two
3605
+ * rotated borders) on the theory that pinning the geometry would match the
3606
+ * reference across platforms. It did not: the reference does not draw it, so
3607
+ * the drawn box was a second, invented control sitting in the same row as the
3608
+ * real one. Measured against the screenshot, its box is 26 device px on a 2x
3609
+ * display — 13 CSS px, which is the engine's own default size, not a number
3610
+ * anyone wrote down.
3611
+ *
3612
+ * accent-color hands size, radius and tick glyph to the platform. That is the
3613
+ * point: the box then follows the reader's OS and theme, which is what makes
3614
+ * it read as a checkbox rather than as a painted square. */
3615
+ .dsm-workbuddy-tabs{display:flex;gap:8px;align-items:stretch}
3616
+ .dsm-workbuddy-tab-cell{display:flex;align-items:center;gap:4px;flex:1;min-width:0;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;background:var(--dsw-alias-bg-layer-3,#2a2c33);transition:border-color .15s,background .15s}
3617
+ .dsm-workbuddy-tab-cell:hover{border-color:var(--dsw-alias-label-dimmed,#777)}
3618
+ .dsm-workbuddy-tab-cell:has(.dsm-workbuddy-tab-active){border-color:var(--dsw-alias-brand-primary,#5686fe);background:var(--dsw-alias-bg-layer-2,#232529)}
3619
+ .dsm-workbuddy-tab{appearance:none;font:inherit;cursor:pointer;flex:1;min-width:0;border:0;border-radius:7px;padding:0;color:var(--dsw-alias-label-tertiary,#999);font-size:13px;font-weight:600;line-height:20px;text-align:left;background:transparent;transition:color .15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3052
3620
  .dsm-workbuddy-tab:hover:not(:disabled):not(.dsm-workbuddy-tab-active){color:var(--dsw-alias-label-primary,#e6e6e6)}
3053
3621
  .dsm-workbuddy-tab:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
3054
- .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)}
3055
- .dsm-workbuddy-tab-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:baseline}
3056
- .dsm-workbuddy-tab-cell{display:flex;align-items:center;gap:2px;flex:1;min-width:0}
3057
- .dsm-workbuddy-tab-switch{display:inline-flex;align-items:center;flex:none;padding:0 8px 0 2px;cursor:pointer}
3622
+ .dsm-workbuddy-tab-active{color:var(--dsw-alias-label-primary,#e6e6e6)}
3623
+ .dsm-workbuddy-tab-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:7px;vertical-align:baseline}
3624
+ /* The hairline rule between the label and the switch. The auto left margin is
3625
+ what pushes the switch to the card's right edge; without it the rule would
3626
+ sit tight against the text. */
3627
+ .dsm-workbuddy-tab-switch{display:inline-flex;align-items:center;flex:none;margin-left:auto;padding-left:10px;border-left:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));cursor:pointer}
3058
3628
  .dsm-workbuddy-tab-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
3059
- .dsm-workbuddy-tab-switch input:disabled{opacity:.4;cursor:default}
3060
- /* One sidebar-credit switch, belonging to the ACTIVE tab's region. It rides in
3061
- the account-pool heading row (dsm-workbuddy-pool-head-extra) so one
3062
- checkbox no longer costs a full line of vertical space; the tab bar already
3063
- says which region is in scope, so it still reads as this tab's property
3064
- rather than as a shared pair of settings. No vertical margin: the heading row
3065
- owns the spacing. */
3066
- .dsm-workbuddy-credits-switch{display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3067
- /* The standalone fallback site: used only when the account-pool section did not
3068
- render (no Host pool state), so there is no heading row to share. This is the
3069
- spacing the control carried before it moved beside that heading. */
3070
- .dsm-workbuddy-credits-switch-row{display:flex;align-items:center;margin:8px 0 2px}
3071
- /* The credit readout's seat picker (issue #62). Narrower than the pool's model
3072
- select so the show/hide box and this control share one heading row, and it
3073
- wraps onto its own line rather than squashing the pool title. */
3074
- /* Its OWN label class, deliberately not the switch's: a query for "the
3075
- sidebar-credit switch" must not be able to pick this control up, and the two
3076
- are different widgets (a checkbox and a two-way picker). */
3629
+ .dsm-workbuddy-tab-switch input:disabled{cursor:default}
3630
+ /* PREFERENCE LINES: a checkbox on a line that commits on the click, packed from
3631
+ the LEFT — the shape the reference card uses for this exact block.
3632
+ *
3633
+ * They belong to the TAB block above and carry no heading of their own, because
3634
+ * both switches describe THIS tab. Earlier revisions gave them a headed section,
3635
+ * which meant a second 「积分」 heading one line under the tab hint; the reference
3636
+ * card never had one either.
3637
+ *
3638
+ * The picker used to sit at the row's far edge, pushed there by
3639
+ * justify-content:space-between. That split one sentence into two objects with
3640
+ * half a card of empty space between them, and left the reader to guess which
3641
+ * label the control belonged to. Here nothing in a row stretches: the words ask
3642
+ * the question and the control answers right beside them.
3643
+ *
3644
+ * align-items:flex-start, not center: the hint wraps to two lines at narrow
3645
+ * widths, and centring would float the checkbox against the middle of it.
3646
+ *
3647
+ * No horizontal inset: the reference card pads this block by the same 2px its
3648
+ * tab hint carries, so box and caption share one left edge. THIS card's tab hint
3649
+ * (the dsm-workbuddy-models-summary class) has no side padding, so copying the
3650
+ * reference's 2px would leave the two edges 2px apart — a difference too small
3651
+ * to name and just big enough to look unaligned. */
3652
+ .dsm-workbuddy-prefs{display:flex;flex-direction:column;gap:14px;padding:2px 0 0}
3653
+ .dsm-workbuddy-pref{display:flex;align-items:flex-start;gap:10px;font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:pointer}
3654
+ /* The platform's own checkbox, themed only with accent-color. A hand-drawn box
3655
+ was tried on this card and reverted: brand-primary resolves to the host's
3656
+ CONTRAST colour, so a ticked box painted itself as a heavy black pill — the
3657
+ loudest object in a section made of plain 13px text. */
3658
+ .dsm-workbuddy-pref input{flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
3659
+ .dsm-workbuddy-pref-check{inline-size:15px;block-size:15px;margin:3px 0 0}
3660
+ .dsm-workbuddy-pref input:disabled{cursor:default}
3661
+ .dsm-workbuddy-pref:has(input:disabled){opacity:.6;cursor:default}
3662
+ /* The NAME and what it does are TWO lines, not one wrapped blob: first what the
3663
+ switch does, then what it means. Running them together is what made this
3664
+ block read as a wall of text. */
3665
+ .dsm-workbuddy-pref-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
3666
+ .dsm-workbuddy-pref-label{font-weight:500;color:var(--dsw-alias-label-primary,#e6e6e6)}
3667
+ /* The picker's own line: indented by the checkbox's width plus the row gap
3668
+ (15px + 10px), so its label starts on the same left edge as the checkbox
3669
+ labels above it rather than under their boxes. */
3670
+ .dsm-workbuddy-pref-indent{padding-left:25px}
3671
+ /* Name then control, packed left — 显示位置 [输入框工具栏] — so the row reads as
3672
+ one sentence instead of a text column facing a control column. */
3673
+ .dsm-workbuddy-pref-lead{display:flex;align-items:center;justify-content:flex-start;gap:10px;min-width:0}
3674
+ .dsm-workbuddy-pref-lead .dsm-workbuddy-pref-label{flex:none}
3675
+ .dsm-workbuddy-pref-hint{color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:17px}
3077
3676
  /* The supplement to the host's fixed quota sentence (issue #63). The plugin no
3078
3677
  longer claims the host's shell.quota-notice chain seat, so this no longer
3079
3678
  paints into shell.overlay — that seat is the reason the previous build's
@@ -3084,11 +3683,34 @@ window.__ModuleLoader__.load({
3084
3683
  The body is one step quieter than the text it follows. */
3085
3684
  .dsm-workbuddy-quota-notice{display:inline}
3086
3685
  .dsm-workbuddy-quota-notice-body{color:var(--dsw-alias-label-tertiary,#8b8f98);font-size:12px}
3087
- .dsm-workbuddy-credit-position-switch{display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3088
- .dsm-workbuddy-credit-position{max-width:150px;padding:2px 6px;font-size:12px}
3089
- .dsm-workbuddy-credits-switch-row .dsm-workbuddy-credit-position-switch{margin-left:12px}
3090
- .dsm-workbuddy-credits-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
3091
- .dsm-workbuddy-credits-switch input:disabled{opacity:.4;cursor:default}
3686
+ /* The seat picker is the PLATFORM's select, sized by its own two options rather
3687
+ * than stretched: the control answers one short question and sits beside the
3688
+ * words that ask it.
3689
+ *
3690
+ * This was drawn by hand for one revision — appearance:none, a layer-2 panel and
3691
+ * a chevron built from two rotated gradients — copying a reference screenshot's
3692
+ * white field. That does not survive the trip: the screenshot was a LIGHT
3693
+ * settings page, this card is dark, and the drawn field ended up the only
3694
+ * element here with its own fill, border and arrow, so it read as a form control
3695
+ * pasted into a paragraph. The reference card's own dark build uses the native
3696
+ * control with a hairline border, which is what this rule is.
3697
+ *
3698
+ * Sized by content, not by a min-width: it used to carry min-width:132px so it
3699
+ * could share a heading row with the account badge, and that floor is now the
3700
+ * reason a two-word option would still leave a gap after the word it answers. */
3701
+ .dsm-workbuddy-credit-position{flex:none;max-width:none;margin-top:0;padding:3px 8px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:7px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);font:inherit;font-size:12px;line-height:16px;cursor:pointer}
3702
+ .dsm-workbuddy-credit-position:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed,#777)}
3703
+ .dsm-workbuddy-credit-position:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
3704
+ .dsm-workbuddy-credit-position:disabled{opacity:.5;cursor:default}
3705
+ /* A card whose provider is switched OFF dims its LABEL AND DOT. The checkbox
3706
+ * beside them must stay at full strength, because that checkbox is the only
3707
+ * way to switch the region back on — dimming it would grey out the control
3708
+ * that fixes the state it is reporting. Its own disabled rule still dims it
3709
+ * when writes are unavailable, which is a different condition and reads as a
3710
+ * different thing.
3711
+ *
3712
+ * That is also what the reference card does: it marks the off state on the
3713
+ * tab button and leaves the switch live. */
3092
3714
  .dsm-workbuddy-tab-off{opacity:.55}
3093
3715
  /* Re-detection keeps a copy in the signed-out branch (that branch is where
3094
3716
  "I just signed in over there" applies), so it needs its own placement: the
@@ -3187,14 +3809,19 @@ window.__ModuleLoader__.load({
3187
3809
 
3188
3810
  /* ---- Account pool ---- */
3189
3811
  .dsm-workbuddy-pool{display:flex;flex-direction:column;gap:12px;margin:0;padding:16px 0 4px;border-top:1px solid var(--dsw-alias-border-l2,#36373b)}
3190
- /* Title, then the inline extra (the sidebar-credit switch), then the
3191
- serving-account badge on the far edge. The badge is pushed right with
3192
- margin-left:auto rather than the row using space-between: with three
3193
- children the latter would strand the switch in the middle instead of binding
3194
- it to the title, and with only two (no switch) margin-left:auto keeps the
3195
- badge on the right edge exactly as before. */
3812
+ /* The block's heading: title on the left, serving-account badge on the right.
3813
+ *
3814
+ * It used to hold a THIRD child — the credit-readout switch, slotted in between
3815
+ * the title and the badge by the card. A control that configures something else
3816
+ * spent several revisions sitting inside this block's title, and it forced the
3817
+ * row onto a shared baseline with a picker whose width shoved the badge to the
3818
+ * far edge. The switch now has its own section above; what is left is the one
3819
+ * thing that is genuinely this block's title row.
3820
+ *
3821
+ * The badge is pushed right with margin-left:auto rather than the row using
3822
+ * space-between, so it stays on the right edge even if a future change adds a
3823
+ * child or removes one. */
3196
3824
  .dsm-workbuddy-pool-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
3197
- .dsm-workbuddy-pool-head-extra{display:inline-flex;align-items:center}
3198
3825
  .dsm-workbuddy-pool-current-badge{margin-left:auto;padding:2px 10px;border-radius:999px;background:rgba(86,134,254,.16);color:var(--dsw-alias-brand-primary,#5686fe);font-size:12px;font-weight:600;line-height:18px;white-space:nowrap}
3199
3826
  /* Sits under the header block, before the batch buttons: account discovery is
3200
3827
  the first step of using the pool, so it reads above the actions it enables. */
@@ -3243,7 +3870,48 @@ window.__ModuleLoader__.load({
3243
3870
  /* Declared AFTER the blanket span rule so it wins: the badge must not inherit
3244
3871
  the muted 11px tertiary treatment the membership line uses. */
3245
3872
  .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-current-tag{align-self:flex-start;margin-top:2px;padding:1px 6px;border-radius:999px;background:rgba(86,134,254,.16);color:var(--dsw-alias-brand-primary,#5686fe);font-size:11px;font-weight:600;line-height:16px;white-space:nowrap}
3873
+ /* Manual-priority chrome (order badge + up/down arrows). The rank badge is a SPAN,
3874
+ not a button: it only DESCRIBES the current order and must not read as a
3875
+ control (nor join the row's button list). The solid vs dashed distinction is
3876
+ load-bearing — a numeric rank must NOT appear when the credit policy still
3877
+ decides the order, so the -auto modifier is the dashed/grey state. */
3878
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-order{display:flex;align-items:center;gap:6px;margin-top:2px;flex-wrap:wrap}
3879
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-rank{display:inline-flex;align-items:center;border-radius:999px;border:1px solid rgba(86,134,254,.32);background:rgba(86,134,254,.12);color:var(--dsw-alias-brand-primary,#5686fe);font-size:11px;font-weight:600;line-height:16px;padding:1px 6px;white-space:nowrap;cursor:default;font-variant-numeric:tabular-nums}
3880
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-rank-auto{border:1px dashed var(--dsw-alias-border-l1,#2c2d31);background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-secondary,#c6c9d0)}
3881
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move{display:inline-flex;align-items:center;gap:2px}
3882
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move-btn{width:18px;height:18px;padding:0;display:inline-flex;align-items:center;justify-content:center;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,#2c2d31);background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:11px;line-height:1;cursor:pointer}
3883
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move-btn:disabled{opacity:.4;cursor:default}
3884
+ /* Hover/focus must change BOTH halves of the contrast pair.
3885
+ The rule this replaces recoloured only the glyph — to
3886
+ --dsw-alias-label-primary (#e6e6e6) — and left the background as it was. In
3887
+ the LIGHT theme that token is near-WHITE, so on hover the arrow painted
3888
+ white-on-light and vanished under the cursor (reported as "the up arrow
3889
+ disappears when I hover"). Recolouring text without also repainting its
3890
+ backdrop is unsafe whenever the two come from independent tokens, so this
3891
+ sets an explicit tinted background and keeps the glyph on the brand colour,
3892
+ which is legible in both themes. The outline is a third, theme-independent
3893
+ affordance so the target is findable even if a token is remapped. */
3894
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move-btn:not(:disabled):hover,
3895
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move-btn:not(:disabled):focus-visible{background:rgba(86,134,254,.16);border-color:rgba(86,134,254,.55);color:var(--dsw-alias-brand-primary,#5686fe);outline:2px solid rgba(86,134,254,.45);outline-offset:1px}
3246
3896
  .dsm-workbuddy-pool-excluded{color:var(--dsw-alias-state-warn-primary,#f59e0b)}
3897
+ /* The ordering-rule disclosure, placed directly above the member table.
3898
+ A native <details>: the marker is left to the browser so the control keeps
3899
+ its built-in keyboard and screen-reader behaviour. The summary is a full-width
3900
+ row because a 12px grey label on its own is a small click target, and this is
3901
+ the one place a confused user is meant to click. */
3902
+ .dsm-workbuddy-pool-order-rules{border:1px solid var(--dsw-alias-border-l1,#2c2d31);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#232529);margin:0 0 10px;overflow:hidden}
3903
+ .dsm-workbuddy-pool-order-rules-summary{padding:8px 12px;cursor:pointer;user-select:none;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;font-weight:600;line-height:18px}
3904
+ .dsm-workbuddy-pool-order-rules-summary:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}
3905
+ .dsm-workbuddy-pool-order-rules-summary:focus-visible{outline:2px solid rgba(86,134,254,.55);outline-offset:-2px}
3906
+ .dsm-workbuddy-pool-order-rules-body{display:flex;flex-direction:column;gap:8px;padding:0 12px 12px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);padding-top:10px}
3907
+ /* The rules already carry their own "1." … "8." inside the sentence, so the <ol>
3908
+ is semantic only. Suppressing the marker keeps a single number on screen
3909
+ instead of "1. 1. …". */
3910
+ .dsm-workbuddy-pool-order-rules-list{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3911
+ .dsm-workbuddy-pool-order-rules-list li{padding-left:2px}
3912
+ /* The "why expiring credits win" note is the sentence users push back on, so
3913
+ it is tinted rather than buried in the same grey as the eight rules. */
3914
+ .dsm-workbuddy-pool-order-rules-why{margin:0;padding:8px 10px;border-radius:8px;border-left:3px solid var(--dsw-alias-state-warn-primary,#f59e0b);background:rgba(245,158,11,.10);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3247
3915
  .dsm-workbuddy-pool-credits{display:flex;flex-direction:column;gap:1px;min-width:0;font-variant-numeric:tabular-nums}
3248
3916
  .dsm-workbuddy-pool-credits b{color:var(--dsw-alias-label-primary,#e6e6e6);font-weight:600}
3249
3917
  .dsm-workbuddy-pool-soon{font-size:11px;line-height:16px;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
@@ -3523,11 +4191,24 @@ window.__ModuleLoader__.load({
3523
4191
  instead of the name. */
3524
4192
  .dsm-workbuddy-composer-panel-who{display:flex;flex-direction:column;min-width:0;gap:1px}
3525
4193
  .dsm-workbuddy-composer-panel-name{overflow:hidden;text-overflow:ellipsis}
3526
- /* Why the account cannot serve right now ("被限流"), in the warn colour the pool
3527
- table uses for the same fact — so one measurement reads the same on both. */
4194
+ /* The second line under the name: TWO facts, two tones.
4195
+ .dsm-workbuddy-composer-panel-mark-current -- "当前选用", green: it says which
4196
+ account is serving this tab, which is not a problem and must not be painted
4197
+ like one. The owner asked for exactly this ("框出来的,当前选用用绿色的字").
4198
+ .dsm-workbuddy-composer-panel-mark-reason -- why it cannot serve ("被限流"),
4199
+ in the warn colour the pool table uses for the same fact, so one
4200
+ measurement reads the same on both surfaces.
4201
+ The two are siblings in a flex line and the separator between them is drawn
4202
+ here, not in the text: rendered text stays exactly "当前选用 · 被限流 · 约 6
4203
+ 小时后" for anything that reads the DOM as a string. */
3528
4204
  .dsm-workbuddy-composer-panel-mark{
4205
+ display:flex;flex-wrap:wrap;align-items:baseline;
3529
4206
  font-size:11px;line-height:15px;
3530
- color:var(--dsw-alias-state-warn-primary,#f59e0b);
4207
+ }
4208
+ .dsm-workbuddy-composer-panel-mark-current{color:var(--dsw-alias-state-success-primary,#22a06b)}
4209
+ .dsm-workbuddy-composer-panel-mark-reason{color:var(--dsw-alias-state-warn-primary,#f59e0b)}
4210
+ .dsm-workbuddy-composer-panel-mark-current+.dsm-workbuddy-composer-panel-mark-reason::before{
4211
+ content:" · ";color:var(--dsw-alias-label-dimmed,rgba(127,127,127,.6));
3531
4212
  }
3532
4213
  /* Hover belongs to the switch, not the row: the number cell is not clickable,
3533
4214
  so lighting it up would promise an action it cannot perform. */
@@ -3542,9 +4223,21 @@ window.__ModuleLoader__.load({
3542
4223
  flex:none;width:7px;height:7px;border-radius:50%;
3543
4224
  background:var(--dsw-alias-label-dimmed,rgba(127,127,127,.5));
3544
4225
  }
3545
- .dsm-workbuddy-composer-panel-table tr.dsm-workbuddy-composer-panel-row-current .dsm-workbuddy-composer-panel-dot{
3546
- background:var(--dsw-state-success,#22a06b);
3547
- }
4226
+ /* Availability, matching the pool table's dot EXACTLY (see
4227
+ account-state.ts, stateTone, which is the one place the rule lives).
4228
+ Three states, and colour answers only the first question: can this account
4229
+ serve the next message? The second question -- which account is serving this
4230
+ tab -- is answered in words on the row ("current"), so the colour is free to
4231
+ stay honest about usability. Green for usable (selected or not), red for a
4232
+ refusal of any kind (a stated rate limit included: amber used to sit here and
4233
+ read as "mostly fine"), and the neutral grey above for an account nobody has
4234
+ measured yet, which is neither.
4235
+ NOTE: no backticks in this comment. The whole stylesheet is a template
4236
+ literal, and a stray backtick silently closes it (the file then fails to
4237
+ parse with a misleading "Expected a semicolon"). */
4238
+ .dsm-workbuddy-composer-panel-dot-ok{background:var(--dsw-alias-state-success-primary,#22a06b)}
4239
+ .dsm-workbuddy-composer-panel-dot-error{background:var(--dsw-alias-state-error-primary,#ef4444)}
4240
+ .dsm-workbuddy-composer-panel-dot-unknown{background:var(--dsw-alias-label-dimmed,rgba(127,127,127,.5))}
3548
4241
  .dsm-workbuddy-composer-panel-foot{
3549
4242
  display:flex;justify-content:space-between;gap:8px;
3550
4243
  margin-top:9px;padding-top:7px;
@@ -4614,39 +5307,6 @@ window.__ModuleLoader__.load({
4614
5307
  }))
4615
5308
  })] });
4616
5309
  /**
4617
- * Sidebar credit line switch — ONE control, for the ACTIVE tab's region only.
4618
- *
4619
- * The two regions are independent providers with independent accounts, so
4620
- * each tab owns its own switch and they are never shown as a pair: this one
4621
- * writes `regions.<activeRegion>.showCreditsInMainUi`, which decides only
4622
- * whether THAT region's line appears in the sidebar. That matches how every
4623
- * other control in this card is scoped to the selected tab (account, pool,
4624
- * model list), and it means a CN-only user is never shown a switch for an
4625
- * account they do not have. An earlier revision rendered both boxes side by
4626
- * side under the tab bar regardless of the active tab; that read as one
4627
- * shared setting and was replaced.
4628
- *
4629
- * RENDERED BESIDE THE POOL HEADING, not on its own row. It used to occupy a
4630
- * full line of vertical space for a single checkbox; the pool heading already
4631
- * had a title-left / badge-right row, so the control fits there without the
4632
- * block growing. It is handed to `AccountPool` as a ready-made node purely so
4633
- * the two can share a flex row — the write path, the disabled rules and the
4634
- * serialization against the card's other region writers all stay here, where
4635
- * the settings scope and the save state live.
4636
- */
4637
- const creditsSwitch = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4638
- className: "dsm-workbuddy-credits-switch",
4639
- title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
4640
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4641
- type: "checkbox",
4642
- checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
4643
- disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4644
- onChange: (event) => {
4645
- toggleCredits(activeRegion, event.target.checked);
4646
- }
4647
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal") })]
4648
- });
4649
- /**
4650
5310
  * WHERE the readout sits — a two-way choice, active tab's region only
4651
5311
  * (issue #62).
4652
5312
  *
@@ -4654,31 +5314,76 @@ window.__ModuleLoader__.load({
4654
5314
  * question together ("show it, and where"), and because a user only reaches
4655
5315
  * for this when the default seat is unavailable — typically after installing
4656
5316
  * another plugin that occupies the composer row. Rendered as a `<select>`
4657
- * rather than a pair of radios so it stays one compact row on the pool
4658
- * heading, matching the switch beside it.
5317
+ * rather than a pair of radios so it stays one compact row.
5318
+ *
5319
+ * LABEL LEFT, CONTROL RIGHT — the reverse of the first draft, which put the
5320
+ * select first. Under an always-visible caption the label is what the eye
5321
+ * scans for ("显示位置"), so it leads and the value follows; leading with the
5322
+ * control made it read as a second checkbox whose box happened to be a
5323
+ * dropdown. It also matches the reference settings screen, where the option
5324
+ * name sits left of its picker.
4659
5325
  *
4660
5326
  * Disabled with the region, exactly like the switch: a region that is off has
4661
5327
  * no readout to place.
4662
5328
  */
4663
- const creditPositionPicker = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4664
- className: "dsm-workbuddy-credit-position-switch",
4665
- title: t("row.creditPositionHint"),
4666
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
4667
- className: "dsm-workbuddy-pool-select dsm-workbuddy-credit-position",
4668
- value: activeRegionOn ? creditPositionOn(activeRegion) : "composer",
4669
- disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4670
- "aria-label": t("row.creditPositionAria", { region: activeRegion === "cn" ? t("row.tabCn") : t("row.tabGlobal") }),
4671
- onChange: (event) => {
4672
- changeCreditPosition(activeRegion, event.currentTarget.value === "stats" ? "stats" : "composer");
4673
- },
4674
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4675
- value: "composer",
4676
- children: t("row.creditPositionComposer")
4677
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4678
- value: "stats",
4679
- children: t("row.creditPositionStats")
5329
+ const creditPositionPicker = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
5330
+ className: "dsm-workbuddy-credit-position",
5331
+ value: activeRegionOn ? creditPositionOn(activeRegion) : "composer",
5332
+ disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
5333
+ "aria-label": t("row.creditPositionAria", { region: activeRegion === "cn" ? t("row.tabCn") : t("row.tabGlobal") }),
5334
+ onChange: (event) => {
5335
+ changeCreditPosition(activeRegion, event.currentTarget.value === "stats" ? "stats" : "composer");
5336
+ },
5337
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
5338
+ value: "composer",
5339
+ children: t("row.creditPositionComposer")
5340
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
5341
+ value: "stats",
5342
+ children: t("row.creditPositionStats")
5343
+ })]
5344
+ });
5345
+ const creditPositionCaption = t("row.creditPositionHintInline");
5346
+ const creditPositionDisabledCaption = t("row.creditPositionDisabledHint");
5347
+ const creditPreferences = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5348
+ className: "dsm-workbuddy-prefs",
5349
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5350
+ className: "dsm-workbuddy-pref",
5351
+ title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
5352
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5353
+ type: "checkbox",
5354
+ className: "dsm-workbuddy-pref-check",
5355
+ checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
5356
+ disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
5357
+ onChange: (event) => {
5358
+ toggleCredits(activeRegion, event.target.checked);
5359
+ }
5360
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5361
+ className: "dsm-workbuddy-pref-copy",
5362
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5363
+ className: "dsm-workbuddy-pref-label",
5364
+ children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal")
5365
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5366
+ className: "dsm-workbuddy-pref-hint",
5367
+ children: t("row.showCreditsHintInline")
5368
+ })]
4680
5369
  })]
4681
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.creditPositionLabel") })]
5370
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
5371
+ className: "dsm-workbuddy-pref dsm-workbuddy-pref-indent",
5372
+ title: t("row.creditPositionHint"),
5373
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5374
+ className: "dsm-workbuddy-pref-copy",
5375
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5376
+ className: "dsm-workbuddy-pref-lead",
5377
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5378
+ className: "dsm-workbuddy-pref-label",
5379
+ children: t("row.creditPositionLabel")
5380
+ }), creditPositionPicker]
5381
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5382
+ className: "dsm-workbuddy-pref-hint",
5383
+ children: activeRegionOn ? creditPositionCaption : creditPositionDisabledCaption
5384
+ })]
5385
+ })
5386
+ })]
4682
5387
  });
4683
5388
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
4684
5389
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
@@ -4768,6 +5473,7 @@ window.__ModuleLoader__.load({
4768
5473
  className: "dsm-workbuddy-tab-off-notice",
4769
5474
  children: t("row.tabOffNotice")
4770
5475
  }) : null,
5476
+ creditPreferences,
4771
5477
  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)("p", {
4772
5478
  className: "dsm-workbuddy-usage-hint",
4773
5479
  children: t("row.reloginHint")
@@ -4824,16 +5530,11 @@ window.__ModuleLoader__.load({
4824
5530
  },
4825
5531
  selectingAccount: switchingAccount,
4826
5532
  accountTools,
4827
- ...status.pool === void 0 ? {} : { headerExtra: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [creditsSwitch, creditPositionPicker] }) },
4828
5533
  siblingBusy: saving || togglingRegion !== void 0,
4829
5534
  onBusyChange: setPoolBusy,
4830
5535
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
4831
5536
  onRefresh: () => refreshUsage(activeRegion)
4832
5537
  }),
4833
- status.pool === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4834
- className: "dsm-workbuddy-credits-switch-row",
4835
- children: [creditsSwitch, creditPositionPicker]
4836
- }) : null,
4837
5538
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
4838
5539
  className: "dsm-workbuddy-models",
4839
5540
  "aria-label": t("row.modelsTitle"),
@@ -5346,6 +6047,57 @@ window.__ModuleLoader__.load({
5346
6047
  minute: "2-digit"
5347
6048
  });
5348
6049
  }
6050
+ /**
6051
+ * The dot's classes, one per tone.
6052
+ *
6053
+ * Written out in full instead of `\`…-dot-${tone}\`` on purpose: the panel's
6054
+ * CSS-contract test harvests class names from this file with
6055
+ * /dsm-workbuddy-composer-[a-z-]+/ and a template literal would contribute a
6056
+ * dangling "…-dot-" prefix that no rule defines.
6057
+ */
6058
+ const DOT_CLASS = {
6059
+ ok: "dsm-workbuddy-composer-panel-dot dsm-workbuddy-composer-panel-dot-ok",
6060
+ error: "dsm-workbuddy-composer-panel-dot dsm-workbuddy-composer-panel-dot-error",
6061
+ unknown: "dsm-workbuddy-composer-panel-dot dsm-workbuddy-composer-panel-dot-unknown"
6062
+ };
6063
+ /**
6064
+ * The dot class for one row: green when the account can serve, red when it cannot.
6065
+ *
6066
+ * The rule the owner settled on, after three rounds of this: colour answers
6067
+ * "can it be used", and the row's second line answers "is it the one I am
6068
+ * using". Those two questions used to share one channel — an earlier revision
6069
+ * painted green only on the selected account, which made every usable row grey
6070
+ * and the list read as one live row among dead ones ("这样无法知道当前用的是什么"):
6071
+ * true about selection, false about usability, and the panel is opened mainly to
6072
+ * ask the second question.
6073
+ *
6074
+ * So green is now the ordinary state of a usable account, selected or not, and
6075
+ * "当前选用" is printed under the name of the selected one. Red carries the
6076
+ * refusal — including a stated rate limit, which used to be amber; amber read as
6077
+ * "mostly fine", and an account that cannot answer this message is not fine.
6078
+ *
6079
+ * Grey is reserved for an account nobody has measured yet. Painting it green
6080
+ * would assert a health nobody checked, and red would assert a failure nobody
6081
+ * saw; the absence of a reading is its own state.
6082
+ */
6083
+ function dotClass(account) {
6084
+ const tone = stateTone(account);
6085
+ if (tone === "ok") return DOT_CLASS.ok;
6086
+ if (tone === "warn" || tone === "error") return DOT_CLASS.error;
6087
+ return DOT_CLASS.unknown;
6088
+ }
6089
+ /**
6090
+ * The row's second line, from the facts that belong in it.
6091
+ *
6092
+ * Two independent ones: whether this is the account serving the tab, and — when
6093
+ * it cannot serve — why, with the wait but never the absolute instant. Joined
6094
+ * with the same separator the rest of this panel uses, and dropped entirely when
6095
+ * there is nothing to say, so a plain usable row stays one line tall.
6096
+ */
6097
+ function detailLine(...parts) {
6098
+ const kept = parts.filter((part) => part !== void 0 && part !== "");
6099
+ return kept.length === 0 ? void 0 : kept.join(" · ");
6100
+ }
5349
6101
  function ComposerCredits(props) {
5350
6102
  const { t, provider, region, settingsScope } = props;
5351
6103
  if (t === void 0) throw new Error("Composer credits readout requires its translation function");
@@ -5354,11 +6106,24 @@ window.__ModuleLoader__.load({
5354
6106
  const [usage, setUsage] = (0, react.useState)(void 0);
5355
6107
  const [accountCredits, setAccountCredits] = (0, react.useState)(void 0);
5356
6108
  const [busy, setBusy] = (0, react.useState)(false);
6109
+ /**
6110
+ * A test batch is running.
6111
+ *
6112
+ * Separate from `busy`, which tracks a credits READ: the two take wildly
6113
+ * different times (one round trip against one probe per account, serial with
6114
+ * a gap), and sharing a flag would either freeze the readout for a minute or
6115
+ * let a press be swallowed as "already refreshing".
6116
+ */
6117
+ const [testing, setTesting] = (0, react.useState)(false);
6118
+ /** The batch's failure, in the user's language; `failed` is for reads. */
6119
+ const [testError, setTestError] = (0, react.useState)(void 0);
5357
6120
  const [switchingId, setSwitchingId] = (0, react.useState)(void 0);
5358
6121
  const [failed, setFailed] = (0, react.useState)(false);
5359
6122
  const [lastRefresh, setLastRefresh] = (0, react.useState)(void 0);
5360
6123
  const [open, setOpen] = (0, react.useState)(false);
5361
6124
  const inFlight = (0, react.useRef)(false);
6125
+ /** A batch is running; guards a second press that lands before `setTesting`. */
6126
+ const testingRef = (0, react.useRef)(false);
5362
6127
  /** Account-table reads in flight; see `fetchAccountCredits`. */
5363
6128
  const accountsInFlight = (0, react.useRef)(0);
5364
6129
  /** Which account-table read is the newest; see `fetchAccountCredits`. */
@@ -5546,26 +6311,85 @@ window.__ModuleLoader__.load({
5546
6311
  }
5547
6312
  };
5548
6313
  /**
5549
- * One manual refresh, of both things the panel shows.
6314
+ * The header button: test EVERY account in this region, then re-read what the
6315
+ * batch changed.
6316
+ *
6317
+ * It used to be a plain refresh. The owner asked for the other thing this
6318
+ * panel's readers actually want — the same "一键测试所有账号" the pool card
6319
+ * runs (`src/account-pool-run.ts testAllAccounts`, one account at a time with
6320
+ * a gap, because hitting the whole pool at once is how the upstream's volume
6321
+ * limit gets tripped).
6322
+ *
6323
+ * Two reasons the batch, not a read, is the right control HERE. A mark in this
6324
+ * table is a MEASUREMENT (`被限流 · 约 6 小时后` is a probe result), and
6325
+ * re-reading the usage document cannot improve a stale measurement — only
6326
+ * running it again can. And the rows are the pool's members, so the question
6327
+ * "which of these can serve?" is answered by testing them, not by asking the
6328
+ * credits endpoint again.
5550
6329
  *
5551
- * It used to call `fetchUsage` alone, so pressing 刷新 on the account table
5552
- * refreshed the trigger's balance and left every ROW exactly as it was —
5553
- * including its 被限流 / 模型繁忙 mark. The button sits inside the table's own
5554
- * header, so the user reads it as "refresh this table"; a control that
5555
- * refreshes something else is worse than no control, and it is one of the
5556
- * reasons the panel could keep claiming a state the card had already moved
5557
- * past.
6330
+ * The re-read afterwards is not a courtesy: the probes live in the USAGE
6331
+ * document (`usage.pool.accounts[].probe`), so the dots and marks only move
6332
+ * once that document is fetched again. Without it the button would leave the
6333
+ * table showing the state it just measured away.
5558
6334
  *
5559
- * Both reads run concurrently rather than in sequence: neither depends on the
5560
- * other, and awaiting the usage document first would delay the table by one
5561
- * round trip for no benefit. `refreshUsage` resolves rather than throws on
5562
- * failure (it records `failed` itself), so the table's read is never skipped
5563
- * because the other half went wrong.
6335
+ * The batch answers NDJSON (one line per account, streamed) on a current Host
6336
+ * and one document on an older one, so both shapes are read; the stream is
6337
+ * drained even after unmount, because dropping the reader leaves the Host
6338
+ * writing into a socket nobody reads.
5564
6339
  */
5565
- const refresh = (0, react.useCallback)(async () => {
5566
- setFailed(false);
5567
- await Promise.all([fetchUsage(), fetchAccountCredits(void 0, true)]);
5568
- }, [fetchUsage, fetchAccountCredits]);
6340
+ const testEveryAccount = (0, react.useCallback)(async () => {
6341
+ if (activeRegion === void 0 || testingRef.current) return;
6342
+ testingRef.current = true;
6343
+ if (mounted.current) {
6344
+ setTesting(true);
6345
+ setTestError(void 0);
6346
+ setFailed(false);
6347
+ }
6348
+ try {
6349
+ const response = await fetch(withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, activeRegion, "test"), {
6350
+ method: "POST",
6351
+ headers: { accept: "application/json" },
6352
+ credentials: "same-origin"
6353
+ });
6354
+ let failure;
6355
+ if ((response.headers.get("content-type") ?? "").includes("ndjson")) {
6356
+ if (!response.ok) failure = poolFailureText(t, { status: response.status });
6357
+ else await readNdjson(response.body, (line) => {
6358
+ const row = testRowOf(line);
6359
+ if (row !== void 0) {
6360
+ if (mounted.current) setUsage((previous) => previous === void 0 ? previous : withTestRow(previous, row, Date.now()));
6361
+ return;
6362
+ }
6363
+ const reason = line["reason"];
6364
+ if (typeof reason !== "string") return;
6365
+ failure = poolFailureText(t, {
6366
+ status: 200,
6367
+ reason,
6368
+ error: typeof line["error"] === "string" ? line["error"] : void 0
6369
+ });
6370
+ });
6371
+ } else {
6372
+ const body = await response.json().catch(() => void 0);
6373
+ if (!response.ok) failure = poolFailureText(t, {
6374
+ status: response.status,
6375
+ reason: body?.reason,
6376
+ error: body?.error
6377
+ });
6378
+ }
6379
+ if (mounted.current && failure !== void 0) setTestError(failure);
6380
+ } catch {
6381
+ if (mounted.current) setTestError(t("composer.testFailed"));
6382
+ } finally {
6383
+ testingRef.current = false;
6384
+ if (mounted.current) setTesting(false);
6385
+ await Promise.all([fetchUsage(), fetchAccountCredits(void 0, true)]);
6386
+ }
6387
+ }, [
6388
+ activeRegion,
6389
+ fetchUsage,
6390
+ fetchAccountCredits,
6391
+ t
6392
+ ]);
5569
6393
  if (owned === void 0) return null;
5570
6394
  /**
5571
6395
  * The Host's own answer for this region, when the document carries one.
@@ -5609,6 +6433,9 @@ window.__ModuleLoader__.load({
5609
6433
  };
5610
6434
  });
5611
6435
  const canSwitch = settingsScope !== void 0 && settingsScope.getSnapshot().writable === true;
6436
+ const poolOrder = usage?.status === "signed-in" ? usage.pool?.orderAccountIds : void 0;
6437
+ const orderedRows = applyManualOrder(rows, poolOrder !== void 0 && poolOrder.length > 0 ? poolOrder : settingsScope === void 0 ? [] : readOrderAccountIds(settingsScope.getSnapshot().value, activeRegion));
6438
+ const poolById = new Map((usage?.status === "signed-in" ? usage.pool?.accounts ?? [] : []).map((account) => [account.accountId, account]));
5612
6439
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5613
6440
  ref: rootRef,
5614
6441
  className: "dsm-workbuddy-composer-credits",
@@ -5644,11 +6471,12 @@ window.__ModuleLoader__.load({
5644
6471
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5645
6472
  type: "button",
5646
6473
  className: "dsm-workbuddy-composer-panel-refresh",
5647
- disabled: busy || switchingId !== void 0,
6474
+ disabled: busy || testing || switchingId !== void 0,
6475
+ title: t("composer.testAllHint"),
5648
6476
  onClick: () => {
5649
- refresh();
6477
+ testEveryAccount();
5650
6478
  },
5651
- children: busy ? t("composer.refreshing") : t("composer.refresh")
6479
+ children: testing ? t("composer.testing") : t("composer.testAll")
5652
6480
  })]
5653
6481
  }),
5654
6482
  signedOut ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -5666,13 +6494,25 @@ window.__ModuleLoader__.load({
5666
6494
  scope: "col",
5667
6495
  className: "dsm-workbuddy-composer-panel-num",
5668
6496
  children: t("composer.credits")
5669
- })] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: rows.map((row) => {
6497
+ })] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: orderedRows.map((row) => {
5670
6498
  const current = row.id === selectedId;
5671
- const excluded = exclusionText(t, row.excludedBy);
6499
+ const poolRow = poolById.get(row.id);
6500
+ const live = poolRow === void 0 ? current : poolRow.current;
6501
+ const availability = poolRow ?? {
6502
+ excludedBy: row.excludedBy,
6503
+ probe: void 0
6504
+ };
6505
+ const usable = stateTone(availability) === "ok";
6506
+ const currentMark = live ? t("composer.current") : void 0;
6507
+ const reasonMark = usable ? void 0 : availabilityMark(t, availability);
6508
+ const spoken = detailLine(live ? t("composer.current") : void 0, usable ? void 0 : availabilityLine(t, availability));
6509
+ const whoLabel = spoken === void 0 ? row.accountName : `${row.accountName} — ${spoken}`;
5672
6510
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", {
5673
6511
  className: current ? "dsm-workbuddy-composer-panel-row-current" : void 0,
6512
+ title: spoken,
5674
6513
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
5675
6514
  scope: "row",
6515
+ "aria-label": whoLabel,
5676
6516
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5677
6517
  type: "button",
5678
6518
  className: "dsm-workbuddy-composer-panel-switch",
@@ -5682,16 +6522,22 @@ window.__ModuleLoader__.load({
5682
6522
  switchTo(row.id);
5683
6523
  },
5684
6524
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5685
- className: "dsm-workbuddy-composer-panel-dot",
6525
+ className: dotClass(availability),
5686
6526
  "aria-hidden": "true"
5687
6527
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5688
6528
  className: "dsm-workbuddy-composer-panel-who",
5689
6529
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5690
6530
  className: "dsm-workbuddy-composer-panel-name",
5691
6531
  children: row.accountName
5692
- }), excluded === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6532
+ }), currentMark === void 0 && reasonMark === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5693
6533
  className: "dsm-workbuddy-composer-panel-mark",
5694
- children: excluded
6534
+ children: [currentMark === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6535
+ className: "dsm-workbuddy-composer-panel-mark-current",
6536
+ children: currentMark
6537
+ }), reasonMark === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6538
+ className: "dsm-workbuddy-composer-panel-mark-reason",
6539
+ children: reasonMark
6540
+ })]
5695
6541
  })]
5696
6542
  })]
5697
6543
  })
@@ -5714,7 +6560,12 @@ window.__ModuleLoader__.load({
5714
6560
  className: "dsm-workbuddy-composer-panel-error",
5715
6561
  role: "status",
5716
6562
  children: t("composer.refreshFailed")
5717
- }) : null
6563
+ }) : null,
6564
+ testError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6565
+ className: "dsm-workbuddy-composer-panel-error",
6566
+ role: "status",
6567
+ children: testError
6568
+ })
5718
6569
  ]
5719
6570
  }), document.body)]
5720
6571
  });
@@ -6169,6 +7020,19 @@ window.__ModuleLoader__.load({
6169
7020
  "row.poolSelectAll": "Select all",
6170
7021
  "row.poolSelectNone": "Clear all",
6171
7022
  "row.poolSelectedCount": "{count} of {total} selected",
7023
+ "row.poolOrderRulesTitle": "How this pool chooses an account",
7024
+ "row.poolOrderRulesIntro": "The pool always picks the first account that can actually be billed right now. The rules below are checked in order — the first one that separates two accounts decides; later rules only break ties.",
7025
+ "row.poolOrderRuleUsable": "1. Can it be billed right now? Accounts that are rate-limited, out of credit, credential-rejected, or failing are moved to the back until their cooldown ends. This outranks everything, including your own order below.",
7026
+ "row.poolOrderRuleExpiring": "2. Does it have credits expiring within 3 days? Yes goes first. This also outranks your manual order — see the note below.",
7027
+ "row.poolOrderRuleManual": "3. Your manual order. The position you set with the ↑ / ↓ arrows. Accounts you never ordered are placed after every ordered one.",
7028
+ "row.poolOrderRuleNearestExpiry": "4. Which expiry comes first? Between accounts that both have credits expiring, the one losing them sooner goes first.",
7029
+ "row.poolOrderRuleExpiringAmount": "5. How many are expiring? Between those same accounts, the one with more at stake goes first. With nothing expiring this is equivalent to comparing totals.",
7030
+ "row.poolOrderRuleTotal": "6. Total credits. The account holding more goes first, so a nearly-drained account is kept in reserve.",
7031
+ "row.poolOrderRuleFreshness": "7. Freshness of the sign-in. The more recently issued credential wins a draw.",
7032
+ "row.poolOrderRuleId": "8. Account id. A final tie-break, so the same pool always produces the same order.",
7033
+ "row.poolOrderWhy": "Why expiring credits outrank your manual order: expiring credits are money about to disappear, while a manual order may be stale or set by accident. If the manual order won, one mis-drag could let credits lapse unused. So an account with credits expiring within 3 days always goes ahead of one without, even if you dragged it to the bottom.",
7034
+ "row.poolOrderBadgeNote": "The badge shows the position you SAVED, not the live ranking. An account can read \"No. 2\" while being benched for a rate limit — the number is your saved preference, and the pool skips accounts it cannot bill right now.",
7035
+ "row.poolOrderRulesToggle": "Show the full ordering rule",
6172
7036
  "row.poolNoneSelected": "No account is checked into this pool.",
6173
7037
  "row.poolNoneSelectedHint": "Check at least one account above and save. Until then both buttons stay disabled, because a pool with no members has nothing to run on.",
6174
7038
  "row.poolGhostMembers": "{count} pool member(s) are no longer signed in on this machine, so they are not counted and not used: {names}. Remove any you no longer need below.",
@@ -6181,6 +7045,14 @@ window.__ModuleLoader__.load({
6181
7045
  "row.poolCurrentHeader": "In use: {account}",
6182
7046
  "row.poolNotMember": "Not in pool",
6183
7047
  "row.poolUnsavedMembers": "Selection changed — save to apply",
7048
+ "row.poolRankLabel": "No. {rank}",
7049
+ "row.poolRankManualTitle": "You placed this account at position {rank} in the pool manually.",
7050
+ "row.poolRankAuto": "Auto",
7051
+ "row.poolRankAutoTitle": "Not manually sorted yet: the pool spends expiring credits first, then picks accounts by remaining balance. Use ↑/↓ to set your own order.",
7052
+ "row.poolMoveUpTitle": "Move account up in this pool's order",
7053
+ "row.poolMoveDownTitle": "Move account down in this pool's order",
7054
+ "row.poolMoveUpAria": "Move {accountName} up in this pool's order",
7055
+ "row.poolMoveDownAria": "Move {accountName} down in this pool's order",
6184
7056
  "row.poolRowTest": "Test",
6185
7057
  "row.poolRowRemove": "Remove",
6186
7058
  "row.poolRowRemoveHint": "Remove this account from the list. The WorkBuddy desktop app is not signed out — it keeps its own sign-in. Removing only hides the account here; 「Detect accounts again」 brings it back.",
@@ -6188,13 +7060,16 @@ window.__ModuleLoader__.load({
6188
7060
  "row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
6189
7061
  "row.poolRowRemoving": "Removing the account…",
6190
7062
  "row.poolRowRemoveDesktopOnly": "{accountName}: hidden from the list — this account comes from the desktop app's own sign-in, which is left untouched. 「Detect accounts again」 re-adds it.",
6191
- "row.showCreditsCn": "WB CN credits in composer",
6192
- "row.showCreditsGlobal": "WB AI credits in composer",
7063
+ "row.showCreditsCn": "Show credits in the composer toolbar",
7064
+ "row.showCreditsGlobal": "Show credits in the composer toolbar",
6193
7065
  "row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
6194
- "row.creditPositionLabel": "position",
7066
+ "row.creditPositionLabel": "Position",
6195
7067
  "row.creditPositionComposer": "composer toolbar",
6196
7068
  "row.creditPositionStats": "below the input box",
6197
7069
  "row.creditPositionAria": "Credit readout position for {region}",
7070
+ "row.creditPositionHintInline": "Where the readout sits. \"composer toolbar\" is the default — the row with the model picker.",
7071
+ "row.creditPositionDisabledHint": "Unavailable while the switch above is off: tick \"Show credits in the composer toolbar\" first.",
7072
+ "row.showCreditsHintInline": "The balance for the account currently serving this tab. Click for per-account balances; it also refreshes every 5 minutes.",
6198
7073
  "quotaNotice.fallbackHead": "The quota for this request is exhausted",
6199
7074
  "quotaNotice.supplement": " (some models also have a daily limit that is unrelated to your credits)",
6200
7075
  "row.creditPositionHint": "Where the credit readout sits. \"composer toolbar\" is the default (the row with the model picker). Choose \"below the input box\" — the row carrying the round / step / token statistics — if another plugin occupies the composer toolbar and the readout does not appear.",
@@ -6206,10 +7081,13 @@ window.__ModuleLoader__.load({
6206
7081
  "composer.account": "Account",
6207
7082
  "composer.credits": "Credits",
6208
7083
  "composer.switchTo": "Switch to {account}",
7084
+ "composer.current": "In use",
6209
7085
  "composer.noAccounts": "No account in this region",
6210
7086
  "composer.signedOut": "Not signed in",
6211
- "composer.refresh": "Refresh credits",
6212
- "composer.refreshing": "Refreshing credits…",
7087
+ "composer.testAll": "Test all",
7088
+ "composer.testing": "Testing…",
7089
+ "composer.testAllHint": "Test every account in this region, one at a time",
7090
+ "composer.testFailed": "Could not test the accounts",
6213
7091
  "composer.refreshFailed": "Could not refresh credits",
6214
7092
  "composer.lastRefresh": "Last refreshed",
6215
7093
  "composer.panelTitle": "WorkBuddy credits",
@@ -6454,6 +7332,19 @@ window.__ModuleLoader__.load({
6454
7332
  "row.poolSelectAll": "全选",
6455
7333
  "row.poolSelectNone": "全不选",
6456
7334
  "row.poolSelectedCount": "已选 {count} / {total}",
7335
+ "row.poolOrderRulesTitle": "本池如何挑选账号",
7336
+ "row.poolOrderRulesIntro": "池子总是先挑「现在确实能扣费的」账号。下面这些规则按顺序逐条比较,第一条分出胜负就停止,后面的只在平局时才看。",
7337
+ "row.poolOrderRuleUsable": "1. 现在能扣费吗?被限流、积分耗尽、凭证被拒或调用失败的账号会被挪到后面,直到冷却结束。这一条优先级最高,压过下面的手动顺序。",
7338
+ "row.poolOrderRuleExpiring": "2. 有 3 天内会过期的积分吗?有就排前面。这一条同样压过手动顺序,原因见下方说明。",
7339
+ "row.poolOrderRuleManual": "3. 你排的手动顺序。就是用 ↑ / ↓ 设定的位置。没有手动排过的账号,会排在所有已排序账号之后。",
7340
+ "row.poolOrderRuleNearestExpiry": "4. 谁先过期?两个都有临期积分时,先到期的排前面。",
7341
+ "row.poolOrderRuleExpiringAmount": "5. 过期多少?同样是两个都有临期积分时,临期数额大的排前面。没有临期积分时,这一步等同于比较总积分。",
7342
+ "row.poolOrderRuleTotal": "6. 总积分。积分多的排前面,接近耗尽的号先留着备用。",
7343
+ "row.poolOrderRuleFreshness": "7. 登录凭证新旧。凭证签发更晚的赢下平局。",
7344
+ "row.poolOrderRuleId": "8. 账号 id。最终兜底,保证同样的池子每次排出来的顺序都一样。",
7345
+ "row.poolOrderWhy": "为什么临期积分压过手动顺序:临期积分是马上要过期的钱,而手动顺序可能已经过时、也可能是手滑拖错了。如果让手动顺序赢,一次误拖就足以让积分白白过期。所以有 3 天内到期积分的账号,永远排在没有的账号前面 —— 哪怕你把它拖到了最后一位。",
7346
+ "row.poolOrderBadgeNote": "徽标显示的是你「保存」的位置,不是此刻的实际排序。一个账号可能写着「第 2 位」却因为被限流而暂时不被选中 —— 那个数字代表你的偏好,池子会跳过当下无法扣费的账号。",
7347
+ "row.poolOrderRulesToggle": "查看完整排序规则",
6457
7348
  "row.poolNoneSelected": "这个池里还没有勾选任何账号。",
6458
7349
  "row.poolNoneSelectedHint": "请在上方勾选至少一个账号并保存。在此之前两个按钮都不可用 —— 空池没有可执行的对象。",
6459
7350
  "row.poolGhostMembers": "有 {count} 个已选入本池的账号在本机已不再登录,因此不计入、也不会被使用:{names}。不需要的可在下方逐个移除。",
@@ -6466,6 +7357,14 @@ window.__ModuleLoader__.load({
6466
7357
  "row.poolCurrentHeader": "当前使用:{account}",
6467
7358
  "row.poolNotMember": "未入池",
6468
7359
  "row.poolUnsavedMembers": "勾选已改动 —— 保存后生效",
7360
+ "row.poolRankLabel": "第 {rank} 位",
7361
+ "row.poolRankManualTitle": "你在本池中手动把该账号排在第 {rank} 位。",
7362
+ "row.poolRankAuto": "自动排序",
7363
+ "row.poolRankAutoTitle": "还没有手动排序:池会先花临期积分,再按余额多寡选号。用 ↑/↓ 自己指定顺序。",
7364
+ "row.poolMoveUpTitle": "在本池顺序中上移该账号",
7365
+ "row.poolMoveDownTitle": "在本池顺序中下移该账号",
7366
+ "row.poolMoveUpAria": "在本池顺序中上移 {accountName}",
7367
+ "row.poolMoveDownAria": "在本池顺序中下移 {accountName}",
6469
7368
  "row.poolRowTest": "测试",
6470
7369
  "row.poolRowRemove": "删除",
6471
7370
  "row.poolRowRemoveHint": "把这个账号从列表中移除。桌面 App 不会被退出登录——它保留自己的登录状态。移除只是暂时隐藏,点「重新检测账号」即可找回。",
@@ -6473,13 +7372,16 @@ window.__ModuleLoader__.load({
6473
7372
  "row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
6474
7373
  "row.poolRowRemoving": "正在删除账号…",
6475
7374
  "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
6476
- "row.showCreditsCn": "对话框显示 WB CN 积分",
6477
- "row.showCreditsGlobal": "对话框显示 WB AI 积分",
7375
+ "row.showCreditsCn": "在输入框工具栏显示积分读数",
7376
+ "row.showCreditsGlobal": "在输入框工具栏显示积分读数",
6478
7377
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
6479
- "row.creditPositionLabel": "位置",
7378
+ "row.creditPositionLabel": "显示位置",
6480
7379
  "row.creditPositionComposer": "输入框工具栏",
6481
7380
  "row.creditPositionStats": "输入框下方",
6482
7381
  "row.creditPositionAria": "{region} 的积分显示位置",
7382
+ "row.creditPositionHintInline": "读数显示在哪里。「输入框工具栏」是默认位置,与模型选择同一行。",
7383
+ "row.creditPositionDisabledHint": "主开关关闭时不可调整:先勾选上方「在输入框工具栏显示积分读数」。",
7384
+ "row.showCreditsHintInline": "当前为本 tab 服务的那个账号的余额。点一下能看各账号明细,另外每 5 分钟自动刷新一次。",
6483
7385
  "quotaNotice.fallbackHead": "当前请求的额度已用尽",
6484
7386
  "quotaNotice.supplement": "(部分模型另有每日额度,与积分无关)",
6485
7387
  "row.creditPositionHint": "积分显示在哪里。「输入框工具栏」是默认位置(与模型选择同一行)。如果装了别的插件后读数不显示,改成「输入框下方」—— 那是「轮次 / 步数 / token」统计所在的那一行。",
@@ -6491,10 +7393,13 @@ window.__ModuleLoader__.load({
6491
7393
  "composer.account": "账号",
6492
7394
  "composer.credits": "积分",
6493
7395
  "composer.switchTo": "切换到 {account}",
7396
+ "composer.current": "当前选用",
6494
7397
  "composer.noAccounts": "该区域暂无账号",
6495
7398
  "composer.signedOut": "未登录",
6496
- "composer.refresh": "刷新积分",
6497
- "composer.refreshing": "正在刷新积分…",
7399
+ "composer.testAll": "一键测试",
7400
+ "composer.testing": "正在测试…",
7401
+ "composer.testAllHint": "逐个测试本区所有账号(一次一个,避免触发上游限流)",
7402
+ "composer.testFailed": "测试失败",
6498
7403
  "composer.refreshFailed": "积分刷新失败",
6499
7404
  "composer.lastRefresh": "最近刷新",
6500
7405
  "composer.panelTitle": "WorkBuddy 积分",