pilotswarm 0.5.42 → 0.5.44
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/mcp/README.md +25 -1
- package/mcp/dist/src/auth.d.ts.map +1 -1
- package/mcp/dist/src/auth.js +5 -3
- package/mcp/dist/src/auth.js.map +1 -1
- package/mcp/dist/src/resources/models.d.ts.map +1 -1
- package/mcp/dist/src/resources/models.js +21 -14
- package/mcp/dist/src/resources/models.js.map +1 -1
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +2 -0
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
- package/mcp/dist/src/tools/agent-packages.js +28 -16
- package/mcp/dist/src/tools/agent-packages.js.map +1 -1
- package/mcp/dist/src/tools/models.d.ts.map +1 -1
- package/mcp/dist/src/tools/models.js +55 -47
- package/mcp/dist/src/tools/models.js.map +1 -1
- package/mcp/dist/src/tools/providers.d.ts +18 -0
- package/mcp/dist/src/tools/providers.d.ts.map +1 -0
- package/mcp/dist/src/tools/providers.js +284 -0
- package/mcp/dist/src/tools/providers.js.map +1 -0
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +10 -1
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/mcp/dist/src/tools/system.js +2 -2
- package/mcp/dist/src/tools/system.js.map +1 -1
- package/mcp/dist/src/util/respond.d.ts.map +1 -1
- package/mcp/dist/src/util/respond.js +6 -0
- package/mcp/dist/src/util/respond.js.map +1 -1
- package/package.json +3 -3
- package/tui/README.md +8 -5
- package/tui/src/agents-cli.js +80 -78
- package/tui/src/app.js +119 -17
- package/tui/src/node-sdk-transport.js +150 -347
- package/ui/core/src/controller.js +1079 -23
- package/ui/core/src/reducer.js +649 -1
- package/ui/core/src/selectors.js +891 -6
- package/ui/core/src/state.js +163 -3
- package/ui/react/src/components.js +146 -4
- package/ui/react/src/web-app.js +1732 -50
- package/web/README.md +4 -4
- package/web/api/router.js +33 -0
- package/web/dist/assets/{abnfDiagram-VRR7QNED-Jh9nLlRI.js → abnfDiagram-VRR7QNED-67Q0WWge.js} +1 -1
- package/web/dist/assets/{arc-CmN4jFMW.js → arc-BvcYFngS.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DgUSVwl7.js → architectureDiagram-ZJ3FMSHR-CPBN0rk8.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-CCJEXNgp.js → blockDiagram-677ZJIJ3-D_hp1Oah.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-DG11zUre.js → c4Diagram-LMCZKHZV-DAOVGMIW.js} +1 -1
- package/web/dist/assets/channel-DiW7lUT8.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-Cl8MUWGh.js → chunk-2Q5K7J3B-DajZuaot.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-BaLutEQY.js → chunk-32BRIVSS-C6pXBsgt.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-BOPcnMeW.js → chunk-5VM5RSS4-780edrmi.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-bY2tdfJ2.js → chunk-EX3LRPZG-C0e4NVuz.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-CASBfQW6.js → chunk-JWPE2WC7-uRe83I6C.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-WrEHfiAj.js → chunk-MOJQB5TN-B0WPoPjh.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-XQoZii_K.js → chunk-RYQCIY6F-eRFuHHyG.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-BvA5V7VF.js → chunk-V7JOEXUC-C8FDXprk.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-SK_jCKYo.js → chunk-VR4S4FIN-DPFpLQdB.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-CtZZImS8.js → chunk-XXDRQBXY-CoGQOPKW.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-CpAhBd5x.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-CpAhBd5x.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-PRXhsdiX.js → cose-bilkent-JH36ORCC-BTBSfO5v.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CzUocqvb.js → cynefin-VYW2F7L2-DgTkQEoK.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DYGEqQ23.js → cynefinDiagram-TSTJHNR4-DiBMlKoP.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-CAyb_zBo.js → dagre-VKFMJZFB-Qv-4dcbk.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CvpXMmL-.js → diagram-FQU43EPY-CgyAb7yx.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-DBQRZJS0.js → diagram-G47NLZAW-BxN-KcGp.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-BGzD61gL.js → diagram-NH7WQ7WH-BiellpWz.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DqQf4oMW.js → diagram-OA4YK3LP-C8Ktgrwv.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-CYHoWtg7.js → diagram-WEI45ONY-Bj5K1X91.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-oMOMgcs8.js → ebnfDiagram-CCIWWBDH-DNQI2XT9.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-DaZaseAH.js → erDiagram-Q63AITRT-D5Xkllu6.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-BIe9TtAL.js → flowDiagram-23GEKE2U-DJpTtMuj.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-DitrY3hM.js → ganttDiagram-NO4QXBWP-C-EQlCJf.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-DRjIH_99.js → gitGraphDiagram-IHSO6WYX-C8vpSbvW.js} +1 -1
- package/web/dist/assets/index-C2ioXNJJ.js +24 -0
- package/web/dist/assets/index-CVYm02UI.css +1 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-BJCx4g9_.js → infoDiagram-FWYZ7A6U-CKNzsR3r.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CLxmaTWp.js → ishikawaDiagram-FXEZZL3T-CjyQq3lf.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-D2_VI8CC.js → journeyDiagram-5HDEW3XC-CK3EEkJt.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-DnUq9E7I.js → kanban-definition-HUTT4EX6-CHQ8YkPx.js} +1 -1
- package/web/dist/assets/{linear-mKMwWrAB.js → linear-CUYirs1h.js} +1 -1
- package/web/dist/assets/{mermaid.core-CApAWULn.js → mermaid.core-Di8sQC4g.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-D2twxIMO.js → mindmap-definition-LN4V7U3C-D38dmHP5.js} +1 -1
- package/web/dist/assets/{papaparse.min-BhDLFNak.js → papaparse.min-DWBU_pXS.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-D2RtIeDP.js → pegDiagram-2B236MQR-BU759QCo.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-vonumKaj.js → pieDiagram-ENE6RG2P-BbpfHbsj.js} +1 -1
- package/web/dist/assets/pilotswarm-DrM3pXOV.js +97 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CAZL1kJH.js → quadrantDiagram-ABIIQ3AL-BYZ3XHZn.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-CgpDomFp.js → railroadDiagram-RFXS5EU6-D18gkZBJ.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-9B6yfsXV.js → requirementDiagram-TGXJPOKE-D6vo26Ib.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_FAnwxi.js → sankeyDiagram-HTMAVEWB-CxQwXnRE.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-BbL6OCms.js → sequenceDiagram-DBY2YBRQ-CfGisw4A.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-DIRKFU8M.js → sizeCapture-X5ZJPWSS-BDK7-yA-.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-pUX5NF32.js → stateDiagram-2N3HPSRC-8IyidYSV.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-F7qYkvpr.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-DO7BjQ4-.js → swimlanes-5IMT3BWC-CgNqXCvM.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CUKrHLkY.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-BcbYRQ7s.js → timeline-definition-FHXFAJF6-DAJsGj6d.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-CUt_orN5.js → vennDiagram-L72KCM5P-DDVdvG5k.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-BlUPWQdi.js → wardleyDiagram-EHGQE667-CiAs_m72.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-CDW7A5_U.js → xychartDiagram-FW5EYKEG-D6OLJtsD.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/runtime.js +220 -2
- package/web/server.js +35 -7
- package/web/dist/assets/channel-BQBGhjIw.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-BIr20mVO.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-BIr20mVO.js +0 -1
- package/web/dist/assets/index-Cl6-pI9g.js +0 -24
- package/web/dist/assets/index-DRLA5aNR.css +0 -1
- package/web/dist/assets/pilotswarm-DETFHWYc.js +0 -97
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-J_okbpZN.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-gTxQyRZa.js +0 -8
package/ui/core/src/selectors.js
CHANGED
|
@@ -28,7 +28,13 @@ import {
|
|
|
28
28
|
getContextHeaderBadge,
|
|
29
29
|
} from "./context-usage.js";
|
|
30
30
|
import { canonicalSystemTitle } from "./system-titles.js";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
BUDGET_PERIODS,
|
|
33
|
+
BUDGET_SERIES_DAYS,
|
|
34
|
+
BUDGET_SERIES_RANGES,
|
|
35
|
+
normalizeArtifactEntries,
|
|
36
|
+
normalizeSessionPause,
|
|
37
|
+
} from "./state.js";
|
|
32
38
|
import { isRecoverableTransportErrorText } from "./session-errors.js";
|
|
33
39
|
|
|
34
40
|
export const ACTIVE_HIGHLIGHT_BACKGROUND = "activeHighlightBackground";
|
|
@@ -82,6 +88,13 @@ function getSessionVisualStatus(session) {
|
|
|
82
88
|
if (!session) return "unknown";
|
|
83
89
|
const status = session.status || "unknown";
|
|
84
90
|
const dormant = status === "waiting" || status === "idle" || status === "unknown";
|
|
91
|
+
// A budget pause outranks every other dormant reading — see the same
|
|
92
|
+
// branch in the reducer's computeRawSessionVisualStatus, which the two
|
|
93
|
+
// must agree on. The session is not "waiting" in the sense the list means
|
|
94
|
+
// it: nothing happens until a person changes a number.
|
|
95
|
+
if (dormant && normalizeSessionPause(session)) {
|
|
96
|
+
return "budget_paused";
|
|
97
|
+
}
|
|
85
98
|
if (session.cronActive === true && dormant) {
|
|
86
99
|
return "cron_waiting";
|
|
87
100
|
}
|
|
@@ -96,7 +109,115 @@ function getSessionVisualStatus(session) {
|
|
|
96
109
|
return status;
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
// ── Budget pauses: the words a paused session is described in ──────────
|
|
113
|
+
//
|
|
114
|
+
// The canonical sentences live in packages/sdk/src/provider-budgets.ts
|
|
115
|
+
// (`budgetWaitReason`) and are what the server writes into the wait itself.
|
|
116
|
+
// These are the SHORT forms — a list row has a few characters, not a
|
|
117
|
+
// sentence — plus a matching long form for the surface's "Paused now" band,
|
|
118
|
+
// which has to say which of the four reasons it is because each has a
|
|
119
|
+
// different remedy.
|
|
120
|
+
|
|
121
|
+
const BUDGET_PERIOD_WORD = { day: "daily", week: "weekly", month: "monthly" };
|
|
122
|
+
|
|
123
|
+
/** "paused · limit" — what fits beside a title. */
|
|
124
|
+
function budgetPauseLabel(pause) {
|
|
125
|
+
// The row status is debounced (see mergeSessionRowVisualStatus), so it can
|
|
126
|
+
// outlive by a few seconds the record that produced it. Name the state
|
|
127
|
+
// without inventing a reason for it.
|
|
128
|
+
if (!pause) return "paused";
|
|
129
|
+
switch (pause.kind) {
|
|
130
|
+
case "no_provider": return "no provider";
|
|
131
|
+
case "hold": return "paused · hold";
|
|
132
|
+
case "allowance": return "paused · allowance";
|
|
133
|
+
case "limit":
|
|
134
|
+
default: return "paused · limit";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The sentence the band and the selected row show. Names the remedy. */
|
|
139
|
+
function budgetPauseSentence(pause, nowMs = Date.now()) {
|
|
140
|
+
const provider = pause?.provider || "this session's provider";
|
|
141
|
+
// One reset spelling for the whole surface — see formatUntilLabel. The
|
|
142
|
+
// server writes the same sentence with a raw timestamp; this is the only
|
|
143
|
+
// clause that differs, because a bare UTC stamp is not something a reader
|
|
144
|
+
// can subtract from their own clock.
|
|
145
|
+
const until = pause?.resetsAtUtc ? formatUntilLabel(pause.resetsAtUtc, nowMs) : null;
|
|
146
|
+
const when = until ? ` Resets ${until}.` : "";
|
|
147
|
+
switch (pause?.kind) {
|
|
148
|
+
case "no_provider":
|
|
149
|
+
return pause?.provider
|
|
150
|
+
? `No provider named ${pause.provider}. This session waits until that name exists again, or until it is switched to another provider and model.`
|
|
151
|
+
: "This session's model does not name a provider, so nothing can pay for it. Switch it to a provider and model.";
|
|
152
|
+
case "hold":
|
|
153
|
+
return pause?.resetsAtUtc
|
|
154
|
+
? `${provider} is on hold.${when}`
|
|
155
|
+
: `${provider} is on hold until an administrator releases it.`;
|
|
156
|
+
case "allowance": {
|
|
157
|
+
// A record that names no period still reads as a sentence: the
|
|
158
|
+
// word is dropped, not left as a gap.
|
|
159
|
+
const period = BUDGET_PERIOD_WORD[pause?.period];
|
|
160
|
+
return `Your ${period ? `${period} ` : ""}allowance on ${provider} is used up. `
|
|
161
|
+
+ `The provider has room; your share of it does not.${when}`;
|
|
162
|
+
}
|
|
163
|
+
case "limit":
|
|
164
|
+
default: {
|
|
165
|
+
const period = BUDGET_PERIOD_WORD[pause?.period];
|
|
166
|
+
const scope = pause?.modelQualified ? ` for ${pause.modelQualified}` : "";
|
|
167
|
+
return `${provider} has reached its ${period ? `${period} ` : ""}limit${scope}.${when}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The pause record for one session, from whichever place holds it.
|
|
174
|
+
*
|
|
175
|
+
* A session row carries `pauseState` on deployments that publish it. Where it
|
|
176
|
+
* does not, the row arrives with a bare status of "waiting" and the reason
|
|
177
|
+
* lives only in `listPausedSessions` — which is read into `state.budget.paused`
|
|
178
|
+
* — so three sessions stopped for three different reasons render identically.
|
|
179
|
+
* Joining the two here means the list, the detail box and the "Paused now"
|
|
180
|
+
* band all describe the same pause in the same words.
|
|
181
|
+
*/
|
|
182
|
+
function budgetPauseForSession(session, state) {
|
|
183
|
+
const own = normalizeSessionPause(session);
|
|
184
|
+
if (own) return own;
|
|
185
|
+
const sessionId = session?.sessionId;
|
|
186
|
+
if (!sessionId) return null;
|
|
187
|
+
const rows = Array.isArray(state?.budget?.paused) ? state.budget.paused : [];
|
|
188
|
+
const row = rows.find((entry) => entry?.sessionId === sessionId);
|
|
189
|
+
// listPausedSessions calls the record `pause`; a session row calls it
|
|
190
|
+
// `pauseState`. One normalizer reads both.
|
|
191
|
+
return row ? normalizeSessionPause({ pauseState: row.pause }) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The pause a session row draws, or null.
|
|
196
|
+
*
|
|
197
|
+
* Colour: RED, for every kind. A pause is a full stop that only a person can
|
|
198
|
+
* clear — the limits in this model are hard and there is no warn-only one —
|
|
199
|
+
* and yellow already means "waiting, nothing to do". The row still says
|
|
200
|
+
* "paused", not "failed", so the colour is the alarm and the label is the
|
|
201
|
+
* fact.
|
|
202
|
+
*/
|
|
203
|
+
function sessionPauseMark(session, state = null) {
|
|
204
|
+
const pause = budgetPauseForSession(session, state);
|
|
205
|
+
if (!pause) return null;
|
|
206
|
+
return {
|
|
207
|
+
kind: pause.kind,
|
|
208
|
+
provider: pause.provider,
|
|
209
|
+
period: pause.period,
|
|
210
|
+
// A vanished provider never counts down — see budgetClearsLabel.
|
|
211
|
+
resetsAtUtc: pause.kind === "no_provider" ? null : pause.resetsAtUtc,
|
|
212
|
+
label: budgetPauseLabel(pause),
|
|
213
|
+
reason: budgetPauseSentence(pause),
|
|
214
|
+
clears: budgetClearsLabel(pause),
|
|
215
|
+
color: "red",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
99
219
|
function sessionStatusLabelForVisualStatus(session, status) {
|
|
220
|
+
if (status === "budget_paused") return budgetPauseLabel(normalizeSessionPause(session));
|
|
100
221
|
if (status === "cron_waiting") return "waiting";
|
|
101
222
|
if (status === "awaiting_children") {
|
|
102
223
|
// The row status is debounced (see mergeSessionRowVisualStatus), so it
|
|
@@ -209,6 +330,8 @@ function getSessionRowVisualKind(session, mode = "local") {
|
|
|
209
330
|
function sessionStatusColor(session, mode = "local") {
|
|
210
331
|
switch (getSessionRowVisualKind(session, mode)) {
|
|
211
332
|
case "running": return "green";
|
|
333
|
+
// See sessionPauseMark for why a pause is red rather than yellow.
|
|
334
|
+
case "budget_paused": return "red";
|
|
212
335
|
case "cron_waiting": return "yellow";
|
|
213
336
|
// Distinct from both running (this session is not doing the work) and
|
|
214
337
|
// waiting (nothing is happening) — something IS running, one level down.
|
|
@@ -228,6 +351,9 @@ function sessionStatusColor(session, mode = "local") {
|
|
|
228
351
|
function sessionStatusIcon(session, mode = "local") {
|
|
229
352
|
switch (getSessionRowVisualKind(session, mode)) {
|
|
230
353
|
case "running": return "*";
|
|
354
|
+
// Pause bars. One cell wide, like every other glyph in this column —
|
|
355
|
+
// the portal draws a coloured dot instead and ignores the character.
|
|
356
|
+
case "budget_paused": return "‖";
|
|
231
357
|
case "cron_waiting": return "~";
|
|
232
358
|
case "awaiting_children": return ">";
|
|
233
359
|
case "waiting": return "~";
|
|
@@ -913,6 +1039,28 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
|
|
|
913
1039
|
if (collapseBadge) {
|
|
914
1040
|
titleRuns.push({ text: ` ${collapseBadge.text}`, color: collapseBadge.color, bold: collapseBadge.bold });
|
|
915
1041
|
}
|
|
1042
|
+
// A budget pause rides on the TITLE line, not only in the selected-row
|
|
1043
|
+
// detail: a session that will not move again until someone raises a limit
|
|
1044
|
+
// has to say so from the list, or the only symptom is a session that has
|
|
1045
|
+
// been quiet for a suspiciously long time.
|
|
1046
|
+
//
|
|
1047
|
+
// Two ways in, and they are gated differently on purpose:
|
|
1048
|
+
//
|
|
1049
|
+
// the session's own `pauseState` — this is what the reducer debounces
|
|
1050
|
+
// into the row status, so the label rides the SAME debounced kind the
|
|
1051
|
+
// dot and the glyph read and the three cannot disagree. A pause that
|
|
1052
|
+
// has just been released leaves the dot red for a few seconds with no
|
|
1053
|
+
// label, exactly as every other status change here does.
|
|
1054
|
+
// the paused list (state.budget.paused) — not debounced, and the only
|
|
1055
|
+
// place the reason exists on a deployment whose session rows carry a
|
|
1056
|
+
// bare "waiting". Shown whenever the row is waiting, or three sessions
|
|
1057
|
+
// stopped for three different reasons all read the same.
|
|
1058
|
+
const pauseMark = (getSessionRowVisualKind(session, mode) === "budget_paused" || session?.status === "waiting")
|
|
1059
|
+
? sessionPauseMark(session, state)
|
|
1060
|
+
: null;
|
|
1061
|
+
if (pauseMark) {
|
|
1062
|
+
titleRuns.push({ text: ` ${pauseMark.label}`, color: pauseMark.color });
|
|
1063
|
+
}
|
|
916
1064
|
// Scheduled sessions keep a compact clock glyph on the title; the full
|
|
917
1065
|
// cron cadence rides in the detail line.
|
|
918
1066
|
const cronBadge = getCronBadge(session);
|
|
@@ -965,6 +1113,13 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
|
|
|
965
1113
|
const childCount = totalDescendantCounts?.[session?.sessionId];
|
|
966
1114
|
if (childCount) { pushSep(); detailRuns.push({ text: `${childCount} child${childCount === 1 ? "" : "ren"}`, color: "gray" }); }
|
|
967
1115
|
if (cronBadge) { pushSep(); detailRuns.push({ text: cronBadge.text, color: cronBadge.color }); }
|
|
1116
|
+
// Why this session is parked, on the selected row. The reason names
|
|
1117
|
+
// the remedy, and the four reasons have four different ones, so the
|
|
1118
|
+
// detail line carries the sentence rather than the short label.
|
|
1119
|
+
if (pauseMark) {
|
|
1120
|
+
pushSep();
|
|
1121
|
+
detailRuns.push({ text: pauseMark.reason, color: pauseMark.color });
|
|
1122
|
+
}
|
|
968
1123
|
// Shared-session marker (security model): private needs no marker;
|
|
969
1124
|
// shared_read/shared_write surface here in the selected-row details.
|
|
970
1125
|
if (session?.visibility === "shared_read" || session?.visibility === "shared_write") {
|
|
@@ -1025,6 +1180,10 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
|
|
|
1025
1180
|
}
|
|
1026
1181
|
: null,
|
|
1027
1182
|
childBadge: collapseBadge ? { text: collapseBadge.text, color: collapseBadge.color } : null,
|
|
1183
|
+
// The pause as a CHIP: the portal draws one, the TUI already has
|
|
1184
|
+
// the same words in titleRuns above and ignores this.
|
|
1185
|
+
// { kind, label, reason, provider, period, resetsAtUtc, color } or null.
|
|
1186
|
+
pause: pauseMark,
|
|
1028
1187
|
cron: Boolean(cronBadge),
|
|
1029
1188
|
age: relTime || null,
|
|
1030
1189
|
model: modelLabel || null,
|
|
@@ -1130,6 +1289,17 @@ export function selectSessionRows(state) {
|
|
|
1130
1289
|
// back a row built before the draw and the marker never appears.
|
|
1131
1290
|
state.canvas?.bySessionId?.[entry.sessionId],
|
|
1132
1291
|
state.canvas?.prefs?.[entry.sessionId],
|
|
1292
|
+
// Why the session is parked, if it is. `session` above changes
|
|
1293
|
+
// identity whenever this does, so this entry is belt and braces —
|
|
1294
|
+
// but a row's inputs should be readable off this list, and a
|
|
1295
|
+
// paused row that never repaints is a session nobody can see is
|
|
1296
|
+
// stuck.
|
|
1297
|
+
session?.pauseState,
|
|
1298
|
+
// The other half of the same answer: on a deployment that does not
|
|
1299
|
+
// publish `pauseState` on a session row, the reason is only in the
|
|
1300
|
+
// paused list. Omit this and the memo hands back a row built before
|
|
1301
|
+
// that list arrived, and the row says "waiting" forever.
|
|
1302
|
+
state.budget?.paused,
|
|
1133
1303
|
];
|
|
1134
1304
|
const cached = memo.get(entry.sessionId);
|
|
1135
1305
|
if (cached && sameRowDeps(cached.deps, deps)) return cached.row;
|
|
@@ -1174,6 +1344,10 @@ export function selectSessionRows(state) {
|
|
|
1174
1344
|
// "canvas" a canvas exists and nothing is new
|
|
1175
1345
|
// "unseen" it changed since this user last looked at it
|
|
1176
1346
|
canvasMark: sessionCanvasMark(state, entry.sessionId),
|
|
1347
|
+
// Why this session is parked on a budget, or null. Lifted out of
|
|
1348
|
+
// `chrome` as well so a host that draws its own row (the phone's
|
|
1349
|
+
// list) can act on it without reading the portal's descriptor.
|
|
1350
|
+
pause: rowView.chrome?.pause || null,
|
|
1177
1351
|
};
|
|
1178
1352
|
memo.set(entry.sessionId, { deps, row });
|
|
1179
1353
|
return row;
|
|
@@ -3682,6 +3856,125 @@ export function selectAdminConsole(state) {
|
|
|
3682
3856
|
|
|
3683
3857
|
const systemGhcpKey = admin.systemGhcpKey || { supported: false, loading: false, configured: false, changedBy: null, changedAt: null, error: null };
|
|
3684
3858
|
const isAdmin = Boolean(profile?.isAdmin);
|
|
3859
|
+
const modelProviderState = admin.modelProviders || {};
|
|
3860
|
+
const providerRows = Array.isArray(modelProviderState.providers) ? modelProviderState.providers : [];
|
|
3861
|
+
const modelRows = Array.isArray(modelProviderState.models) ? modelProviderState.models : [];
|
|
3862
|
+
const ownProviderRows = providerRows.filter((provider) => provider?.class !== "shared"
|
|
3863
|
+
&& (provider?.mine === true || provider?.usableByMe === true));
|
|
3864
|
+
const sharedProviderRows = providerRows.filter((provider) => provider?.class === "shared");
|
|
3865
|
+
const choiceRows = (providers) => providers.flatMap((provider) => modelRows
|
|
3866
|
+
.filter((model) => model?.providerId === provider.name || model?.providerId === provider.typeId)
|
|
3867
|
+
.map((model) => {
|
|
3868
|
+
const modelName = model?.modelName || model?.name || String(model?.qualifiedName || "").split(":").pop();
|
|
3869
|
+
const qualifiedName = model?.providerId === provider.name && model?.qualifiedName
|
|
3870
|
+
? model.qualifiedName
|
|
3871
|
+
: `${provider.name}:${modelName}`;
|
|
3872
|
+
return {
|
|
3873
|
+
provider: provider.name,
|
|
3874
|
+
providerClass: provider.class,
|
|
3875
|
+
model: modelName,
|
|
3876
|
+
qualifiedName,
|
|
3877
|
+
supportedReasoningEfforts: Array.isArray(model?.supportedReasoningEfforts) ? model.supportedReasoningEfforts : [],
|
|
3878
|
+
defaultReasoningEffort: model?.defaultReasoningEffort || null,
|
|
3879
|
+
supportedContextTiers: Array.isArray(model?.supportedContextTiers) ? model.supportedContextTiers : [],
|
|
3880
|
+
defaultContextTier: model?.defaultContextTier || null,
|
|
3881
|
+
};
|
|
3882
|
+
}));
|
|
3883
|
+
const defaults = modelProviderState.defaults || {};
|
|
3884
|
+
const providerTypeMap = new Map();
|
|
3885
|
+
for (const model of modelRows) {
|
|
3886
|
+
if (!model?.providerId || providerTypeMap.has(model.providerId)) continue;
|
|
3887
|
+
providerTypeMap.set(model.providerId, {
|
|
3888
|
+
id: model.providerId,
|
|
3889
|
+
label: model.providerType || model.providerId,
|
|
3890
|
+
});
|
|
3891
|
+
}
|
|
3892
|
+
const providerSummary = (provider) => ({
|
|
3893
|
+
name: provider.name,
|
|
3894
|
+
typeId: provider.typeId || provider.type || null,
|
|
3895
|
+
class: provider.class === "shared" ? "shared" : "personal",
|
|
3896
|
+
hasCredential: Boolean(provider.hasCredential),
|
|
3897
|
+
usableByMe: provider.usableByMe !== false,
|
|
3898
|
+
systemUseEnabled: Boolean(provider.systemUseEnabled),
|
|
3899
|
+
systemEligible: Boolean(provider.systemEligible),
|
|
3900
|
+
isClusterDefault: Boolean(provider.isClusterDefault),
|
|
3901
|
+
isMyDefault: Boolean(provider.isMyDefault),
|
|
3902
|
+
isSystemDefault: Boolean(provider.isSystemDefault),
|
|
3903
|
+
});
|
|
3904
|
+
const overrides = isAdmin && Array.isArray(defaults.systemOverrides) ? defaults.systemOverrides : [];
|
|
3905
|
+
const overrideByAgent = new Map(overrides.map((override) => [override.agentId, override]));
|
|
3906
|
+
const systemAgentsById = new Map();
|
|
3907
|
+
for (const session of Object.values(state.sessions?.byId || {})) {
|
|
3908
|
+
if (!session?.isSystem || !session?.agentId) continue;
|
|
3909
|
+
if (!systemAgentsById.has(session.agentId)) {
|
|
3910
|
+
systemAgentsById.set(session.agentId, {
|
|
3911
|
+
agentId: session.agentId,
|
|
3912
|
+
title: canonicalSystemTitle(session, state.branding?.title || "PilotSwarm"),
|
|
3913
|
+
});
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
for (const override of overrides) {
|
|
3917
|
+
if (!systemAgentsById.has(override.agentId)) {
|
|
3918
|
+
systemAgentsById.set(override.agentId, { agentId: override.agentId, title: override.agentId });
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
const effectiveSystemDefault = defaults.system?.effective || null;
|
|
3922
|
+
const systemAgentRoutes = [...systemAgentsById.values()]
|
|
3923
|
+
.sort((left, right) => left.title.localeCompare(right.title))
|
|
3924
|
+
.map((agent) => {
|
|
3925
|
+
const override = overrideByAgent.get(agent.agentId) || null;
|
|
3926
|
+
const effective = override || effectiveSystemDefault;
|
|
3927
|
+
return {
|
|
3928
|
+
...agent,
|
|
3929
|
+
effectiveModel: effective?.model || null,
|
|
3930
|
+
source: override ? "agent_override" : (effectiveSystemDefault?.source || "system_default"),
|
|
3931
|
+
override,
|
|
3932
|
+
};
|
|
3933
|
+
});
|
|
3934
|
+
const providerSelection = modelProviderState.selection || {};
|
|
3935
|
+
const providerPage = modelProviderState.page === "shared" && isAdmin ? "shared" : "mine";
|
|
3936
|
+
const modelProviders = {
|
|
3937
|
+
loading: Boolean(modelProviderState.loading),
|
|
3938
|
+
error: modelProviderState.error || null,
|
|
3939
|
+
page: providerPage,
|
|
3940
|
+
providerTypes: [...providerTypeMap.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
|
3941
|
+
myProviders: ownProviderRows.map((provider) => ({
|
|
3942
|
+
...providerSummary(provider),
|
|
3943
|
+
selected: provider.name === providerSelection.providerName,
|
|
3944
|
+
})),
|
|
3945
|
+
sharedProviders: sharedProviderRows.map((provider) => ({
|
|
3946
|
+
...providerSummary(provider),
|
|
3947
|
+
selected: provider.name === providerSelection.providerName,
|
|
3948
|
+
})),
|
|
3949
|
+
userChoices: choiceRows([
|
|
3950
|
+
...sharedProviderRows.filter((provider) => provider.usableByMe !== false),
|
|
3951
|
+
...ownProviderRows.filter((provider) => provider.usableByMe !== false),
|
|
3952
|
+
]),
|
|
3953
|
+
clusterChoices: isAdmin ? choiceRows(sharedProviderRows) : [],
|
|
3954
|
+
systemChoices: isAdmin ? choiceRows([
|
|
3955
|
+
...sharedProviderRows,
|
|
3956
|
+
...ownProviderRows.filter((provider) => provider.systemUseEnabled === true),
|
|
3957
|
+
]) : [],
|
|
3958
|
+
mySessionDefault: defaults.userSession || { configured: null, effective: null },
|
|
3959
|
+
clusterSessionDefault: isAdmin ? (defaults.clusterSession || { configured: null, effective: null }) : null,
|
|
3960
|
+
systemSessionDefault: isAdmin ? (defaults.system || { configured: null, effective: null }) : null,
|
|
3961
|
+
systemAgentOverrides: overrides,
|
|
3962
|
+
systemAgentRoutes: isAdmin ? systemAgentRoutes.map((route) => ({
|
|
3963
|
+
...route,
|
|
3964
|
+
selected: route.agentId === providerSelection.agentId,
|
|
3965
|
+
})) : [],
|
|
3966
|
+
selection: {
|
|
3967
|
+
focus: providerSelection.focus === "agents" ? "agents" : "providers",
|
|
3968
|
+
providerName: providerSelection.providerName
|
|
3969
|
+
|| (providerPage === "shared" ? sharedProviderRows[0]?.name : ownProviderRows[0]?.name)
|
|
3970
|
+
|| null,
|
|
3971
|
+
agentId: providerSelection.agentId || systemAgentRoutes[0]?.agentId || null,
|
|
3972
|
+
},
|
|
3973
|
+
mutation: {
|
|
3974
|
+
pending: modelProviderState.mutation?.pending || null,
|
|
3975
|
+
error: modelProviderState.mutation?.error || null,
|
|
3976
|
+
},
|
|
3977
|
+
};
|
|
3685
3978
|
// Admin-only target switch: when on, Set/Replace/Clear act on the
|
|
3686
3979
|
// SYSTEM user's key (ownerless system sessions run on it).
|
|
3687
3980
|
const storeAsSystem = isAdmin && Boolean(ghcpKey.storeAsSystem);
|
|
@@ -3804,9 +4097,11 @@ export function selectAdminConsole(state) {
|
|
|
3804
4097
|
// Settings tree — the session-list-slot navigation. Rendered by both
|
|
3805
4098
|
// hosts; `kind` drives affordances (section rows switch panes, package
|
|
3806
4099
|
// rows select a package).
|
|
3807
|
-
const section = ["packages", "workers"].includes(admin.section) ? admin.section : "
|
|
4100
|
+
const section = ["providers", "packages", "workers"].includes(admin.section) ? admin.section : "providers";
|
|
3808
4101
|
const settingsTree = [
|
|
3809
|
-
{ id: "
|
|
4102
|
+
{ id: "providers", kind: "section", depth: 0, label: "Model Providers", selected: false },
|
|
4103
|
+
{ id: "myProviders", kind: "subsection", depth: 1, label: "My Providers", selected: section === "providers" && providerPage === "mine" },
|
|
4104
|
+
...(isAdmin ? [{ id: "sharedProviders", kind: "subsection", depth: 1, label: "Shared Providers", selected: section === "providers" && providerPage === "shared" }] : []),
|
|
3810
4105
|
{ id: "agents", kind: "section", depth: 0, label: "Agents", selected: section === "packages" && !pkgState.selectedName },
|
|
3811
4106
|
{ id: "group:shared", kind: "group", depth: 1, label: "Shared", count: sharedRows.length },
|
|
3812
4107
|
...sharedRows.map((row) => ({ id: `pkg:shared:${row.name}`, kind: "package", depth: 2, label: row.name, ...row })),
|
|
@@ -4069,7 +4364,7 @@ export function selectAdminConsole(state) {
|
|
|
4069
4364
|
if (section === "workers") {
|
|
4070
4365
|
actions.push({ id: "workersRefresh", label: "Refresh workers", key: "r" });
|
|
4071
4366
|
actions.push({ id: "showPackages", label: "Agents", key: "a" });
|
|
4072
|
-
actions.push({ id: "
|
|
4367
|
+
actions.push({ id: "showProviders", label: "Model Providers", key: "m" });
|
|
4073
4368
|
actions.push({ id: "close", label: "Close console", key: "Esc" });
|
|
4074
4369
|
return {
|
|
4075
4370
|
visible: Boolean(admin.visible),
|
|
@@ -4081,6 +4376,7 @@ export function selectAdminConsole(state) {
|
|
|
4081
4376
|
settingsTree,
|
|
4082
4377
|
packages: packagesView,
|
|
4083
4378
|
workers: workersView,
|
|
4379
|
+
modelProviders,
|
|
4084
4380
|
ghcpKey: {
|
|
4085
4381
|
configured: Boolean(profile?.githubCopilotKeySet),
|
|
4086
4382
|
targetConfigured,
|
|
@@ -4107,7 +4403,52 @@ export function selectAdminConsole(state) {
|
|
|
4107
4403
|
actions.push({ id: "packagesRefresh", label: "Refresh packages", key: "r" });
|
|
4108
4404
|
actions.push({ id: "packagesNext", label: "Next package", key: "j" });
|
|
4109
4405
|
actions.push({ id: "packagesPrev", label: "Previous package", key: "k" });
|
|
4110
|
-
actions.push({ id: "
|
|
4406
|
+
actions.push({ id: "showProviders", label: "Model Providers", key: "m" });
|
|
4407
|
+
actions.push({ id: "close", label: "Close console", key: "Esc" });
|
|
4408
|
+
return {
|
|
4409
|
+
visible: Boolean(admin.visible),
|
|
4410
|
+
loading: Boolean(admin.loading),
|
|
4411
|
+
loadError: admin.loadError || null,
|
|
4412
|
+
principal,
|
|
4413
|
+
isAdmin,
|
|
4414
|
+
section,
|
|
4415
|
+
settingsTree,
|
|
4416
|
+
packages: packagesView,
|
|
4417
|
+
workers: workersView,
|
|
4418
|
+
modelProviders,
|
|
4419
|
+
ghcpKey: {
|
|
4420
|
+
configured: Boolean(profile?.githubCopilotKeySet),
|
|
4421
|
+
targetConfigured,
|
|
4422
|
+
storeAsSystem,
|
|
4423
|
+
editing: false,
|
|
4424
|
+
draft: "",
|
|
4425
|
+
cursorIndex: 0,
|
|
4426
|
+
saving: false,
|
|
4427
|
+
error: null,
|
|
4428
|
+
statusText: ghcpStatusText,
|
|
4429
|
+
},
|
|
4430
|
+
systemGhcpKey: {
|
|
4431
|
+
supported: Boolean(systemGhcpKey.supported),
|
|
4432
|
+
loading: Boolean(systemGhcpKey.loading),
|
|
4433
|
+
configured: Boolean(systemGhcpKey.configured),
|
|
4434
|
+
changedBy: systemGhcpKey.changedBy || null,
|
|
4435
|
+
changedAt: systemGhcpKey.changedAt || null,
|
|
4436
|
+
error: systemGhcpKey.error || null,
|
|
4437
|
+
},
|
|
4438
|
+
actions,
|
|
4439
|
+
};
|
|
4440
|
+
}
|
|
4441
|
+
if (section === "providers") {
|
|
4442
|
+
actions.push({ id: "addProvider", label: "Add GitHub provider", key: "e" });
|
|
4443
|
+
actions.push({ id: "cycleUserDefault", label: "Cycle my default", key: "u" });
|
|
4444
|
+
if (isAdmin) {
|
|
4445
|
+
actions.push({ id: "cycleClusterDefault", label: "Cycle cluster default", key: "l" });
|
|
4446
|
+
actions.push({ id: "cycleSystemDefault", label: "Cycle system default", key: "s" });
|
|
4447
|
+
actions.push({ id: "toggleSystemUse", label: "Toggle system use", key: "t" });
|
|
4448
|
+
actions.push({ id: "cycleSystemOverride", label: "Cycle system-agent override", key: "o" });
|
|
4449
|
+
}
|
|
4450
|
+
actions.push({ id: "refreshProviders", label: "Refresh", key: "r" });
|
|
4451
|
+
actions.push({ id: "showPackages", label: "Agents", key: "a" });
|
|
4111
4452
|
actions.push({ id: "close", label: "Close console", key: "Esc" });
|
|
4112
4453
|
return {
|
|
4113
4454
|
visible: Boolean(admin.visible),
|
|
@@ -4119,6 +4460,7 @@ export function selectAdminConsole(state) {
|
|
|
4119
4460
|
settingsTree,
|
|
4120
4461
|
packages: packagesView,
|
|
4121
4462
|
workers: workersView,
|
|
4463
|
+
modelProviders,
|
|
4122
4464
|
ghcpKey: {
|
|
4123
4465
|
configured: Boolean(profile?.githubCopilotKeySet),
|
|
4124
4466
|
targetConfigured,
|
|
@@ -4166,6 +4508,7 @@ export function selectAdminConsole(state) {
|
|
|
4166
4508
|
settingsTree,
|
|
4167
4509
|
packages: packagesView,
|
|
4168
4510
|
workers: workersView,
|
|
4511
|
+
modelProviders,
|
|
4169
4512
|
ghcpKey: {
|
|
4170
4513
|
configured: Boolean(profile?.githubCopilotKeySet),
|
|
4171
4514
|
targetConfigured,
|
|
@@ -4257,6 +4600,548 @@ export function selectAdminGhcpKeyEditorModal(state, maxWidth = 76) {
|
|
|
4257
4600
|
};
|
|
4258
4601
|
}
|
|
4259
4602
|
|
|
4603
|
+
export function selectAdminProviderCreateModal(state, maxWidth = 76) {
|
|
4604
|
+
const admin = state.admin || {};
|
|
4605
|
+
const create = admin.modelProviders?.create || {};
|
|
4606
|
+
if (!admin.visible || !create.editing) return null;
|
|
4607
|
+
const credentialStage = create.stage === "credential";
|
|
4608
|
+
const shared = create.shared === true;
|
|
4609
|
+
const ownTitle = /github/i.test(create.typeId || "")
|
|
4610
|
+
? "Add GitHub Copilot provider"
|
|
4611
|
+
: `Add ${create.typeId || "model"} provider`;
|
|
4612
|
+
const value = String(create.draft || "");
|
|
4613
|
+
return {
|
|
4614
|
+
type: "adminProviderCreate",
|
|
4615
|
+
title: credentialStage
|
|
4616
|
+
? (shared ? "Add shared model provider" : ownTitle)
|
|
4617
|
+
: (shared ? "Name the shared provider" : ownTitle),
|
|
4618
|
+
label: credentialStage ? "credential" : "provider name",
|
|
4619
|
+
displayValue: credentialStage && value ? "•".repeat(value.length) : value,
|
|
4620
|
+
cursorIndex: Math.max(0, Math.min(Number(create.cursorIndex) || 0, value.length)),
|
|
4621
|
+
placeholder: credentialStage ? "Paste token" : "my-ghcp",
|
|
4622
|
+
saving: Boolean(create.saving),
|
|
4623
|
+
error: create.error || null,
|
|
4624
|
+
idealWidth: Math.max(56, Math.min(maxWidth, 76)),
|
|
4625
|
+
detailsLines: credentialStage
|
|
4626
|
+
? [
|
|
4627
|
+
{ text: `Provider ${create.name}`, color: "white", bold: true },
|
|
4628
|
+
{ text: `Type ${create.typeId}`, color: "cyan" },
|
|
4629
|
+
{ text: shared ? "Shared" : "Only you", color: "gray" },
|
|
4630
|
+
]
|
|
4631
|
+
: [
|
|
4632
|
+
{ text: `Type ${create.typeId} [Tab cycles]`, color: "cyan", bold: true },
|
|
4633
|
+
{ text: "Permanent. Used in provider:model.", color: "gray" },
|
|
4634
|
+
{ text: "Letters, numbers, dot, dash, and underscore only.", color: "gray" },
|
|
4635
|
+
],
|
|
4636
|
+
helpLines: credentialStage
|
|
4637
|
+
? ["Type/paste credential", "Enter create provider", "Esc cancel and clear"]
|
|
4638
|
+
: ["Type provider name", "Tab next provider type", "Enter continue to credential", "Esc cancel"],
|
|
4639
|
+
};
|
|
4640
|
+
}
|
|
4641
|
+
|
|
4642
|
+
// ── Providers & Budgets ────────────────────────────────────────────────
|
|
4643
|
+
//
|
|
4644
|
+
// The view-model for the one table in
|
|
4645
|
+
// docs/proposals/providers-and-budgets-meters.md.
|
|
4646
|
+
//
|
|
4647
|
+
// `state.budget.grid` is what `getProviderUsageGrid` sent, in the order it
|
|
4648
|
+
// sent it: shared providers first, then the viewer's own, each immediately
|
|
4649
|
+
// followed by its model rows. Everything below turns that into rows already
|
|
4650
|
+
// carrying the numbers the CURRENT toggle prints, so the component does no
|
|
4651
|
+
// arithmetic and cannot print a number the selector did not mean.
|
|
4652
|
+
//
|
|
4653
|
+
// The vocabulary is binding: provider, limit, allowance, hold, paused,
|
|
4654
|
+
// period, your usage vs everyone's usage. Never pool, payer, grant, quota
|
|
4655
|
+
// rule, or bind mode.
|
|
4656
|
+
|
|
4657
|
+
const BUDGET_PERIOD_TITLE = { day: "Day", week: "Week", month: "Month" };
|
|
4658
|
+
|
|
4659
|
+
/** Is the person looking at this an administrator? */
|
|
4660
|
+
function viewerIsAdmin(state) {
|
|
4661
|
+
const role = state.auth?.authorization?.role;
|
|
4662
|
+
// "anonymous" is the single-user local deployment: no login, no one to be
|
|
4663
|
+
// less privileged than.
|
|
4664
|
+
return role === "admin" || role === "anonymous" || Boolean(state.admin?.profile?.isAdmin);
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
/** "00:00 UTC" — the clock every period in this model turns over on. */
|
|
4668
|
+
function formatUtcClock(iso) {
|
|
4669
|
+
const match = /T(\d{2}:\d{2})/.exec(String(iso || ""));
|
|
4670
|
+
return match ? `${match[1]} UTC` : null;
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
/**
|
|
4674
|
+
* "in 2h 10m (00:00 UTC)" — the ONE way this surface writes a reset.
|
|
4675
|
+
*
|
|
4676
|
+
* Both halves, every time: the relative half answers "how long do I wait",
|
|
4677
|
+
* the absolute half answers "when", and nobody should do UTC arithmetic at
|
|
4678
|
+
* 02:00 local to get from one to the other. Three spellings of one instant is
|
|
4679
|
+
* how the same reset came to read as three different facts.
|
|
4680
|
+
*/
|
|
4681
|
+
function formatUntilLabel(iso, nowMs = Date.now()) {
|
|
4682
|
+
const at = iso ? Date.parse(iso) : NaN;
|
|
4683
|
+
if (!Number.isFinite(at)) return null;
|
|
4684
|
+
const clock = formatUtcClock(iso);
|
|
4685
|
+
const suffix = clock ? ` (${clock})` : "";
|
|
4686
|
+
const ms = at - nowMs;
|
|
4687
|
+
if (ms <= 0) return `now${suffix}`;
|
|
4688
|
+
const minutes = Math.floor(ms / 60_000);
|
|
4689
|
+
if (minutes < 60) return `in ${Math.max(1, minutes)}m${suffix}`;
|
|
4690
|
+
const hours = Math.floor(minutes / 60);
|
|
4691
|
+
if (hours < 24) return `in ${hours}h ${String(minutes % 60).padStart(2, "0")}m${suffix}`;
|
|
4692
|
+
return `in ${Math.floor(hours / 24)}d ${String(hours % 24).padStart(2, "0")}h${suffix}`;
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4695
|
+
/**
|
|
4696
|
+
* When a pause ends, as one short phrase — or null.
|
|
4697
|
+
*
|
|
4698
|
+
* Null whenever the pause SENTENCE already answers it, which it does for
|
|
4699
|
+
* anything with a clock ("Resets in 2h 10m (00:00 UTC)."), for a hold ("until
|
|
4700
|
+
* an administrator releases it") and for a provider that is gone ("until that
|
|
4701
|
+
* name exists again"). Printing both put the same fact on the screen twice in
|
|
4702
|
+
* two different spellings, which is what made one reset read as two.
|
|
4703
|
+
*
|
|
4704
|
+
* A provider that no longer exists is also the ONE reason with no automatic
|
|
4705
|
+
* recovery, so it never gets a clock here under any circumstances.
|
|
4706
|
+
*/
|
|
4707
|
+
function budgetClearsLabel(pause) {
|
|
4708
|
+
if (pause?.kind === "no_provider" || pause?.kind === "hold") return null;
|
|
4709
|
+
if (pause?.resetsAtUtc) return null;
|
|
4710
|
+
return "waits until someone changes it";
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
/** The UTC day key ("2026-08-20") N days back from now. */
|
|
4714
|
+
function utcDayKey(nowMs, back) {
|
|
4715
|
+
return new Date(nowMs - back * 86_400_000).toISOString().slice(0, 10);
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
function budgetPlural(n, one, many) {
|
|
4719
|
+
return n === 1 ? one : many;
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4722
|
+
/**
|
|
4723
|
+
* One cell of the table: a used figure over a quota.
|
|
4724
|
+
*
|
|
4725
|
+
* The two pairs are never mixed. Unticked prints the viewer's own spend
|
|
4726
|
+
* against their share of the limit; ticked prints everyone's spend against
|
|
4727
|
+
* the limit itself. On a shared provider with an allowance the two are
|
|
4728
|
+
* usually very different, so which pair is on screen is a fact the table
|
|
4729
|
+
* carries rather than something the component guesses at.
|
|
4730
|
+
*
|
|
4731
|
+
* A null quota is no limit for that period — and the used figure beside it is
|
|
4732
|
+
* still real, because the meter runs whether or not anybody capped it.
|
|
4733
|
+
*
|
|
4734
|
+
* A null used figure means nobody is signed in. It stays unknown rather than
|
|
4735
|
+
* becoming a zero: "we do not know who you are" and "you spent nothing" are
|
|
4736
|
+
* different facts.
|
|
4737
|
+
*/
|
|
4738
|
+
function budgetCell(raw, overall, nowMs) {
|
|
4739
|
+
const rawQuota = overall ? raw?.quotaTokens : raw?.yourQuotaTokens;
|
|
4740
|
+
const rawUsed = overall ? raw?.usedTokens : raw?.yourUsedTokens;
|
|
4741
|
+
const quotaTokens = rawQuota == null ? null : Number(rawQuota);
|
|
4742
|
+
const usedTokens = rawUsed == null ? null : Number(rawUsed);
|
|
4743
|
+
const known = Number.isFinite(usedTokens);
|
|
4744
|
+
const capped = Number.isFinite(quotaTokens) && quotaTokens > 0;
|
|
4745
|
+
const pct = known && capped ? Math.round((usedTokens / quotaTokens) * 100) : null;
|
|
4746
|
+
const usedLabel = known ? formatCompactNumber(usedTokens) : "—";
|
|
4747
|
+
const quotaLabel = Number.isFinite(quotaTokens) ? formatCompactNumber(quotaTokens) : "∞";
|
|
4748
|
+
// Whether the PROVIDER's own limit is spent, whichever pair is on screen.
|
|
4749
|
+
// With an allowance this is the case that stops you while your share
|
|
4750
|
+
// still shows room: a limit caps everyone TOGETHER, and a cell showing
|
|
4751
|
+
// 13% of your slice was drawn plain on a provider that had nothing left.
|
|
4752
|
+
const totalQuota = Number(raw?.quotaTokens);
|
|
4753
|
+
const totalUsed = Number(raw?.usedTokens);
|
|
4754
|
+
const providerExhausted = Number.isFinite(totalQuota) && totalQuota > 0
|
|
4755
|
+
&& Number.isFinite(totalUsed) && totalUsed >= totalQuota;
|
|
4756
|
+
// Only worth marking when the cell's OWN pair does not already say it.
|
|
4757
|
+
// At 100/100 the two numbers are the story; the case this exists for is a
|
|
4758
|
+
// cell reading 13% of your share on a provider that has nothing left.
|
|
4759
|
+
const blockedButLooksFine = !overall && providerExhausted && (pct == null || pct < 100);
|
|
4760
|
+
return {
|
|
4761
|
+
providerExhausted: blockedButLooksFine,
|
|
4762
|
+
ruleId: raw?.ruleId || null,
|
|
4763
|
+
usedTokens: known ? usedTokens : null,
|
|
4764
|
+
quotaTokens: Number.isFinite(quotaTokens) ? quotaTokens : null,
|
|
4765
|
+
usedLabel,
|
|
4766
|
+
quotaLabel,
|
|
4767
|
+
// The whole cell, already written: "480.0K / 500.0K", "1.2M / ∞".
|
|
4768
|
+
text: `${usedLabel} / ${quotaLabel}`,
|
|
4769
|
+
// False = nobody is signed in, so there is no "your" number to print.
|
|
4770
|
+
known,
|
|
4771
|
+
// True = this period is uncapped. The used figure is still real.
|
|
4772
|
+
uncapped: !Number.isFinite(quotaTokens),
|
|
4773
|
+
// The real percentage, which CAN exceed 100: the turn that crosses a
|
|
4774
|
+
// limit completes and is charged, and only the next one pauses.
|
|
4775
|
+
pct,
|
|
4776
|
+
// What a meter may draw without overflowing its track.
|
|
4777
|
+
meterPct: pct == null ? null : Math.max(0, Math.min(100, pct)),
|
|
4778
|
+
// Plain under 90%, amber to 100%, red over. Never the only signal —
|
|
4779
|
+
// the two numbers beside it already say it.
|
|
4780
|
+
// Plain under 90%, amber to 100%, red over — and red as well when the
|
|
4781
|
+
// PROVIDER's limit is spent while your own share still reads low,
|
|
4782
|
+
// because nothing runs then either.
|
|
4783
|
+
tone: pct == null ? (blockedButLooksFine ? "red" : "idle")
|
|
4784
|
+
: pct > 100 ? "red"
|
|
4785
|
+
: blockedButLooksFine ? "red"
|
|
4786
|
+
: pct >= 90 ? "amber" : "plain",
|
|
4787
|
+
windowStartUtc: raw?.windowStartUtc || null,
|
|
4788
|
+
resetsAtUtc: raw?.resetsAtUtc || null,
|
|
4789
|
+
resetsLabel: formatUntilLabel(raw?.resetsAtUtc, nowMs),
|
|
4790
|
+
};
|
|
4791
|
+
}
|
|
4792
|
+
|
|
4793
|
+
/**
|
|
4794
|
+
* One row of the table — a provider, or one model-scoped limit under it.
|
|
4795
|
+
*
|
|
4796
|
+
* A model row is read exactly like the provider row above it, in the same
|
|
4797
|
+
* three columns, because a model limit can bite while the provider still has
|
|
4798
|
+
* room and one grid is what makes that visible.
|
|
4799
|
+
*/
|
|
4800
|
+
function budgetTableRow(raw, { overall, selectedProvider, selectedScope = "*", nowMs, overModels = 0 }) {
|
|
4801
|
+
const providerName = String(raw?.providerName || "");
|
|
4802
|
+
const kind = raw?.rowKind === "model" ? "model" : "provider";
|
|
4803
|
+
const scope = String(raw?.scope || "*");
|
|
4804
|
+
const shared = raw?.class === "shared";
|
|
4805
|
+
const allowancePct = Number.isFinite(Number(raw?.allowancePct)) ? Number(raw.allowancePct) : 100;
|
|
4806
|
+
const holdUntilUtc = raw?.holdUntilUtc || null;
|
|
4807
|
+
const holdIndefinite = raw?.holdIndefinite === true;
|
|
4808
|
+
const held = holdIndefinite || (holdUntilUtc ? Date.parse(holdUntilUtc) > nowMs : false);
|
|
4809
|
+
const modelRowCount = kind === "provider" ? Number(raw?.modelRowCount) || 0 : 0;
|
|
4810
|
+
// Whose row this is, and whether you may change it. Both come from the
|
|
4811
|
+
// database, which is the thing that will actually decide — a client that
|
|
4812
|
+
// guessed armed Edit and Remove on providers the server then refused.
|
|
4813
|
+
const ownedByMe = raw?.ownedByMe !== false;
|
|
4814
|
+
const manageable = raw?.manageable === true;
|
|
4815
|
+
// Whose it is. On an administrator's screen a bare "USER" chip told two
|
|
4816
|
+
// people's providers apart only by whatever they happened to name them.
|
|
4817
|
+
const ownerLabel = typeof raw?.ownerLabel === "string" && raw.ownerLabel ? raw.ownerLabel : null;
|
|
4818
|
+
// A model row is selectable in its own right — its limit is a different
|
|
4819
|
+
// number from the provider's, so it gets its own chart.
|
|
4820
|
+
const selected = providerName === selectedProvider
|
|
4821
|
+
&& (kind === "provider" ? selectedScope === "*" : scope === selectedScope);
|
|
4822
|
+
const periods = raw?.periods || {};
|
|
4823
|
+
return {
|
|
4824
|
+
// Unique across the table: one provider row plus one row per model
|
|
4825
|
+
// scope under it.
|
|
4826
|
+
key: `${providerName}${scope}`,
|
|
4827
|
+
providerName,
|
|
4828
|
+
kind,
|
|
4829
|
+
scope,
|
|
4830
|
+
// A provider row prints the provider's name; a model row prints just
|
|
4831
|
+
// the model, because the provider is the row directly above it and
|
|
4832
|
+
// "azure-research:gpt-5.4" under "azure-research" says it twice. The
|
|
4833
|
+
// full scope stays on `scope` for anything that needs to name it.
|
|
4834
|
+
label: kind === "provider"
|
|
4835
|
+
? providerName
|
|
4836
|
+
: (scope.startsWith(`${providerName}:`) ? scope.slice(providerName.length + 1) : scope),
|
|
4837
|
+
// What this row is ABOUT, written out. A model row's own name is only
|
|
4838
|
+
// unambiguous while the provider is directly above it, so anything
|
|
4839
|
+
// that names the row away from the table — the chart heading, a
|
|
4840
|
+
// tooltip — uses this instead.
|
|
4841
|
+
subject: kind === "provider"
|
|
4842
|
+
? (ownerLabel ? `${providerName} (${ownerLabel})` : providerName)
|
|
4843
|
+
: `${providerName} · ${(scope.startsWith(`${providerName}:`)
|
|
4844
|
+
? scope.slice(providerName.length + 1) : scope)}`,
|
|
4845
|
+
class: shared ? "shared" : "personal",
|
|
4846
|
+
ownedByMe,
|
|
4847
|
+
manageable,
|
|
4848
|
+
ownerLabel: kind === "provider" ? ownerLabel : null,
|
|
4849
|
+
// The provider's CLASS, named the way the model names it. Both are
|
|
4850
|
+
// marked: an unmarked row is a row whose kind the reader has to
|
|
4851
|
+
// infer, and the two behave differently enough that guessing is
|
|
4852
|
+
// worse than a second chip.
|
|
4853
|
+
classLabel: kind !== "provider" ? null : (shared ? "Shared" : "User"),
|
|
4854
|
+
allowancePct,
|
|
4855
|
+
// A fact about how the TOTAL is divided, so it belongs beside the
|
|
4856
|
+
// total and nowhere else. 100 means there is no per-person ceiling.
|
|
4857
|
+
// A share of nothing is nothing: printed beside three ∞ cells the
|
|
4858
|
+
// label implied a cap that does not exist.
|
|
4859
|
+
allowanceLabel: kind === "provider" && overall && allowancePct < 100 && ownedByMe
|
|
4860
|
+
? (["day", "week", "month"].some((p) => raw?.periods?.[p]?.quotaTokens != null)
|
|
4861
|
+
? `Per-user allowance: ${allowancePct}% of each limit`
|
|
4862
|
+
: `Per-user allowance: ${allowancePct}%, but no limit is set to divide`)
|
|
4863
|
+
: null,
|
|
4864
|
+
hold: held
|
|
4865
|
+
? {
|
|
4866
|
+
indefinite: holdIndefinite || !holdUntilUtc,
|
|
4867
|
+
untilUtc: holdUntilUtc,
|
|
4868
|
+
// Reads as the end of "It …", and carries the same reset
|
|
4869
|
+
// spelling as every other clock on this surface.
|
|
4870
|
+
label: holdIndefinite || !holdUntilUtc
|
|
4871
|
+
? "lifts when an administrator releases it"
|
|
4872
|
+
: `lifts ${formatUntilLabel(holdUntilUtc, nowMs)}`,
|
|
4873
|
+
}
|
|
4874
|
+
: null,
|
|
4875
|
+
modelRowCount,
|
|
4876
|
+
selected,
|
|
4877
|
+
// Expansion follows the PROVIDER, not the exact row: standing on one
|
|
4878
|
+
// of its model rows must not make the teaser offer to expand rows
|
|
4879
|
+
// that are already on screen.
|
|
4880
|
+
expanded: providerName === selectedProvider && modelRowCount > 0,
|
|
4881
|
+
// The affordance, already worded. Its model rows are already in the
|
|
4882
|
+
// array — expanding shows rows the table has, it fetches nothing.
|
|
4883
|
+
expandLabel: modelRowCount === 0 ? null
|
|
4884
|
+
: providerName === selectedProvider
|
|
4885
|
+
? `hide ${modelRowCount} ${budgetPlural(modelRowCount, "model", "models")}`
|
|
4886
|
+
: overModels > 0
|
|
4887
|
+
? `show ${modelRowCount} ${budgetPlural(modelRowCount, "model", "models")} — ${overModels} over limit`
|
|
4888
|
+
: `show ${modelRowCount} ${budgetPlural(modelRowCount, "model", "models")}`,
|
|
4889
|
+
overModels: kind === "provider" ? overModels : 0,
|
|
4890
|
+
cells: {
|
|
4891
|
+
day: budgetCell(periods.day, overall, nowMs),
|
|
4892
|
+
week: budgetCell(periods.week, overall, nowMs),
|
|
4893
|
+
month: budgetCell(periods.month, overall, nowMs),
|
|
4894
|
+
},
|
|
4895
|
+
};
|
|
4896
|
+
}
|
|
4897
|
+
|
|
4898
|
+
/**
|
|
4899
|
+
* The provider table: every row it draws, already carrying the six numbers
|
|
4900
|
+
* (used and quota, for day, week and month) that the current toggle prints.
|
|
4901
|
+
*
|
|
4902
|
+
* Rows are NOT sorted here. The server returns them in render order and a
|
|
4903
|
+
* sort would move a model row out from under the provider it belongs to.
|
|
4904
|
+
*/
|
|
4905
|
+
export function selectProviderTable(state) {
|
|
4906
|
+
const budget = state.budget || {};
|
|
4907
|
+
const nowMs = Date.now();
|
|
4908
|
+
const isAdmin = viewerIsAdmin(state);
|
|
4909
|
+
const overall = budget.overall === true;
|
|
4910
|
+
const rangeDays = BUDGET_SERIES_RANGES.includes(Number(budget.rangeDays))
|
|
4911
|
+
? Number(budget.rangeDays)
|
|
4912
|
+
: BUDGET_SERIES_DAYS;
|
|
4913
|
+
const grid = Array.isArray(budget.grid) ? budget.grid : [];
|
|
4914
|
+
const error = budget.error || null;
|
|
4915
|
+
const loaded = budget.loaded === true;
|
|
4916
|
+
const selectedProvider = budget.selectedProvider || null;
|
|
4917
|
+
// Which row under it: '*' is the provider itself, a qualified model
|
|
4918
|
+
// reference is one of its per-model limits.
|
|
4919
|
+
const selectedScope = budget.selectedScope || "*";
|
|
4920
|
+
|
|
4921
|
+
const providerCount = grid.reduce((n, raw) => n + (raw?.rowKind === "model" ? 0 : 1), 0);
|
|
4922
|
+
|
|
4923
|
+
// Provider rows always; a provider's model rows only while it is the
|
|
4924
|
+
// selected one. Selecting is what expands it.
|
|
4925
|
+
const rows = [];
|
|
4926
|
+
// How many of a provider's model limits are over, whether or not its rows
|
|
4927
|
+
// are on screen. A collapsed teaser that said only "2 per-model limits"
|
|
4928
|
+
// hid the one fact worth expanding for.
|
|
4929
|
+
const overModelsByProvider = new Map();
|
|
4930
|
+
for (const raw of grid) {
|
|
4931
|
+
if (raw?.rowKind !== "model") continue;
|
|
4932
|
+
const over = ["day", "week", "month"].some((period) => {
|
|
4933
|
+
const cell = raw?.periods?.[period];
|
|
4934
|
+
const quota = Number(cell?.quotaTokens);
|
|
4935
|
+
const used = Number(overall ? cell?.usedTokens : cell?.yourUsedTokens);
|
|
4936
|
+
return Number.isFinite(quota) && quota > 0 && Number.isFinite(used) && used > quota;
|
|
4937
|
+
});
|
|
4938
|
+
if (over) {
|
|
4939
|
+
overModelsByProvider.set(raw.providerName, (overModelsByProvider.get(raw.providerName) || 0) + 1);
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
for (const raw of grid) {
|
|
4943
|
+
if (raw?.rowKind === "model" && raw?.providerName !== selectedProvider) continue;
|
|
4944
|
+
rows.push(budgetTableRow(raw, {
|
|
4945
|
+
overall, selectedProvider, selectedScope, nowMs,
|
|
4946
|
+
overModels: overModelsByProvider.get(raw?.providerName) || 0,
|
|
4947
|
+
}));
|
|
4948
|
+
}
|
|
4949
|
+
// The row the actions and the chart are about. A model row can be it: its
|
|
4950
|
+
// limit is a different number from the provider's and deserves its own
|
|
4951
|
+
// days. The PROVIDER row stays findable for the things that are still
|
|
4952
|
+
// about the provider — Edit, Remove, and what the buttons name.
|
|
4953
|
+
const selected = rows.find((row) => row.selected)
|
|
4954
|
+
|| rows.find((row) => row.kind === "provider" && row.providerName === selectedProvider)
|
|
4955
|
+
|| null;
|
|
4956
|
+
const selectedProviderRow = rows.find(
|
|
4957
|
+
(row) => row.kind === "provider" && row.providerName === selectedProvider,
|
|
4958
|
+
) || null;
|
|
4959
|
+
|
|
4960
|
+
// ── The one line above the table ───────────────────────────────────
|
|
4961
|
+
//
|
|
4962
|
+
// Not the old band. An administrator's fleet-wide "is anything stopped
|
|
4963
|
+
// right now" is the only thing the band was uniquely good at, and one
|
|
4964
|
+
// sentence with a link keeps it. WHY a particular session is stopped is
|
|
4965
|
+
// said on the session itself, which is where the remedy is.
|
|
4966
|
+
const pausedRaw = Array.isArray(budget.paused) ? budget.paused : [];
|
|
4967
|
+
const pausedByProvider = new Map();
|
|
4968
|
+
for (const row of pausedRaw) {
|
|
4969
|
+
const pause = normalizeSessionPause({ pauseState: row?.pause });
|
|
4970
|
+
const name = pause?.provider || null;
|
|
4971
|
+
if (!name) continue;
|
|
4972
|
+
pausedByProvider.set(name, (pausedByProvider.get(name) || 0) + 1);
|
|
4973
|
+
}
|
|
4974
|
+
const topPaused = [...pausedByProvider.entries()]
|
|
4975
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] || null;
|
|
4976
|
+
const pausedCount = pausedRaw.length;
|
|
4977
|
+
// Named only when that one provider accounts for every waiting session.
|
|
4978
|
+
// Naming one of two causes sends the reader to the wrong row.
|
|
4979
|
+
const pausedName = topPaused && topPaused[1] === pausedCount ? topPaused[0] : null;
|
|
4980
|
+
const pausedError = budget.pausedError || null;
|
|
4981
|
+
const paused = {
|
|
4982
|
+
count: pausedCount,
|
|
4983
|
+
// The waiting list is the half that failed. Said on the line itself,
|
|
4984
|
+
// because the numbers above it were read fine and marking THEM stale
|
|
4985
|
+
// is the opposite of the truth.
|
|
4986
|
+
error: pausedError,
|
|
4987
|
+
stale: Boolean(pausedError) && pausedCount > 0,
|
|
4988
|
+
// Where the link goes: the provider stopping the most sessions.
|
|
4989
|
+
provider: topPaused ? topPaused[0] : null,
|
|
4990
|
+
sentence: pausedCount === 0
|
|
4991
|
+
? null
|
|
4992
|
+
: `${pausedCount} ${budgetPlural(pausedCount, "session is", "sessions are")} waiting`
|
|
4993
|
+
+ `${pausedName ? ` on ${pausedName}` : ""}.`,
|
|
4994
|
+
};
|
|
4995
|
+
|
|
4996
|
+
// ── A name that is waited on and does not exist ────────────────────
|
|
4997
|
+
//
|
|
4998
|
+
// Only when sessions are actually stopped on it. A provider deleted with
|
|
4999
|
+
// nothing riding on it just leaves the table, which is correct and needs
|
|
5000
|
+
// no announcement; a name that sessions are stuck on has one remedy —
|
|
5001
|
+
// create it again — and this is the only screen that offers it.
|
|
5002
|
+
const missingName = budget.missingProvider || null;
|
|
5003
|
+
const missingWaiting = missingName ? (pausedByProvider.get(missingName) || 0) : 0;
|
|
5004
|
+
const missing = missingWaiting > 0
|
|
5005
|
+
? {
|
|
5006
|
+
provider: missingName,
|
|
5007
|
+
count: missingWaiting,
|
|
5008
|
+
sentence: `No provider is named ${missingName}, and `
|
|
5009
|
+
+ `${missingWaiting} ${budgetPlural(missingWaiting, "session waits", "sessions wait")} on it. `
|
|
5010
|
+
+ "They run again as soon as a provider takes that name.",
|
|
5011
|
+
}
|
|
5012
|
+
: null;
|
|
5013
|
+
|
|
5014
|
+
// ── The chart under the selected provider ──────────────────────────
|
|
5015
|
+
//
|
|
5016
|
+
// The range is drawn in full, one bar per day, filled from the report. A
|
|
5017
|
+
// day the report omits is a day with no usage — not a missing axis.
|
|
5018
|
+
const seriesState = budget.series || {};
|
|
5019
|
+
const seriesDaily = new Map(
|
|
5020
|
+
(Array.isArray(seriesState.days) ? seriesState.days : [])
|
|
5021
|
+
.map((row) => [String(row?.dayUtc || "").slice(0, 10), row]),
|
|
5022
|
+
);
|
|
5023
|
+
const chartDays = [];
|
|
5024
|
+
for (let back = rangeDays - 1; back >= 0; back -= 1) {
|
|
5025
|
+
const dayUtc = utcDayKey(nowMs, back);
|
|
5026
|
+
const row = seriesDaily.get(dayUtc);
|
|
5027
|
+
chartDays.push({
|
|
5028
|
+
dayUtc,
|
|
5029
|
+
label: dayUtc.slice(5),
|
|
5030
|
+
tokens: Number(row?.tokensTotal) || 0,
|
|
5031
|
+
turns: Number(row?.turns) || 0,
|
|
5032
|
+
});
|
|
5033
|
+
}
|
|
5034
|
+
const peak = chartDays.reduce((max, day) => Math.max(max, day.tokens), 0);
|
|
5035
|
+
// The dashed line is the very number the Day cell just printed, so the
|
|
5036
|
+
// chart and the row it belongs to cannot disagree.
|
|
5037
|
+
const dayCell = selected?.cells?.day || null;
|
|
5038
|
+
const quotaTokens = dayCell?.quotaTokens ?? null;
|
|
5039
|
+
// The axis has to hold both the tallest bar and the line, or a provider
|
|
5040
|
+
// well under its limit draws a line off the top of the plot.
|
|
5041
|
+
const scaleMax = Math.max(peak, quotaTokens || 0);
|
|
5042
|
+
for (const day of chartDays) {
|
|
5043
|
+
day.pct = scaleMax > 0 ? Math.round((day.tokens / scaleMax) * 100) : 0;
|
|
5044
|
+
}
|
|
5045
|
+
const seriesError = seriesState.error || null;
|
|
5046
|
+
const seriesLoaded = seriesState.loaded === true;
|
|
5047
|
+
const series = {
|
|
5048
|
+
provider: seriesState.provider || null,
|
|
5049
|
+
days: chartDays,
|
|
5050
|
+
peak,
|
|
5051
|
+
peakLabel: formatCompactNumber(peak),
|
|
5052
|
+
scaleMax,
|
|
5053
|
+
quotaTokens,
|
|
5054
|
+
quotaLabel: dayCell?.quotaLabel ?? "∞",
|
|
5055
|
+
// Where to draw the line, as a percentage of the plot height. Null
|
|
5056
|
+
// when the day is uncapped: there is no line to draw.
|
|
5057
|
+
quotaPct: quotaTokens != null && scaleMax > 0
|
|
5058
|
+
? Math.round((quotaTokens / scaleMax) * 100)
|
|
5059
|
+
: null,
|
|
5060
|
+
rangeDays,
|
|
5061
|
+
rangeLabel: `Last ${rangeDays} days`,
|
|
5062
|
+
loading: seriesState.loading === true,
|
|
5063
|
+
loaded: seriesLoaded,
|
|
5064
|
+
error: seriesError,
|
|
5065
|
+
// The read failed and there is nothing behind it. Distinct from a
|
|
5066
|
+
// range that really had no usage in it.
|
|
5067
|
+
failed: Boolean(seriesError) && !seriesLoaded,
|
|
5068
|
+
stale: Boolean(seriesError) && seriesLoaded,
|
|
5069
|
+
// Nothing to draw. The view says so instead of drawing an empty axis.
|
|
5070
|
+
empty: seriesLoaded && !seriesError && peak === 0,
|
|
5071
|
+
// The bars and the dashed line are now the SAME scope in both
|
|
5072
|
+
// states — the request carries `mine` when the tick is off — so there
|
|
5073
|
+
// is nothing left to warn about. Kept as a field because the view
|
|
5074
|
+
// reads it; it is false because the mismatch it named is fixed.
|
|
5075
|
+
mixedScope: false,
|
|
5076
|
+
};
|
|
5077
|
+
|
|
5078
|
+
const systemState = budget.systemUsage || {};
|
|
5079
|
+
const systemMatches = isAdmin && selected
|
|
5080
|
+
&& systemState.provider === selectedProvider
|
|
5081
|
+
&& (systemState.scope || "*") === selectedScope
|
|
5082
|
+
&& (systemState.rangeDays || BUDGET_SERIES_DAYS) === rangeDays;
|
|
5083
|
+
const systemSpend = !isAdmin || !selected ? null : {
|
|
5084
|
+
loading: systemMatches && systemState.loading === true,
|
|
5085
|
+
loaded: systemMatches && systemState.loaded === true,
|
|
5086
|
+
error: systemMatches ? (systemState.error || null) : null,
|
|
5087
|
+
stale: systemMatches && Boolean(systemState.error) && systemState.loaded === true,
|
|
5088
|
+
rangeDays,
|
|
5089
|
+
rangeLabel: `Last ${rangeDays} days`,
|
|
5090
|
+
tokens: Number(systemMatches ? systemState.totals?.tokensTotal : 0) || 0,
|
|
5091
|
+
tokensLabel: formatCompactNumber(Number(systemMatches ? systemState.totals?.tokensTotal : 0) || 0),
|
|
5092
|
+
turns: Number(systemMatches ? systemState.totals?.turns : 0) || 0,
|
|
5093
|
+
models: (systemMatches && Array.isArray(systemState.breakdown) ? systemState.breakdown : [])
|
|
5094
|
+
.map((row) => ({
|
|
5095
|
+
key: String(row?.key || row?.label || "unknown"),
|
|
5096
|
+
label: String(row?.label || row?.key || "Unknown model"),
|
|
5097
|
+
tokens: Number(row?.tokensTotal) || 0,
|
|
5098
|
+
tokensLabel: formatCompactNumber(Number(row?.tokensTotal) || 0),
|
|
5099
|
+
turns: Number(row?.turns) || 0,
|
|
5100
|
+
}))
|
|
5101
|
+
.sort((left, right) => right.tokens - left.tokens || left.label.localeCompare(right.label)),
|
|
5102
|
+
};
|
|
5103
|
+
|
|
5104
|
+
return {
|
|
5105
|
+
open: Boolean(state.ui?.budgetOpen),
|
|
5106
|
+
isAdmin,
|
|
5107
|
+
loading: Boolean(budget.loading),
|
|
5108
|
+
refreshing: Boolean(budget.refreshing),
|
|
5109
|
+
loaded,
|
|
5110
|
+
error,
|
|
5111
|
+
fetchedAt: Number(budget.fetchedAt) || 0,
|
|
5112
|
+
// The read failed and there is nothing trustworthy behind it. The two
|
|
5113
|
+
// must never look alike: "no limits" and "could not load the limits"
|
|
5114
|
+
// are different facts and only one of them is safe to act on.
|
|
5115
|
+
failed: Boolean(error) && !loaded,
|
|
5116
|
+
// The read failed but earlier numbers are still on screen. Say they
|
|
5117
|
+
// are old rather than passing them off as current.
|
|
5118
|
+
stale: Boolean(error) && loaded,
|
|
5119
|
+
// A namespace that really is empty, which is a state with its own
|
|
5120
|
+
// remedy (create a provider) — and only ever reported after a
|
|
5121
|
+
// SUCCESSFUL read.
|
|
5122
|
+
empty: loaded && !error && providerCount === 0,
|
|
5123
|
+
// Which pair of numbers the cells are printing.
|
|
5124
|
+
overall,
|
|
5125
|
+
rangeDays,
|
|
5126
|
+
rangeOptions: BUDGET_SERIES_RANGES.map((days) => ({ value: days, label: `${days}d` })),
|
|
5127
|
+
overallLabel: "Show all user spend",
|
|
5128
|
+
usageHeading: overall ? "All user spend" : "My spend",
|
|
5129
|
+
columns: BUDGET_PERIODS.map((period) => ({ id: period, label: BUDGET_PERIOD_TITLE[period] })),
|
|
5130
|
+
rows,
|
|
5131
|
+
providerCount,
|
|
5132
|
+
selectedProvider,
|
|
5133
|
+
selectedScope,
|
|
5134
|
+
selected,
|
|
5135
|
+
// Edit and Remove always act on the provider, even while a model row
|
|
5136
|
+
// under it is the one being read.
|
|
5137
|
+
selectedProviderRow,
|
|
5138
|
+
paused,
|
|
5139
|
+
missing,
|
|
5140
|
+
series,
|
|
5141
|
+
systemSpend,
|
|
5142
|
+
};
|
|
5143
|
+
}
|
|
5144
|
+
|
|
4260
5145
|
export function selectStatusBar(state) {
|
|
4261
5146
|
const focus = state.ui.focusRegion;
|
|
4262
5147
|
const paneFullscreen = state.ui.fullscreenPane || null;
|
|
@@ -5514,7 +6399,7 @@ export function selectModelPickerModal(state, maxWidth = 72) {
|
|
|
5514
6399
|
: []),
|
|
5515
6400
|
...(defaultReasoning ? [[{ text: `Default reasoning: ${defaultReasoning}`, color: "gray" }]] : []),
|
|
5516
6401
|
...(selectedItem.disabled
|
|
5517
|
-
? [[{ text: "Unavailable:
|
|
6402
|
+
? [[{ text: "Unavailable: add a personal provider in Admin Console, then reopen this picker.", color: "yellow" }]]
|
|
5518
6403
|
: []),
|
|
5519
6404
|
[{ text: "", color: "gray" }],
|
|
5520
6405
|
[{
|