pilotswarm 0.5.41 → 0.5.43
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/history.js +25 -1
- package/ui/core/src/reducer.js +649 -1
- package/ui/core/src/selectors.js +906 -6
- package/ui/core/src/state.js +163 -3
- package/ui/react/src/components.js +146 -4
- package/ui/react/src/web-app.js +1942 -125
- package/web/README.md +4 -4
- package/web/api/router.js +33 -0
- package/web/dist/assets/{abnfDiagram-VRR7QNED-kpxu_PEa.js → abnfDiagram-VRR7QNED-BqKIaTz5.js} +1 -1
- package/web/dist/assets/{arc-BnVQmtB5.js → arc-DCSwKNAt.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CDynQ_6_.js → architectureDiagram-ZJ3FMSHR-B6j7Bv5B.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-BUpYaSDb.js → blockDiagram-677ZJIJ3-BT1iegLO.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-ZL8eskDB.js → c4Diagram-LMCZKHZV-DXEt-_ny.js} +1 -1
- package/web/dist/assets/channel-BLVoqfUa.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-o0Nsyp1T.js → chunk-2Q5K7J3B-B144-8I2.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-CH5CSNRP.js → chunk-32BRIVSS-CMHrtYOC.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-C9wwtPWL.js → chunk-5VM5RSS4-4r8OmbGE.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG--F9yFbhA.js → chunk-EX3LRPZG-R_j3wosN.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-BPIstyWL.js → chunk-JWPE2WC7-BKlFEYXC.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-5Dm2u-0k.js → chunk-MOJQB5TN-C2S4TPSa.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-Dbtf9iQK.js → chunk-RYQCIY6F-Y2kpzPD7.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-DrZEBJ5l.js → chunk-V7JOEXUC-kAOP_3Kc.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-AqAUynqK.js → chunk-VR4S4FIN-B2Au1EfR.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-DQlFaqCy.js → chunk-XXDRQBXY-CZfTG6DD.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-BgaYclQI.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-BgaYclQI.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-RAtPG1Ou.js → cose-bilkent-JH36ORCC--tNLpMEb.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CbeHaUWZ.js → cynefin-VYW2F7L2-BeIRFyvX.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-B6paqF5Y.js → cynefinDiagram-TSTJHNR4-Bm5lb_xz.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-DziTBO2K.js → dagre-VKFMJZFB-Cz1i_B0y.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CPRUiLcr.js → diagram-FQU43EPY-BF0j7c1T.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-CIMVdcL1.js → diagram-G47NLZAW-C2QWJ-Y4.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-CHPFllxR.js → diagram-NH7WQ7WH-BiNeThAA.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DwS6unBJ.js → diagram-OA4YK3LP-Cq7_37rx.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-DKWgkObI.js → diagram-WEI45ONY-Cl0GjIma.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-zAVWV6-D.js → ebnfDiagram-CCIWWBDH-C-dTpVrf.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-D0tYX4h0.js → erDiagram-Q63AITRT-RPSg6Lws.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-Cfq4I7R0.js → flowDiagram-23GEKE2U-CslnZm0D.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-wgBAHr5I.js → ganttDiagram-NO4QXBWP-dzr6nimS.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BTIPX5PR.js → gitGraphDiagram-IHSO6WYX-UpjDHGP0.js} +1 -1
- package/web/dist/assets/index-C-ujPKpy.css +1 -0
- package/web/dist/assets/index-Cf_e477H.js +24 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-CRLiQ87D.js → infoDiagram-FWYZ7A6U-R4qLdwe7.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CwzwUis1.js → ishikawaDiagram-FXEZZL3T-Cst7PjW4.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-B9pqMeFw.js → journeyDiagram-5HDEW3XC-CugDTBMp.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-CZ5Xky4p.js → kanban-definition-HUTT4EX6-D6Yf2Lgg.js} +1 -1
- package/web/dist/assets/{linear-BLZ2eH4a.js → linear-hUntimCu.js} +1 -1
- package/web/dist/assets/{mermaid.core-CPN1IVjz.js → mermaid.core-BhKlcGBI.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-MdC8d2tc.js → mindmap-definition-LN4V7U3C-DKVBRUj4.js} +1 -1
- package/web/dist/assets/{papaparse.min-CtrVJupf.js → papaparse.min-DbTWBfDP.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-B2DqAc-A.js → pegDiagram-2B236MQR-DeilQyRr.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-C3QAoXZh.js → pieDiagram-ENE6RG2P-CSVE3nUJ.js} +1 -1
- package/web/dist/assets/pilotswarm-DaQpAPI0.js +97 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-BoUr5qFH.js → quadrantDiagram-ABIIQ3AL-Di-WUa_j.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-bZzE4KFN.js → railroadDiagram-RFXS5EU6-jBlJSP-m.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-B2ENAqLS.js → requirementDiagram-TGXJPOKE-BlLuScOr.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-AJZ3h46Z.js → sankeyDiagram-HTMAVEWB-D76NurlB.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-QUf0VVE7.js → sequenceDiagram-DBY2YBRQ-BGPIUJo0.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-Cv9RcUy6.js → sizeCapture-X5ZJPWSS-DKmGUrSI.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-hDiye45t.js → stateDiagram-2N3HPSRC-DAYHO_nW.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-jZoHf_Ku.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-hSk_OF85.js → swimlanes-5IMT3BWC-CiRowk4M.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-C0ms7UKB.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-ccOZEJ5e.js → timeline-definition-FHXFAJF6-iN7kQL24.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-Bk5DLH_V.js → vennDiagram-L72KCM5P-C6TtJ36b.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-C20AX8ot.js → wardleyDiagram-EHGQE667-CBiv7EU-.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-DaHqmi-R.js → xychartDiagram-FW5EYKEG-zXUejHih.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-Bi5A6sHR.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-D8dCxI54.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-D8dCxI54.js +0 -1
- package/web/dist/assets/index-OHj-gGHp.js +0 -24
- package/web/dist/assets/index-eTH1FBtN.css +0 -1
- package/web/dist/assets/pilotswarm-CF1nmpMy.js +0 -97
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Bu4ufvaU.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTFePb1Y.js +0 -8
package/ui/react/src/web-app.js
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
selectLogFilterModal,
|
|
47
47
|
selectModelPickerModal,
|
|
48
48
|
selectNavigationError,
|
|
49
|
+
selectProviderTable,
|
|
49
50
|
selectReasoningEffortPickerModal,
|
|
50
51
|
selectContextTierPickerModal,
|
|
51
52
|
selectRenameSessionModal,
|
|
@@ -193,6 +194,53 @@ function copySessionLinkText(url) {
|
|
|
193
194
|
if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(text).catch(() => {});
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/**
|
|
198
|
+
* One box per entry point, each independently copyable, plus a "Copy all"
|
|
199
|
+
* that takes every labeled line at once.
|
|
200
|
+
*
|
|
201
|
+
* The single shared textarea this replaced forced the sender to hand over
|
|
202
|
+
* both URLs even when they knew which network the recipient was on, and a
|
|
203
|
+
* partial selection out of a monospace blob is fiddly. A sender who knows
|
|
204
|
+
* copies one; a sender who doesn't copies all.
|
|
205
|
+
*
|
|
206
|
+
* Single-origin deployments render exactly one unlabeled row — no labels, no
|
|
207
|
+
* Copy all, nothing to explain.
|
|
208
|
+
*/
|
|
209
|
+
function MultiOriginLinkRows({ url, keyPrefix, copied, onCopy }) {
|
|
210
|
+
const links = buildPortalLinks(url, portalLinkOrigins);
|
|
211
|
+
const selectOnFocus = (event) => event.currentTarget.select();
|
|
212
|
+
if (links.length <= 1) {
|
|
213
|
+
return React.createElement("div", { className: "ps-link-row" },
|
|
214
|
+
React.createElement("input", {
|
|
215
|
+
className: "ps-link-input", readOnly: true, value: url, onFocus: selectOnFocus,
|
|
216
|
+
}),
|
|
217
|
+
React.createElement("button", {
|
|
218
|
+
type: "button", className: "ps-mini-button", onClick: () => onCopy(keyPrefix, url),
|
|
219
|
+
}, copied === keyPrefix ? "Copied" : "Copy"));
|
|
220
|
+
}
|
|
221
|
+
const allKey = `${keyPrefix}:all`;
|
|
222
|
+
return React.createElement("div", { className: "ps-link-origins" },
|
|
223
|
+
links.map((link, index) => {
|
|
224
|
+
const key = `${keyPrefix}:${index}`;
|
|
225
|
+
return React.createElement("div", { className: "ps-link-origin", key },
|
|
226
|
+
React.createElement("div", { className: "ps-link-origin-label" }, link.label),
|
|
227
|
+
React.createElement("div", { className: "ps-link-row" },
|
|
228
|
+
React.createElement("input", {
|
|
229
|
+
className: "ps-link-input", readOnly: true, value: link.url,
|
|
230
|
+
"aria-label": `${link.label} link`, onFocus: selectOnFocus,
|
|
231
|
+
}),
|
|
232
|
+
React.createElement("button", {
|
|
233
|
+
type: "button", className: "ps-mini-button",
|
|
234
|
+
onClick: () => onCopy(key, link.url),
|
|
235
|
+
}, copied === key ? "Copied" : "Copy")));
|
|
236
|
+
}),
|
|
237
|
+
React.createElement("div", { className: "ps-link-copy-all" },
|
|
238
|
+
React.createElement("button", {
|
|
239
|
+
type: "button", className: "ps-mini-button",
|
|
240
|
+
onClick: () => onCopy(allKey, formatPortalLinksForCopy(links)),
|
|
241
|
+
}, copied === allKey ? "Copied all" : "Copy all")));
|
|
242
|
+
}
|
|
243
|
+
|
|
196
244
|
// Best-effort probe: is this session's deep link openable only by the owner/
|
|
197
245
|
// admins right now (private, with no targeted grants)? Used to warn in the
|
|
198
246
|
// copy-link dialog. Failure resolves false (no warning).
|
|
@@ -3956,7 +4004,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
3956
4004
|
*/
|
|
3957
4005
|
const SESSION_DETAIL_NONE = "—";
|
|
3958
4006
|
|
|
3959
|
-
function SessionDetailBox({ session, childCount = 0 }) {
|
|
4007
|
+
function SessionDetailBox({ session, childCount = 0, pause = null, controller = null }) {
|
|
3960
4008
|
// EVERY field renders on EVERY selection, empty ones as an em dash. The box
|
|
3961
4009
|
// is a fixed grid of rows, so moving through the list cannot change its
|
|
3962
4010
|
// height — a box that grew and shrank would shove the list under the
|
|
@@ -4013,6 +4061,37 @@ function SessionDetailBox({ session, childCount = 0 }) {
|
|
|
4013
4061
|
|
|
4014
4062
|
const statusSummary = selectSessionStatusSummary(session);
|
|
4015
4063
|
|
|
4064
|
+
// Why this session is stopped, where the person is standing.
|
|
4065
|
+
//
|
|
4066
|
+
// "(waiting)" is a state, not a reason, and the four reasons have four
|
|
4067
|
+
// different remedies — so the sentence is the server's own, the same one
|
|
4068
|
+
// the "Paused now" band shows, with a way through to the provider that
|
|
4069
|
+
// caused it. `waitReason` covers every OTHER kind of wait (a scheduled
|
|
4070
|
+
// one, an agent asking to sleep), which is a different fact and says so.
|
|
4071
|
+
const waitReason = typeof session?.waitReason === "string" && session.waitReason.trim()
|
|
4072
|
+
? session.waitReason.trim()
|
|
4073
|
+
: null;
|
|
4074
|
+
const pauseBlock = (!pause && !waitReason) ? null : React.createElement("div", {
|
|
4075
|
+
className: `ps-session-detail-pause${pause ? " is-paused" : ""}`,
|
|
4076
|
+
},
|
|
4077
|
+
React.createElement("span", { className: "ps-session-detail-label" }, pause ? "Paused" : "Waiting"),
|
|
4078
|
+
React.createElement("span", { className: "ps-session-detail-why" },
|
|
4079
|
+
// The sentence already says when it clears wherever it can; `clears`
|
|
4080
|
+
// only fills the gap where it cannot.
|
|
4081
|
+
pause ? pause.reason : waitReason,
|
|
4082
|
+
pause && pause.clears
|
|
4083
|
+
? React.createElement("span", { className: "ps-session-detail-clears" }, pause.clears)
|
|
4084
|
+
: null,
|
|
4085
|
+
pause && controller ? React.createElement("button", {
|
|
4086
|
+
type: "button", className: "ps-budget-link",
|
|
4087
|
+
title: pause.provider
|
|
4088
|
+
? `Open ${pause.provider} in Providers & Budgets`
|
|
4089
|
+
: "Open providers and budgets",
|
|
4090
|
+
onClick: () => controller.openBudget(
|
|
4091
|
+
pause.provider ? { provider: pause.provider } : {},
|
|
4092
|
+
).catch(() => {}),
|
|
4093
|
+
}, pause.provider ? `Open ${pause.provider}` : "Open providers and budgets") : null));
|
|
4094
|
+
|
|
4016
4095
|
return React.createElement("div", { className: "ps-session-detail-box" },
|
|
4017
4096
|
// The row above ellipsizes to one line, so this is the only place the
|
|
4018
4097
|
// whole name is legible. Two lines are RESERVED whether or not they are
|
|
@@ -4029,7 +4108,10 @@ function SessionDetailBox({ session, childCount = 0 }) {
|
|
|
4029
4108
|
// selectSessionStatusSummary for why raw made this flip idle/waiting.
|
|
4030
4109
|
field("Updated", statusSummary ? `${statusSummary.relative} (${statusSummary.status})` : null),
|
|
4031
4110
|
field("Children", children),
|
|
4032
|
-
field("Access", access)
|
|
4111
|
+
field("Access", access),
|
|
4112
|
+
// Outside the fixed grid above: this one is a sentence, and the grid's
|
|
4113
|
+
// whole point is that every row is one clamped line.
|
|
4114
|
+
pauseBlock);
|
|
4033
4115
|
}
|
|
4034
4116
|
|
|
4035
4117
|
/**
|
|
@@ -4430,6 +4512,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
4430
4512
|
// every row until these two lines existed.
|
|
4431
4513
|
canvasBySessionId: state.canvas?.bySessionId,
|
|
4432
4514
|
canvasPrefs: state.canvas?.prefs,
|
|
4515
|
+
// Why a waiting session is waiting. Same trap as the canvas slices
|
|
4516
|
+
// above: omit it and every row computes as if nothing were paused, and
|
|
4517
|
+
// three sessions stopped for three different reasons all read
|
|
4518
|
+
// "waiting".
|
|
4519
|
+
budgetPaused: state.budget?.paused,
|
|
4433
4520
|
}), shallowEqualObject);
|
|
4434
4521
|
const computedRows = React.useMemo(() => selectSessionRows({
|
|
4435
4522
|
sessions: {
|
|
@@ -4457,7 +4544,8 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
4457
4544
|
bySessionId: viewState.canvasBySessionId,
|
|
4458
4545
|
prefs: viewState.canvasPrefs,
|
|
4459
4546
|
},
|
|
4460
|
-
|
|
4547
|
+
budget: { paused: viewState.budgetPaused },
|
|
4548
|
+
}), [viewState.activeSessionId, viewState.auth, viewState.branding, viewState.budgetPaused, viewState.canvasBySessionId, viewState.canvasPrefs, viewState.connectionMode, viewState.filterQuery, viewState.listDeselected, viewState.ownerFilter, viewState.pinnedIds, viewState.manualOrder, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
|
|
4461
4549
|
// Hold the previous rows when a poll produced identical output.
|
|
4462
4550
|
const rows = useStableValue(computedRows);
|
|
4463
4551
|
const activeSession = viewState.activeSessionId
|
|
@@ -5190,6 +5278,10 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
5190
5278
|
? React.createElement(SessionDetailBox, {
|
|
5191
5279
|
session: activeSession,
|
|
5192
5280
|
childCount: activeRow?.childCount || 0,
|
|
5281
|
+
// Why it is stopped, and a way through to the surface that can
|
|
5282
|
+
// change it. The row already carries the joined pause record.
|
|
5283
|
+
pause: activeRow?.pause || null,
|
|
5284
|
+
controller,
|
|
5193
5285
|
})
|
|
5194
5286
|
: null),
|
|
5195
5287
|
(manageOpen && activeSession && !activeSession.isGroup)
|
|
@@ -5222,34 +5314,27 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
5222
5314
|
// (selectable), confirms the copy, and warns when the link points at a private
|
|
5223
5315
|
// session no one else can open yet.
|
|
5224
5316
|
function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
|
|
5225
|
-
const
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5317
|
+
const [copied, setCopied] = React.useState("");
|
|
5318
|
+
const multi = buildPortalLinks(url, portalLinkOrigins).length > 1;
|
|
5319
|
+
const copy = React.useCallback((key, text) => {
|
|
5320
|
+
// onCopyAgain owns the single-origin clipboard write (and its status
|
|
5321
|
+
// line); the per-origin buttons write their own one URL.
|
|
5322
|
+
if (key === "session" && !multi) { onCopyAgain?.(); }
|
|
5323
|
+
else if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(text).catch(() => {});
|
|
5324
|
+
setCopied(key);
|
|
5325
|
+
window.setTimeout(() => setCopied(""), 1500);
|
|
5326
|
+
}, [multi, onCopyAgain]);
|
|
5230
5327
|
const stop = (e) => e.stopPropagation();
|
|
5231
5328
|
return React.createElement("div", { className: "ps-share-overlay", onClick: onClose },
|
|
5232
5329
|
React.createElement("div", { className: "ps-link-modal", onClick: stop },
|
|
5233
5330
|
React.createElement("div", { className: "ps-share-modal-head" },
|
|
5234
|
-
React.createElement("span", null,
|
|
5331
|
+
React.createElement("span", null, multi ? "Session links copied" : "Link copied"),
|
|
5235
5332
|
React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
|
|
5236
5333
|
React.createElement("div", { className: "ps-share-section-sub" },
|
|
5237
|
-
|
|
5238
|
-
? "All entry-point links were copied
|
|
5334
|
+
multi
|
|
5335
|
+
? "All entry-point links were copied. Copy just the one your recipient's network can reach, or take both."
|
|
5239
5336
|
: "Copied to your clipboard — anyone with access can open the session from this link."),
|
|
5240
|
-
React.createElement(
|
|
5241
|
-
portalLinkOrigins.length >= 2
|
|
5242
|
-
? React.createElement("textarea", {
|
|
5243
|
-
ref: inputRef, className: "ps-link-input", readOnly: true,
|
|
5244
|
-
rows: buildPortalLinks(url, portalLinkOrigins).length,
|
|
5245
|
-
value: formatPortalLinksForCopy(buildPortalLinks(url, portalLinkOrigins)),
|
|
5246
|
-
onFocus: () => inputRef.current?.select(),
|
|
5247
|
-
})
|
|
5248
|
-
: React.createElement("input", {
|
|
5249
|
-
ref: inputRef, className: "ps-link-input", readOnly: true, value: url,
|
|
5250
|
-
onFocus: () => inputRef.current?.select(),
|
|
5251
|
-
}),
|
|
5252
|
-
React.createElement("button", { className: "ps-mini-button", onClick: onCopyAgain }, "Copy")),
|
|
5337
|
+
React.createElement(MultiOriginLinkRows, { url, keyPrefix: "session", copied, onCopy: copy }),
|
|
5253
5338
|
warn
|
|
5254
5339
|
? React.createElement("div", { className: "ps-link-warn" }, SESSION_LINK_PRIVATE_WARNING)
|
|
5255
5340
|
: null));
|
|
@@ -5301,6 +5386,15 @@ function PlusGlyph() {
|
|
|
5301
5386
|
React.createElement("line", { x1: "5", y1: "12", x2: "19", y2: "12" }));
|
|
5302
5387
|
}
|
|
5303
5388
|
|
|
5389
|
+
function TrashGlyph() {
|
|
5390
|
+
return React.createElement(Glyph, null,
|
|
5391
|
+
React.createElement("path", { d: "M3 6h18" }),
|
|
5392
|
+
React.createElement("path", { d: "M8 6V4h8v2" }),
|
|
5393
|
+
React.createElement("path", { d: "M19 6l-1 15H6L5 6" }),
|
|
5394
|
+
React.createElement("path", { d: "M10 11v5" }),
|
|
5395
|
+
React.createElement("path", { d: "M14 11v5" }));
|
|
5396
|
+
}
|
|
5397
|
+
|
|
5304
5398
|
// Theme picker. Deliberately a contrast disc rather than a sun/moon: the picker
|
|
5305
5399
|
// offers DOOM, WinAMP, win95 and friends, not a light/dark binary.
|
|
5306
5400
|
function ContrastGlyph() {
|
|
@@ -5331,6 +5425,17 @@ function CogGlyph() {
|
|
|
5331
5425
|
React.createElement("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" }));
|
|
5332
5426
|
}
|
|
5333
5427
|
|
|
5428
|
+
// Providers & Budgets. A coin, not a chart or a gauge: the surface is about
|
|
5429
|
+
// what a turn COSTS and who is paying, and a chart glyph would collide with
|
|
5430
|
+
// the inspector's stats mark.
|
|
5431
|
+
function CoinGlyph() {
|
|
5432
|
+
return React.createElement(Glyph, null,
|
|
5433
|
+
React.createElement("circle", { cx: "12", cy: "12", r: "9" }),
|
|
5434
|
+
React.createElement("path", { d: "M14.5 9.2a2.6 2.6 0 0 0-2.5-1.6c-1.5 0-2.6.8-2.6 2s1 1.7 2.6 2 2.6.8 2.6 2-1.1 2-2.6 2a2.6 2.6 0 0 1-2.5-1.6" }),
|
|
5435
|
+
React.createElement("path", { d: "M12 6v1.6" }),
|
|
5436
|
+
React.createElement("path", { d: "M12 16.4V18" }));
|
|
5437
|
+
}
|
|
5438
|
+
|
|
5334
5439
|
// Restart a system session. Was "↻" — the one text codepoint in an otherwise
|
|
5335
5440
|
// all-SVG row, so it rendered a weight lighter and sat a pixel high.
|
|
5336
5441
|
function RestartGlyph() {
|
|
@@ -6273,6 +6378,71 @@ function MobileArtifactOverlay({ controller }) {
|
|
|
6273
6378
|
})));
|
|
6274
6379
|
}
|
|
6275
6380
|
|
|
6381
|
+
// How much visual-viewport height must vanish before we call it a keyboard.
|
|
6382
|
+
// Smaller drops (URL-bar churn, iOS toolbars settling) stay under this.
|
|
6383
|
+
const KEYBOARD_TAKEOVER_MIN_SHRINK_PX = 140;
|
|
6384
|
+
|
|
6385
|
+
/**
|
|
6386
|
+
* Pure decision for the keyboard takeover, split out for tests. Returns the
|
|
6387
|
+
* next {baseline, takeover}. The keyboard signal is a visual-viewport height
|
|
6388
|
+
* drop from the tallest height seen at the current width; a width change is
|
|
6389
|
+
* a rotation and resets the baseline. window.innerHeight is NOT a usable
|
|
6390
|
+
* baseline: Android's interactive-widget=resizes-content shrinks it together
|
|
6391
|
+
* with the keyboard, which would read as "no shrink".
|
|
6392
|
+
*/
|
|
6393
|
+
export function evaluateKeyboardTakeover(baseline, viewportWidth, viewportHeight, composerFocused) {
|
|
6394
|
+
let nextBaseline = baseline;
|
|
6395
|
+
if (!baseline || Math.abs(viewportWidth - baseline.width) > 2) {
|
|
6396
|
+
nextBaseline = { width: viewportWidth, height: viewportHeight };
|
|
6397
|
+
} else if (viewportHeight > baseline.height) {
|
|
6398
|
+
nextBaseline = { width: baseline.width, height: viewportHeight };
|
|
6399
|
+
}
|
|
6400
|
+
const shrunk = viewportHeight <= nextBaseline.height - KEYBOARD_TAKEOVER_MIN_SHRINK_PX;
|
|
6401
|
+
return { baseline: nextBaseline, takeover: shrunk && composerFocused };
|
|
6402
|
+
}
|
|
6403
|
+
|
|
6404
|
+
/**
|
|
6405
|
+
* True while the on-screen keyboard is up AND the chat composer summoned it.
|
|
6406
|
+
* Both conditions matter: height alone would fire for a modal's text field
|
|
6407
|
+
* and collapse the workspace behind the modal; focus alone would fire for
|
|
6408
|
+
* hardware keyboards that shrink nothing. State-driven on the keyboard
|
|
6409
|
+
* itself, so it reverts the moment the keyboard goes away — including the
|
|
6410
|
+
* iOS swipe-dismiss that closes the keyboard without blurring the input.
|
|
6411
|
+
*/
|
|
6412
|
+
function useKeyboardTakeover(enabled) {
|
|
6413
|
+
const [takeover, setTakeover] = React.useState(false);
|
|
6414
|
+
React.useEffect(() => {
|
|
6415
|
+
if (!enabled || typeof window === "undefined" || !window.visualViewport) {
|
|
6416
|
+
setTakeover(false);
|
|
6417
|
+
return undefined;
|
|
6418
|
+
}
|
|
6419
|
+
const viewport = window.visualViewport;
|
|
6420
|
+
let baseline = { width: viewport.width, height: viewport.height };
|
|
6421
|
+
let frame = 0;
|
|
6422
|
+
const evaluate = () => {
|
|
6423
|
+
frame = 0;
|
|
6424
|
+
const composerFocused = Boolean(document.activeElement?.classList?.contains("ps-prompt-input"));
|
|
6425
|
+
const next = evaluateKeyboardTakeover(baseline, viewport.width, viewport.height, composerFocused);
|
|
6426
|
+
baseline = next.baseline;
|
|
6427
|
+
setTakeover(next.takeover);
|
|
6428
|
+
};
|
|
6429
|
+
// rAF-deferred: focusout fires while activeElement is mid-transition,
|
|
6430
|
+
// and visualViewport resize storms during the keyboard animation.
|
|
6431
|
+
const schedule = () => { if (!frame) frame = requestAnimationFrame(evaluate); };
|
|
6432
|
+
evaluate();
|
|
6433
|
+
viewport.addEventListener("resize", schedule);
|
|
6434
|
+
window.addEventListener("focusin", schedule);
|
|
6435
|
+
window.addEventListener("focusout", schedule);
|
|
6436
|
+
return () => {
|
|
6437
|
+
if (frame) cancelAnimationFrame(frame);
|
|
6438
|
+
viewport.removeEventListener("resize", schedule);
|
|
6439
|
+
window.removeEventListener("focusin", schedule);
|
|
6440
|
+
window.removeEventListener("focusout", schedule);
|
|
6441
|
+
};
|
|
6442
|
+
}, [enabled]);
|
|
6443
|
+
return enabled ? takeover : false;
|
|
6444
|
+
}
|
|
6445
|
+
|
|
6276
6446
|
/**
|
|
6277
6447
|
* The phone's Main pane, in one of three layouts cycled by the Main toolbar
|
|
6278
6448
|
* button: split (sessions + chat), chat only, sessions only (list + the
|
|
@@ -6386,6 +6556,13 @@ function CanvasLinkGlyph() {
|
|
|
6386
6556
|
* Link management is a session:manage operation server-side; anyone else
|
|
6387
6557
|
* sees the server's refusal inline.
|
|
6388
6558
|
*/
|
|
6559
|
+
// Tab labels name the AUDIENCE, not the mechanism — the sharer is choosing
|
|
6560
|
+
// who should be able to open this, not which token scheme to use.
|
|
6561
|
+
const CANVAS_SHARE_TABS = [
|
|
6562
|
+
{ id: "session", label: "Session access" },
|
|
6563
|
+
{ id: "public", label: "Anyone with link" },
|
|
6564
|
+
];
|
|
6565
|
+
|
|
6389
6566
|
function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
|
|
6390
6567
|
const [info, setInfo] = React.useState(null); // { exists, createdAt } | null while loading
|
|
6391
6568
|
const [freshLink, setFreshLink] = React.useState(null); // the just-minted URL, shown once
|
|
@@ -6393,37 +6570,19 @@ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
|
|
|
6393
6570
|
const [error, setError] = React.useState("");
|
|
6394
6571
|
const [copied, setCopied] = React.useState("");
|
|
6395
6572
|
|
|
6573
|
+
// Which kind of link the dialog is showing. Two tabs rather than two
|
|
6574
|
+
// stacked sections: the choice between "people who already have session
|
|
6575
|
+
// access" and "anyone holding this token" is the decision the sharer is
|
|
6576
|
+
// making, and stacking them put a destructive Reset next to an innocuous
|
|
6577
|
+
// deep link. Mirrors the Manage-session dialog's General/Access tabs.
|
|
6578
|
+
const [tab, setTab] = React.useState("session");
|
|
6396
6579
|
const transport = controller.transport;
|
|
6397
6580
|
const sessionLink = `${window.location.origin}/?session=${encodeURIComponent(sessionId)}&view=canvas&slot=${slot}&max=1`;
|
|
6398
|
-
//
|
|
6399
|
-
// variant, ONE copy that takes all of them — the sender does not know
|
|
6400
|
-
// which entry point the recipient can reach, so they paste both. The
|
|
6401
|
-
// token variants all resolve the same hashed row: one token, N doors,
|
|
6581
|
+
// The token variants all resolve the same hashed row: one token, N doors,
|
|
6402
6582
|
// single-switch revocation whichever door the bearer uses.
|
|
6403
|
-
const linkRows = (url, keyPrefix) => {
|
|
6404
|
-
|
|
6405
|
-
|
|
6406
|
-
return React.createElement("div", { className: "ps-canvas-share-row" },
|
|
6407
|
-
React.createElement("code", { className: "ps-canvas-share-url" }, url),
|
|
6408
|
-
React.createElement("button", {
|
|
6409
|
-
type: "button", className: "ps-mini-button",
|
|
6410
|
-
onClick: () => copy(keyPrefix, url),
|
|
6411
|
-
}, copied === keyPrefix ? "Copied" : "Copy"));
|
|
6412
|
-
}
|
|
6413
|
-
const text = formatPortalLinksForCopy(links);
|
|
6414
|
-
return React.createElement("div", { className: "ps-canvas-share-row" },
|
|
6415
|
-
React.createElement("textarea", {
|
|
6416
|
-
className: "ps-link-input",
|
|
6417
|
-
readOnly: true,
|
|
6418
|
-
rows: links.length,
|
|
6419
|
-
value: text,
|
|
6420
|
-
onFocus: (event) => event.currentTarget.select(),
|
|
6421
|
-
}),
|
|
6422
|
-
React.createElement("button", {
|
|
6423
|
-
type: "button", className: "ps-mini-button",
|
|
6424
|
-
onClick: () => copy(keyPrefix, text),
|
|
6425
|
-
}, copied === keyPrefix ? "Copied" : "Copy"));
|
|
6426
|
-
};
|
|
6583
|
+
const linkRows = (url, keyPrefix) => React.createElement(MultiOriginLinkRows, {
|
|
6584
|
+
url, keyPrefix, copied, onCopy: copy,
|
|
6585
|
+
});
|
|
6427
6586
|
|
|
6428
6587
|
const refresh = React.useCallback(() => {
|
|
6429
6588
|
transport.getCanvasShareLink?.(sessionId, slot)
|
|
@@ -6464,32 +6623,55 @@ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
|
|
|
6464
6623
|
"Share this canvas",
|
|
6465
6624
|
React.createElement("button", { type: "button", className: "ps-mini-button", onClick: onClose, "aria-label": "Close" }, "✕")),
|
|
6466
6625
|
|
|
6467
|
-
React.createElement("div", { className: "ps-
|
|
6468
|
-
React.createElement("
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
React.createElement("div", {
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6626
|
+
React.createElement("div", { className: "ps-manage-tabs", role: "tablist" },
|
|
6627
|
+
CANVAS_SHARE_TABS.map((t) => React.createElement("button", {
|
|
6628
|
+
key: t.id,
|
|
6629
|
+
type: "button",
|
|
6630
|
+
role: "tab",
|
|
6631
|
+
"aria-selected": tab === t.id ? "true" : "false",
|
|
6632
|
+
className: `ps-manage-tab${tab === t.id ? " is-active" : ""}`,
|
|
6633
|
+
onClick: () => setTab(t.id),
|
|
6634
|
+
}, t.label))),
|
|
6635
|
+
|
|
6636
|
+
// Both panels are always rendered, stacked in one grid cell, with
|
|
6637
|
+
// the inactive one held at visibility:hidden. It still occupies
|
|
6638
|
+
// its space, so the dialog is always as tall as the TALLER tab and
|
|
6639
|
+
// switching tabs never resizes the box. Sizing this way (rather
|
|
6640
|
+
// than a min-height guess) also stays correct when the panels grow:
|
|
6641
|
+
// one link box on a single-origin deployment, two plus Copy all on
|
|
6642
|
+
// a multi-origin one. visibility:hidden also takes the hidden
|
|
6643
|
+
// panel's inputs and buttons out of the tab order and the
|
|
6644
|
+
// accessibility tree, so nothing offscreen is reachable.
|
|
6645
|
+
React.createElement("div", { className: "ps-canvas-share-panels" },
|
|
6646
|
+
React.createElement("div", {
|
|
6647
|
+
className: `ps-canvas-share-panel${tab === "session" ? " is-active" : ""}`,
|
|
6648
|
+
role: "tabpanel", "aria-hidden": tab === "session" ? undefined : "true",
|
|
6649
|
+
},
|
|
6650
|
+
React.createElement("div", { className: "ps-canvas-share-sub" }, "Opens the portal signed in, canvas full screen. Session visibility rules apply."),
|
|
6651
|
+
linkRows(sessionLink, "session")),
|
|
6652
|
+
React.createElement("div", {
|
|
6653
|
+
className: `ps-canvas-share-panel${tab === "public" ? " is-active" : ""}`,
|
|
6654
|
+
role: "tabpanel", "aria-hidden": tab === "public" ? undefined : "true",
|
|
6655
|
+
},
|
|
6656
|
+
React.createElement("div", { className: "ps-canvas-share-sub" },
|
|
6657
|
+
"View only, live, no sign-in. One link at a time — resetting makes the previous link stop working immediately."),
|
|
6658
|
+
info === null
|
|
6659
|
+
? React.createElement("div", { className: "ps-canvas-share-sub" }, "Checking…")
|
|
6660
|
+
: freshLink
|
|
6661
|
+
? React.createElement(React.Fragment, null,
|
|
6662
|
+
linkRows(freshLink, "public"),
|
|
6663
|
+
React.createElement("div", { className: "ps-canvas-share-sub is-warn" },
|
|
6664
|
+
"Copy it now — the link is shown only this once."))
|
|
6665
|
+
: info.exists
|
|
6666
|
+
? React.createElement("div", { className: "ps-canvas-share-sub" },
|
|
6667
|
+
`A link exists${info.createdAt ? ` (created ${new Date(info.createdAt).toLocaleString()})` : ""}. For safety it cannot be re-shown — Reset to get a new one.`)
|
|
6668
|
+
: React.createElement("div", { className: "ps-canvas-share-sub" }, "No public link yet."),
|
|
6669
|
+
React.createElement("div", { className: "ps-canvas-share-actions" },
|
|
6670
|
+
React.createElement("button", { type: "button", className: "ps-mini-button", disabled: busy, onClick: () => mint(info?.exists) },
|
|
6671
|
+
info?.exists ? "Reset link" : "Create link"),
|
|
6672
|
+
info?.exists
|
|
6673
|
+
? React.createElement("button", { type: "button", className: "ps-mini-button", disabled: busy, onClick: remove }, "Remove link")
|
|
6674
|
+
: null))),
|
|
6493
6675
|
error ? React.createElement("div", { className: "ps-canvas-share-error" }, error) : null));
|
|
6494
6676
|
}
|
|
6495
6677
|
|
|
@@ -8335,6 +8517,7 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
|
|
|
8335
8517
|
setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
|
|
8336
8518
|
}, [mobile]);
|
|
8337
8519
|
const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
|
|
8520
|
+
const budgetOpen = useControllerSelector(controller, (state) => Boolean(state.ui?.budgetOpen));
|
|
8338
8521
|
const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
|
|
8339
8522
|
const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
|
|
8340
8523
|
const canvasMaximized = useControllerSelector(controller, (s) => s?.ui?.canvasMaximized === true);
|
|
@@ -8404,6 +8587,19 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
|
|
|
8404
8587
|
// (loaded), green + yellow (loaded with unseen changes).
|
|
8405
8588
|
loadedDot: canvasView.exists,
|
|
8406
8589
|
},
|
|
8590
|
+
// Providers & Budgets. Both device classes: what a turn costs, and
|
|
8591
|
+
// what stopped a session, are answers a person needs wherever they
|
|
8592
|
+
// are — and unlike the admin console the surface reflows to a phone.
|
|
8593
|
+
{
|
|
8594
|
+
key: "budget",
|
|
8595
|
+
icon: React.createElement(CoinGlyph),
|
|
8596
|
+
label: budgetOpen ? "Close budget" : "Budget — providers, limits and usage",
|
|
8597
|
+
onClick: () => {
|
|
8598
|
+
if (budgetOpen) controller.closeBudget();
|
|
8599
|
+
else controller.openBudget().catch(() => {});
|
|
8600
|
+
},
|
|
8601
|
+
active: budgetOpen,
|
|
8602
|
+
},
|
|
8407
8603
|
// Diagnostics — Inspector + Activity as one column, desktop only.
|
|
8408
8604
|
//
|
|
8409
8605
|
// They used to be tabs inside the chat pane and were always present.
|
|
@@ -8496,10 +8692,10 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
|
|
|
8496
8692
|
);
|
|
8497
8693
|
}
|
|
8498
8694
|
|
|
8499
|
-
// Two groups. Diagnostics and the admin console are TOOLS — they
|
|
8500
|
-
// the app rather than on the conversation — so they sit apart from
|
|
8501
|
-
// session controls, hard right. Everything else keeps its order.
|
|
8502
|
-
const RIGHT_GROUP = new Set(["diagnostics", "admin"]);
|
|
8695
|
+
// Two groups. Budget, Diagnostics and the admin console are TOOLS — they
|
|
8696
|
+
// act on the app rather than on the conversation — so they sit apart from
|
|
8697
|
+
// the session controls, hard right. Everything else keeps its order.
|
|
8698
|
+
const RIGHT_GROUP = new Set(["budget", "diagnostics", "admin"]);
|
|
8503
8699
|
|
|
8504
8700
|
// While the canvas is full screen, ANY other button first drops full
|
|
8505
8701
|
// screen and then does its own job. Pressing Filter and watching nothing
|
|
@@ -9144,6 +9340,1364 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
|
|
|
9144
9340
|
|
|
9145
9341
|
|
|
9146
9342
|
|
|
9343
|
+
// ── Providers & Budgets ─────────────────────────────────────────────────────
|
|
9344
|
+
//
|
|
9345
|
+
// One table. docs/proposals/providers-and-budgets-meters.md is the build spec:
|
|
9346
|
+
// every provider the viewer can use, with what has been spent against what may
|
|
9347
|
+
// be spent, for the day, the week and the month. Selecting a row expands its
|
|
9348
|
+
// per-model limits and opens its day-by-day chart underneath.
|
|
9349
|
+
//
|
|
9350
|
+
// A cell is `used / quota`. A period with no limit shows ∞, and the figure
|
|
9351
|
+
// beside it is still real usage — the meter runs whether or not anybody capped
|
|
9352
|
+
// it.
|
|
9353
|
+
//
|
|
9354
|
+
// TWO PAIRS OF NUMBERS, AND THEY ARE NEVER MIXED. Unticked, every cell is the
|
|
9355
|
+
// viewer's own spend against their share of the limit. Ticked, every cell is
|
|
9356
|
+
// everyone's spend against the limit itself. The selector decides which pair a
|
|
9357
|
+
// cell holds; the heading over the three columns says which one is on screen;
|
|
9358
|
+
// nothing in this file recomputes either.
|
|
9359
|
+
//
|
|
9360
|
+
// Nothing here decides who may do what. Every change goes through the
|
|
9361
|
+
// controller to the `cms_provider_*` procedures, the DATABASE refuses what the
|
|
9362
|
+
// viewer may not do, and its refusal — already written for a person, and
|
|
9363
|
+
// naming the remedy — is printed unchanged.
|
|
9364
|
+
//
|
|
9365
|
+
// The vocabulary is binding: provider, limit, allowance, hold, paused, period.
|
|
9366
|
+
// Never pool, payer, grant, quota rule, or bind mode.
|
|
9367
|
+
|
|
9368
|
+
const BUDGET_PERIOD_OPTIONS = [
|
|
9369
|
+
{ value: "day", label: "Daily" },
|
|
9370
|
+
{ value: "week", label: "Weekly" },
|
|
9371
|
+
{ value: "month", label: "Monthly" },
|
|
9372
|
+
];
|
|
9373
|
+
const BUDGET_PERIOD_TITLES = { day: "Daily", week: "Weekly", month: "Monthly" };
|
|
9374
|
+
|
|
9375
|
+
// Plain under 90%, amber to 100%, red over. Colour is never the only signal —
|
|
9376
|
+
// the two numbers in the cell already say it — so this only tints the used
|
|
9377
|
+
// figure and nothing depends on being able to see the tint.
|
|
9378
|
+
const BUDGET_TONE_CLASS = { plain: "", amber: " warn", red: " over", idle: "" };
|
|
9379
|
+
|
|
9380
|
+
/**
|
|
9381
|
+
* One number format for a WHOLE axis, chosen from its top value.
|
|
9382
|
+
*
|
|
9383
|
+
* A per-value formatter is right for a table and wrong for a scale: the same
|
|
9384
|
+
* y-axis printed 18.4M, 13.8M, 9.21M and 4.60M, and four precisions down one
|
|
9385
|
+
* ruler read as four different units.
|
|
9386
|
+
*/
|
|
9387
|
+
function fixedScaleFormatter(max) {
|
|
9388
|
+
const top = Math.abs(Number(max) || 0);
|
|
9389
|
+
const [unit, div] = top >= 1e9 ? ["B", 1e9]
|
|
9390
|
+
: top >= 1e6 ? ["M", 1e6]
|
|
9391
|
+
: top >= 1e3 ? ["K", 1e3]
|
|
9392
|
+
: ["", 1];
|
|
9393
|
+
const scaled = top / div;
|
|
9394
|
+
const decimals = unit === "" ? 0 : scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2;
|
|
9395
|
+
return (value) => {
|
|
9396
|
+
const v = Number(value);
|
|
9397
|
+
if (!Number.isFinite(v)) return "—";
|
|
9398
|
+
// Zero is zero in every unit; "0.00M" is noise on the baseline.
|
|
9399
|
+
if (v === 0) return "0";
|
|
9400
|
+
return `${(v / div).toFixed(decimals)}${unit}`;
|
|
9401
|
+
};
|
|
9402
|
+
}
|
|
9403
|
+
|
|
9404
|
+
/** Parse "20,000,000" / "20M" / "20000000" to a whole number of tokens, or null. */
|
|
9405
|
+
function parseTokenCount(raw) {
|
|
9406
|
+
const s = String(raw ?? "").trim().replace(/[,_\s]/g, "").toUpperCase();
|
|
9407
|
+
if (!s) return null;
|
|
9408
|
+
const m = /^(\d+(?:\.\d+)?)([KMB])?$/.exec(s);
|
|
9409
|
+
if (!m) return null;
|
|
9410
|
+
const mult = m[2] === "K" ? 1e3 : m[2] === "M" ? 1e6 : m[2] === "B" ? 1e9 : 1;
|
|
9411
|
+
// A whole number of tokens, as the field says. "1.5" used to round to 2 —
|
|
9412
|
+
// one of a bare decimal and "2.5M" is a typo and the other is meant, and
|
|
9413
|
+
// guessing between them is how a limit lands ten times off.
|
|
9414
|
+
if (!m[2] && /\./.test(m[1])) return null;
|
|
9415
|
+
const n = Math.round(Number(m[1]) * mult);
|
|
9416
|
+
// Above 2^53 a JS number stops being the integer that was typed, so the
|
|
9417
|
+
// value SAVED is not the value shown — and past bigint it fails deep in
|
|
9418
|
+
// the database as a bare "Internal server error". Refused here, where the
|
|
9419
|
+
// field can say so, exactly like 0 and a negative already are.
|
|
9420
|
+
if (!Number.isSafeInteger(n)) return null;
|
|
9421
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
9422
|
+
}
|
|
9423
|
+
|
|
9424
|
+
/**
|
|
9425
|
+
* "resets in 2h 31m (00:00 UTC)".
|
|
9426
|
+
*
|
|
9427
|
+
* The clock is formatted ONCE, in the selector (formatUntilLabel), so every
|
|
9428
|
+
* reset on this surface is spelled the same way. This only puts the verb in
|
|
9429
|
+
* front of it. It used to be a second formatter here, which is how one instant
|
|
9430
|
+
* came to read three ways.
|
|
9431
|
+
*/
|
|
9432
|
+
function budgetResetPhrase(resetsLabel) {
|
|
9433
|
+
return resetsLabel ? `resets ${resetsLabel}` : "no reset recorded";
|
|
9434
|
+
}
|
|
9435
|
+
|
|
9436
|
+
/** "3 sessions" / "1 session". */
|
|
9437
|
+
function budgetPlural(n, one, many) {
|
|
9438
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
9439
|
+
}
|
|
9440
|
+
|
|
9441
|
+
/**
|
|
9442
|
+
* A model reference a session can run: `<provider>:<model>`.
|
|
9443
|
+
*
|
|
9444
|
+
* The catalog's `providerId` is the TYPE ("azure-openai"); a provider is an
|
|
9445
|
+
* instance of that type with a name of its own, and both a model-scoped limit
|
|
9446
|
+
* and a default tuple are refused by the database unless the model half names
|
|
9447
|
+
* the provider it belongs to.
|
|
9448
|
+
*/
|
|
9449
|
+
function budgetModelsForProvider(models, provider) {
|
|
9450
|
+
if (!provider?.typeId) return [];
|
|
9451
|
+
return models
|
|
9452
|
+
.filter((model) => model.providerId === provider.typeId)
|
|
9453
|
+
.map((model) => ({
|
|
9454
|
+
qualifiedName: `${provider.name}:${model.modelName}`,
|
|
9455
|
+
modelName: model.modelName,
|
|
9456
|
+
}));
|
|
9457
|
+
}
|
|
9458
|
+
|
|
9459
|
+
/** The provider TYPES this deployment can instantiate, from its model catalog. */
|
|
9460
|
+
function budgetProviderTypes(models) {
|
|
9461
|
+
const seen = new Map();
|
|
9462
|
+
for (const model of models) {
|
|
9463
|
+
if (!model.providerId || seen.has(model.providerId)) continue;
|
|
9464
|
+
seen.set(model.providerId, { id: model.providerId, type: model.providerType || model.providerId });
|
|
9465
|
+
}
|
|
9466
|
+
return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
9467
|
+
}
|
|
9468
|
+
|
|
9469
|
+
/** listModels() is flat on the web transport and grouped on the direct one. */
|
|
9470
|
+
function budgetNormalizeModels(raw) {
|
|
9471
|
+
const top = Array.isArray(raw)
|
|
9472
|
+
? raw
|
|
9473
|
+
: (Array.isArray(raw?.providers) ? raw.providers : (Array.isArray(raw?.models) ? raw.models : []));
|
|
9474
|
+
const flat = top.flatMap((entry) => (
|
|
9475
|
+
Array.isArray(entry?.models)
|
|
9476
|
+
? entry.models.map((model) => ({ ...model, providerId: model.providerId || entry.providerId }))
|
|
9477
|
+
: [entry]
|
|
9478
|
+
));
|
|
9479
|
+
return flat
|
|
9480
|
+
.filter((model) => model && (model.modelName || model.qualifiedName))
|
|
9481
|
+
.map((model) => ({
|
|
9482
|
+
modelName: model.modelName || String(model.qualifiedName).split(":").pop(),
|
|
9483
|
+
providerId: model.providerId || "",
|
|
9484
|
+
providerType: model.providerType || model.providerId || "",
|
|
9485
|
+
}));
|
|
9486
|
+
}
|
|
9487
|
+
|
|
9488
|
+
/**
|
|
9489
|
+
* One sheet for every change on this surface. Local state, no shared slot: a
|
|
9490
|
+
* refused write is READ where the person is looking rather than dismissed, and
|
|
9491
|
+
* the sheet stays open so the value that was refused is still on screen.
|
|
9492
|
+
*/
|
|
9493
|
+
function BudgetSheet({ title, subtitle, children, footNote, confirmLabel, danger = false, busy = false,
|
|
9494
|
+
disabled = false, error = null, onCancel, onConfirm = null, cancelLabel = "Cancel" }) {
|
|
9495
|
+
// aria-modal promises focus stays inside. Keep the promise: Tab cycles
|
|
9496
|
+
// within the sheet, and a sheet with no input (Delete, Remove) takes focus
|
|
9497
|
+
// itself so the first Tab does not land behind the backdrop.
|
|
9498
|
+
const sheetRef = React.useRef(null);
|
|
9499
|
+
React.useEffect(() => {
|
|
9500
|
+
const root = sheetRef.current;
|
|
9501
|
+
if (!root) return undefined;
|
|
9502
|
+
const focusables = () => [...root.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')];
|
|
9503
|
+
if (!root.contains(document.activeElement)) { const f = focusables(); (f[0] || root).focus(); }
|
|
9504
|
+
const onKey = (event) => {
|
|
9505
|
+
if (event.key !== "Tab") return;
|
|
9506
|
+
const f = focusables();
|
|
9507
|
+
if (!f.length) return;
|
|
9508
|
+
const first = f[0];
|
|
9509
|
+
const last = f[f.length - 1];
|
|
9510
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
9511
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
9512
|
+
};
|
|
9513
|
+
root.addEventListener("keydown", onKey);
|
|
9514
|
+
return () => root.removeEventListener("keydown", onKey);
|
|
9515
|
+
}, []);
|
|
9516
|
+
// Capture phase, and stopPropagation: the surface's own Escape handler is
|
|
9517
|
+
// ALSO on capture and closes the whole workspace. It exempts INPUT/SELECT
|
|
9518
|
+
// targets, so a sheet whose focus sat on a button let Escape fall through
|
|
9519
|
+
// to it — the sheet AND the surface vanished together.
|
|
9520
|
+
React.useEffect(() => {
|
|
9521
|
+
const onKey = (event) => {
|
|
9522
|
+
if (event.key !== "Escape") return;
|
|
9523
|
+
event.preventDefault();
|
|
9524
|
+
event.stopPropagation();
|
|
9525
|
+
onCancel();
|
|
9526
|
+
};
|
|
9527
|
+
window.addEventListener("keydown", onKey, true);
|
|
9528
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
9529
|
+
}, [onCancel]);
|
|
9530
|
+
return React.createElement("div", {
|
|
9531
|
+
className: "ps-budget-sheet-backdrop",
|
|
9532
|
+
// preventDefault, or the browser's own default action for this same
|
|
9533
|
+
// mousedown moves focus AFTER the close handler has restored it —
|
|
9534
|
+
// and it lands on <body>, because a backdrop is a div nothing can
|
|
9535
|
+
// focus. Cancel and Escape never hit this, which is why the backdrop
|
|
9536
|
+
// was the one close path that still stranded a keyboard user.
|
|
9537
|
+
onMouseDown: (event) => {
|
|
9538
|
+
if (event.target !== event.currentTarget) return;
|
|
9539
|
+
event.preventDefault();
|
|
9540
|
+
onCancel();
|
|
9541
|
+
},
|
|
9542
|
+
},
|
|
9543
|
+
React.createElement("div", {
|
|
9544
|
+
className: "ps-budget-sheet", role: "dialog", "aria-modal": "true", "aria-label": title,
|
|
9545
|
+
ref: sheetRef, tabIndex: -1,
|
|
9546
|
+
},
|
|
9547
|
+
React.createElement("div", { className: "ps-budget-sheet-top" },
|
|
9548
|
+
React.createElement("div", { className: "ps-budget-sheet-title" }, title),
|
|
9549
|
+
subtitle ? React.createElement("div", { className: "ps-budget-sheet-sub" }, subtitle) : null),
|
|
9550
|
+
React.createElement("div", { className: "ps-budget-sheet-body" }, children,
|
|
9551
|
+
error ? React.createElement("div", { className: "ps-budget-sheet-error", role: "alert" }, error) : null),
|
|
9552
|
+
React.createElement("div", { className: "ps-budget-sheet-foot" },
|
|
9553
|
+
React.createElement("span", { className: "ps-budget-sheet-note" }, footNote || ""),
|
|
9554
|
+
React.createElement("div", { className: "ps-budget-sheet-actions" },
|
|
9555
|
+
React.createElement("button", { type: "button", className: "ps-mini-button", onClick: onCancel, disabled: busy }, cancelLabel),
|
|
9556
|
+
// `busy` is a change in flight (the label becomes "…"); `disabled`
|
|
9557
|
+
// is a form that is not yet valid, where the label stays so the
|
|
9558
|
+
// button still says what it will do.
|
|
9559
|
+
onConfirm ? React.createElement("button", {
|
|
9560
|
+
type: "button",
|
|
9561
|
+
className: `ps-mini-button ${danger ? "is-danger" : "is-primary"}`,
|
|
9562
|
+
onClick: onConfirm, disabled: busy || disabled,
|
|
9563
|
+
}, busy ? "…" : confirmLabel) : null))));
|
|
9564
|
+
}
|
|
9565
|
+
|
|
9566
|
+
/**
|
|
9567
|
+
* A labelled field inside a sheet. A <div>, not a <label>: a <label> wrapping a
|
|
9568
|
+
* segmented control gives its FIRST button an accessible name that swallows
|
|
9569
|
+
* every sibling's text ("Period Daily Weekly Monthly"), so a screen reader
|
|
9570
|
+
* cannot tell the options apart.
|
|
9571
|
+
*/
|
|
9572
|
+
function SheetField({ label, hint, children }) {
|
|
9573
|
+
return React.createElement("div", { className: "ps-budget-field", role: "group", "aria-label": label },
|
|
9574
|
+
React.createElement("span", { className: "ps-budget-label" }, label),
|
|
9575
|
+
children,
|
|
9576
|
+
hint ? React.createElement("span", { className: "ps-budget-field-hint" }, hint) : null);
|
|
9577
|
+
}
|
|
9578
|
+
|
|
9579
|
+
/**
|
|
9580
|
+
* A segmented control: one of N words.
|
|
9581
|
+
*
|
|
9582
|
+
* Pressed buttons, not radios. A radiogroup promises the arrow keys move
|
|
9583
|
+
* between the options and that only one of them is a tab stop; neither is true
|
|
9584
|
+
* here, so this claims no contract it does not keep.
|
|
9585
|
+
*/
|
|
9586
|
+
function Segmented({ value, options, onChange, label = null }) {
|
|
9587
|
+
return React.createElement("div", {
|
|
9588
|
+
className: "ps-budget-seg", role: "group", "aria-label": label || undefined,
|
|
9589
|
+
},
|
|
9590
|
+
options.map((option) => React.createElement("button", {
|
|
9591
|
+
key: option.value, type: "button", "aria-pressed": value === option.value ? "true" : "false",
|
|
9592
|
+
className: `ps-budget-seg-btn${value === option.value ? " is-on" : ""}`,
|
|
9593
|
+
onClick: () => onChange(option.value),
|
|
9594
|
+
}, option.label)));
|
|
9595
|
+
}
|
|
9596
|
+
|
|
9597
|
+
/**
|
|
9598
|
+
* One cell: the used figure, then the quota beside it.
|
|
9599
|
+
*
|
|
9600
|
+
* The two halves are marked apart. Printed as one string they read as one
|
|
9601
|
+
* number, and "480.0K / 500.0K" is a fact about two.
|
|
9602
|
+
*/
|
|
9603
|
+
function budgetCellEl(cell) {
|
|
9604
|
+
// Nobody signed in: the used figure is UNKNOWN, and unknown is not zero.
|
|
9605
|
+
const tone = cell.known ? (BUDGET_TONE_CLASS[cell.tone] || "") : " idle";
|
|
9606
|
+
return React.createElement(React.Fragment, null,
|
|
9607
|
+
React.createElement("span", { className: `ps-budget-pct${tone}` }, cell.usedLabel),
|
|
9608
|
+
// The provider's limit is spent even though your share is not. Marked
|
|
9609
|
+
// in the cell, because a plain number here means "you may spend this"
|
|
9610
|
+
// and you may not.
|
|
9611
|
+
cell.providerExhausted
|
|
9612
|
+
? React.createElement("span", { className: "ps-budget-blocked", title: "The provider's own limit is spent." }, " ⏻")
|
|
9613
|
+
: null,
|
|
9614
|
+
" ",
|
|
9615
|
+
React.createElement("span", {
|
|
9616
|
+
className: `ps-budget-q ps-budget-sub${cell.uncapped ? " is-inf" : ""}`,
|
|
9617
|
+
}, `/ ${cell.quotaLabel}`));
|
|
9618
|
+
}
|
|
9619
|
+
|
|
9620
|
+
/** What a cell says on hover: the period, its reset, and whether it is capped. */
|
|
9621
|
+
function budgetCellTitle(row, column, cell) {
|
|
9622
|
+
const who = row.kind === "model" ? `${row.providerName} · ${row.label}` : row.providerName;
|
|
9623
|
+
const cap = cell.uncapped
|
|
9624
|
+
? "no limit for this period"
|
|
9625
|
+
: `${cell.usedLabel} of ${cell.quotaLabel}${cell.pct == null ? "" : ` (${cell.pct}%)`}`;
|
|
9626
|
+
// Your share can have room while the limit it is a share OF is spent, and
|
|
9627
|
+
// then nothing runs. The tint alone would not say which of the two it is.
|
|
9628
|
+
const blocked = cell.providerExhausted
|
|
9629
|
+
? " The provider's own limit for this period is spent, so nothing runs against it whatever your share says."
|
|
9630
|
+
: "";
|
|
9631
|
+
return `${who} · ${column.label} — ${cap}. It ${budgetResetPhrase(cell.resetsLabel)}.${blocked}`;
|
|
9632
|
+
}
|
|
9633
|
+
|
|
9634
|
+
/**
|
|
9635
|
+
* The table, and the tick that decides which pair of numbers is in it.
|
|
9636
|
+
*
|
|
9637
|
+
* Rows arrive in draw order — shared providers, then the viewer's own, each
|
|
9638
|
+
* followed by its model rows while it is selected. They are not sorted here: a
|
|
9639
|
+
* sort would move a model row out from under the provider it belongs to.
|
|
9640
|
+
*/
|
|
9641
|
+
function ProviderTable({ controller, view, defaults = null }) {
|
|
9642
|
+
const select = React.useCallback((name, scope = "*") => {
|
|
9643
|
+
controller.selectBudgetProvider(name, scope).catch(() => {});
|
|
9644
|
+
}, [controller]);
|
|
9645
|
+
|
|
9646
|
+
const nameCell = (row) => {
|
|
9647
|
+
if (row.kind === "model") {
|
|
9648
|
+
// Selectable in its own right: usage and any limit belong to this
|
|
9649
|
+
// model, so the chart follows it.
|
|
9650
|
+
return React.createElement("td", { className: "ps-budget-grid-name is-model" },
|
|
9651
|
+
React.createElement("button", {
|
|
9652
|
+
type: "button", className: "ps-budget-grid-pick",
|
|
9653
|
+
"aria-pressed": row.selected ? "true" : "false",
|
|
9654
|
+
onClick: () => select(row.providerName, row.scope),
|
|
9655
|
+
},
|
|
9656
|
+
React.createElement("span", { className: "ps-budget-grid-mname" }, row.label),
|
|
9657
|
+
React.createElement("span", { className: "ps-budget-sub" }, " · model")));
|
|
9658
|
+
}
|
|
9659
|
+
return React.createElement("td", { className: "ps-budget-grid-name" },
|
|
9660
|
+
// ONE control for the whole cell. Selecting a provider and
|
|
9661
|
+
// expanding its per-model limits are the same act, so they are the
|
|
9662
|
+
// same button rather than two that do the same thing.
|
|
9663
|
+
React.createElement("button", {
|
|
9664
|
+
type: "button", className: "ps-budget-grid-pick",
|
|
9665
|
+
"aria-pressed": row.selected ? "true" : "false",
|
|
9666
|
+
"aria-expanded": row.modelRowCount > 0 ? (row.expanded ? "true" : "false") : undefined,
|
|
9667
|
+
onClick: () => select(row.providerName),
|
|
9668
|
+
},
|
|
9669
|
+
React.createElement("span", { className: "ps-budget-grid-pname" }, row.label),
|
|
9670
|
+
// Whose it is, for a provider that has an owner. Beside the name
|
|
9671
|
+
// rather than in the chip: the chip says WHAT kind of provider it
|
|
9672
|
+
// is, and this says whose — two different facts.
|
|
9673
|
+
row.ownerLabel
|
|
9674
|
+
? React.createElement("span", { className: "ps-budget-grid-owner" }, `(${row.ownerLabel})`)
|
|
9675
|
+
: null,
|
|
9676
|
+
// The class, named. Shared and User look different because they
|
|
9677
|
+
// behave differently: one is everyone's and an administrator's to
|
|
9678
|
+
// change, the other is a single person's.
|
|
9679
|
+
row.classLabel
|
|
9680
|
+
? React.createElement("span", {
|
|
9681
|
+
className: `ps-budget-chip is-${row.class}`,
|
|
9682
|
+
title: row.class === "shared"
|
|
9683
|
+
? "Shared: anyone may spend from it. An administrator manages its limits."
|
|
9684
|
+
: (row.ownedByMe === false
|
|
9685
|
+
? "A user provider belonging to another user."
|
|
9686
|
+
: "Your own provider. Only you see it or spend from it."),
|
|
9687
|
+
}, row.classLabel)
|
|
9688
|
+
: null,
|
|
9689
|
+
defaults?.mine?.provider === row.providerName
|
|
9690
|
+
? React.createElement("span", { className: "ps-budget-chip" }, "my default")
|
|
9691
|
+
: null,
|
|
9692
|
+
defaults?.cluster?.provider === row.providerName
|
|
9693
|
+
? React.createElement("span", { className: "ps-budget-chip" }, "cluster default")
|
|
9694
|
+
: null,
|
|
9695
|
+
defaults?.system?.provider === row.providerName
|
|
9696
|
+
? React.createElement("span", { className: "ps-budget-chip" }, "system default")
|
|
9697
|
+
: null,
|
|
9698
|
+
// Not a class mark: a hold stops every turn against this provider
|
|
9699
|
+
// right now, and a screen that hides that is lying about the row.
|
|
9700
|
+
row.hold
|
|
9701
|
+
? React.createElement("span", {
|
|
9702
|
+
className: "ps-budget-chip is-hold", title: `It ${row.hold.label}.`,
|
|
9703
|
+
}, "on hold")
|
|
9704
|
+
: null,
|
|
9705
|
+
// A fact about how the TOTAL is divided, so it appears only where
|
|
9706
|
+
// the total is on screen.
|
|
9707
|
+
row.allowanceLabel
|
|
9708
|
+
? React.createElement("span", { className: "ps-budget-grid-allow" }, row.allowanceLabel)
|
|
9709
|
+
: null,
|
|
9710
|
+
row.expandLabel
|
|
9711
|
+
? React.createElement("span", { className: "ps-budget-grid-teaser" }, row.expandLabel)
|
|
9712
|
+
: null));
|
|
9713
|
+
};
|
|
9714
|
+
|
|
9715
|
+
const bodyRow = (row) => React.createElement("tr", {
|
|
9716
|
+
key: row.key,
|
|
9717
|
+
className: `ps-budget-grid-row${row.kind === "model" ? " is-model" : ""}${row.selected ? " is-on" : ""}`,
|
|
9718
|
+
// The numbers are part of the row too. A click that started on the name
|
|
9719
|
+
// button is already handled there, so it is let through rather than
|
|
9720
|
+
// toggling the selection twice.
|
|
9721
|
+
onClick: (event) => {
|
|
9722
|
+
if (event.target?.closest?.("button")) return;
|
|
9723
|
+
select(row.providerName, row.kind === "model" ? row.scope : "*");
|
|
9724
|
+
},
|
|
9725
|
+
},
|
|
9726
|
+
nameCell(row),
|
|
9727
|
+
view.columns.map((column) => React.createElement("td", {
|
|
9728
|
+
key: column.id, className: "ps-budget-grid-num",
|
|
9729
|
+
// On a phone the column headings are gone and each number prints its
|
|
9730
|
+
// own period from this. It comes from the column rather than from the
|
|
9731
|
+
// stylesheet counting cells, so the two cannot drift apart.
|
|
9732
|
+
"data-period": column.label,
|
|
9733
|
+
title: budgetCellTitle(row, column, row.cells[column.id]),
|
|
9734
|
+
}, budgetCellEl(row.cells[column.id]))));
|
|
9735
|
+
|
|
9736
|
+
return React.createElement(React.Fragment, null,
|
|
9737
|
+
React.createElement("div", { className: "ps-budget-table-head" },
|
|
9738
|
+
React.createElement("span", { className: "ps-budget-label" },
|
|
9739
|
+
`Providers${view.providerCount > 0 ? ` · ${view.providerCount}` : ""}`),
|
|
9740
|
+
React.createElement("label", { className: "ps-budget-toggle" },
|
|
9741
|
+
React.createElement("input", {
|
|
9742
|
+
type: "checkbox", checked: view.overall,
|
|
9743
|
+
onChange: () => controller.setBudgetOverall(),
|
|
9744
|
+
}),
|
|
9745
|
+
React.createElement("span", null, view.overallLabel))),
|
|
9746
|
+
// A namespace that really is empty, reported only after a read that
|
|
9747
|
+
// SUCCEEDED. A failed read is handled above this component.
|
|
9748
|
+
view.empty
|
|
9749
|
+
? React.createElement("div", { className: "ps-budget-empty" },
|
|
9750
|
+
"No provider exists yet. Add one in Admin Console → Model Providers to start spending.")
|
|
9751
|
+
: React.createElement("table", { className: "ps-budget-grid" },
|
|
9752
|
+
React.createElement("thead", null,
|
|
9753
|
+
React.createElement("tr", null,
|
|
9754
|
+
React.createElement("th", {
|
|
9755
|
+
className: "ps-budget-grid-h is-name", rowSpan: 2, scope: "col",
|
|
9756
|
+
}, "Provider"),
|
|
9757
|
+
// Which pair of numbers the three columns hold. It is
|
|
9758
|
+
// the heading, not a footnote, because every figure
|
|
9759
|
+
// under it means something different when the tick
|
|
9760
|
+
// changes.
|
|
9761
|
+
React.createElement("th", {
|
|
9762
|
+
className: "ps-budget-grid-h is-span", colSpan: 3, scope: "colgroup",
|
|
9763
|
+
}, view.usageHeading)),
|
|
9764
|
+
React.createElement("tr", null,
|
|
9765
|
+
view.columns.map((column) => React.createElement("th", {
|
|
9766
|
+
key: column.id, className: "ps-budget-grid-h is-num", scope: "col",
|
|
9767
|
+
}, column.label)))),
|
|
9768
|
+
React.createElement("tbody", null, view.rows.map(bodyRow))));
|
|
9769
|
+
}
|
|
9770
|
+
|
|
9771
|
+
/**
|
|
9772
|
+
* The selected provider's days, with the dashed line at whatever quota the Day
|
|
9773
|
+
* cell just showed. The chart is drawn from the same number as the row, so the
|
|
9774
|
+
* two cannot disagree.
|
|
9775
|
+
*/
|
|
9776
|
+
function ProviderDayChart({ controller, view }) {
|
|
9777
|
+
const series = view.series;
|
|
9778
|
+
const row = view.selected;
|
|
9779
|
+
const paneProps = {
|
|
9780
|
+
className: "ps-budget-chart-pane",
|
|
9781
|
+
"data-range-days": String(view.rangeDays),
|
|
9782
|
+
};
|
|
9783
|
+
|
|
9784
|
+
// How many dates the x-axis can print is MEASURED, not assumed. "About
|
|
9785
|
+
// seven" is right at 1440px and wrong at 390px, where each slot is 15px,
|
|
9786
|
+
// "08-16" needs 26px, and every date came out clipped to "08-".
|
|
9787
|
+
const [axisNode, setAxisNode] = React.useState(null);
|
|
9788
|
+
const [axisWidth, setAxisWidth] = React.useState(0);
|
|
9789
|
+
React.useEffect(() => {
|
|
9790
|
+
if (!axisNode || typeof ResizeObserver === "undefined") return undefined;
|
|
9791
|
+
const measure = () => {
|
|
9792
|
+
const next = Math.round(axisNode.getBoundingClientRect().width);
|
|
9793
|
+
setAxisWidth((current) => (Math.abs(next - current) < 1 ? current : next));
|
|
9794
|
+
};
|
|
9795
|
+
const observer = new ResizeObserver(measure);
|
|
9796
|
+
observer.observe(axisNode);
|
|
9797
|
+
measure();
|
|
9798
|
+
return () => observer.disconnect();
|
|
9799
|
+
}, [axisNode]);
|
|
9800
|
+
|
|
9801
|
+
const days = series.days;
|
|
9802
|
+
const dayCount = days.length;
|
|
9803
|
+
const xTicks = React.useMemo(() => {
|
|
9804
|
+
const GAP_PX = 5; // .ps-budget-tl-x gap
|
|
9805
|
+
const DATE_PX = 30; // "08-16" plus a little air, at either axis font size
|
|
9806
|
+
const room = axisWidth - ((dayCount - 1) * GAP_PX);
|
|
9807
|
+
const fits = axisWidth > 0 ? Math.floor(room / DATE_PX) : 7;
|
|
9808
|
+
const maxTicks = Math.max(1, Math.min(7, fits));
|
|
9809
|
+
const every = Math.max(1, Math.ceil(dayCount / maxTicks));
|
|
9810
|
+
const out = new Set();
|
|
9811
|
+
// Anchored on the LAST day, so today is always named and the spacing
|
|
9812
|
+
// runs evenly back from it.
|
|
9813
|
+
for (let i = dayCount - 1; i >= 0; i -= every) out.add(i);
|
|
9814
|
+
return out;
|
|
9815
|
+
}, [axisWidth, dayCount]);
|
|
9816
|
+
|
|
9817
|
+
const head = React.createElement("div", { className: "ps-budget-chart-head" },
|
|
9818
|
+
React.createElement("span", { className: "ps-budget-chart-title" },
|
|
9819
|
+
React.createElement("strong", null, row.subject),
|
|
9820
|
+
React.createElement("span", { className: "ps-budget-sub" },
|
|
9821
|
+
` · ${view.usageHeading.toLowerCase()}, by day`)),
|
|
9822
|
+
React.createElement("div", { className: "ps-budget-chart-range" },
|
|
9823
|
+
React.createElement(Segmented, {
|
|
9824
|
+
value: view.rangeDays,
|
|
9825
|
+
options: view.rangeOptions,
|
|
9826
|
+
label: "Chart range",
|
|
9827
|
+
onChange: (days) => controller.setBudgetRangeDays(days).catch(() => {}),
|
|
9828
|
+
}),
|
|
9829
|
+
React.createElement("span", { className: "ps-budget-sub" }, series.rangeLabel)));
|
|
9830
|
+
|
|
9831
|
+
// A failed read is a failure, never an empty chart: "no usage" and "we could
|
|
9832
|
+
// not find out" are different facts and only one is safe to act on.
|
|
9833
|
+
if (series.failed) {
|
|
9834
|
+
return React.createElement("div", paneProps, head,
|
|
9835
|
+
React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
|
|
9836
|
+
React.createElement("div", null, `The daily usage for ${row.subject} could not be read.`),
|
|
9837
|
+
React.createElement("div", { className: "ps-budget-sub" }, series.error)));
|
|
9838
|
+
}
|
|
9839
|
+
if (series.loading && !series.loaded) {
|
|
9840
|
+
return React.createElement("div", paneProps, head,
|
|
9841
|
+
React.createElement("div", { className: "ps-budget-empty" }, "Reading the daily usage…"));
|
|
9842
|
+
}
|
|
9843
|
+
|
|
9844
|
+
const scaleMax = series.scaleMax;
|
|
9845
|
+
const ticks = [1, 0.75, 0.5, 0.25, 0];
|
|
9846
|
+
const tickText = fixedScaleFormatter(scaleMax);
|
|
9847
|
+
const activeDays = days.filter((day) => day.tokens > 0).length;
|
|
9848
|
+
const quotaTop = series.quotaPct == null ? null : Math.max(0, Math.min(96, 100 - series.quotaPct));
|
|
9849
|
+
|
|
9850
|
+
const axisEl = React.createElement("div", { className: "ps-budget-xaxis" },
|
|
9851
|
+
React.createElement("div", { className: "ps-budget-xaxis-spacer" }),
|
|
9852
|
+
// ONE span per column, blank ones included, so a label always sits on
|
|
9853
|
+
// its own bar.
|
|
9854
|
+
React.createElement("div", { className: "ps-budget-tl-x", ref: setAxisNode },
|
|
9855
|
+
days.map((day, index) => React.createElement("span", {
|
|
9856
|
+
key: day.dayUtc,
|
|
9857
|
+
className: xTicks.has(index) ? "is-tick" : undefined,
|
|
9858
|
+
}, xTicks.has(index) ? day.label : ""))));
|
|
9859
|
+
|
|
9860
|
+
return React.createElement("div", paneProps, head,
|
|
9861
|
+
// An administrator reading their own share sees a chart of two
|
|
9862
|
+
// different subjects. Say which is which, or one reads as the other.
|
|
9863
|
+
series.mixedScope ? React.createElement("div", { className: "ps-budget-note is-warn" },
|
|
9864
|
+
"The bars are what the whole fleet spent on this provider each day. "
|
|
9865
|
+
+ "The dashed line is YOUR share of the daily limit, not the limit itself. "
|
|
9866
|
+
+ "Tick “Show overall usage” to read both as the provider's.") : null,
|
|
9867
|
+
series.stale ? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
|
|
9868
|
+
React.createElement("div", null, "These figures are from an earlier read. The most recent read failed."),
|
|
9869
|
+
React.createElement("div", { className: "ps-budget-sub" }, series.error)) : null,
|
|
9870
|
+
React.createElement("div", { className: "ps-budget-chart" },
|
|
9871
|
+
React.createElement("div", { className: "ps-budget-yaxis-title" }, "tokens"),
|
|
9872
|
+
React.createElement("div", { className: "ps-budget-plotwrap" },
|
|
9873
|
+
React.createElement("div", { className: "ps-budget-yaxis" },
|
|
9874
|
+
// A range with nothing in it and no limit gets no ruler:
|
|
9875
|
+
// every tick on it would be a number nobody spent.
|
|
9876
|
+
scaleMax > 0
|
|
9877
|
+
? ticks.map((frac) => React.createElement("div", {
|
|
9878
|
+
key: `t${frac}`, className: "ps-budget-ytick", style: { top: `${(1 - frac) * 100}%` },
|
|
9879
|
+
}, tickText(scaleMax * frac)))
|
|
9880
|
+
: null),
|
|
9881
|
+
React.createElement("div", { className: "ps-budget-plot" },
|
|
9882
|
+
scaleMax > 0
|
|
9883
|
+
? ticks.map((frac) => React.createElement("div", {
|
|
9884
|
+
key: `g${frac}`, className: "ps-budget-gridline", style: { top: `${(1 - frac) * 100}%` },
|
|
9885
|
+
}))
|
|
9886
|
+
: null,
|
|
9887
|
+
// The dashed line IS the Day cell's quota, the same object
|
|
9888
|
+
// the row printed. There is nothing to draw when the day is
|
|
9889
|
+
// uncapped.
|
|
9890
|
+
quotaTop == null ? null : React.createElement("div", {
|
|
9891
|
+
className: "ps-budget-tl-median", style: { top: `${quotaTop}%` },
|
|
9892
|
+
}, React.createElement("span", null, `the Day limit · ${series.quotaLabel} a day`)),
|
|
9893
|
+
React.createElement("div", { className: "ps-budget-bars" },
|
|
9894
|
+
days.map((day) => React.createElement("div", {
|
|
9895
|
+
key: day.dayUtc,
|
|
9896
|
+
className: `ps-budget-tl-col${day.tokens === 0 ? " is-idle" : ""}`
|
|
9897
|
+
+ `${series.quotaTokens != null && day.tokens > series.quotaTokens ? " is-over" : ""}`,
|
|
9898
|
+
title: `${day.label} · ${day.tokens
|
|
9899
|
+
? `${day.tokens.toLocaleString("en-US")} tokens · ${budgetPlural(day.turns, "turn", "turns")}`
|
|
9900
|
+
: "no usage"}`,
|
|
9901
|
+
},
|
|
9902
|
+
React.createElement("div", {
|
|
9903
|
+
className: "ps-budget-tl-bar",
|
|
9904
|
+
// An idle day keeps a hairline so the column is
|
|
9905
|
+
// visibly a day that happened, not a gap.
|
|
9906
|
+
style: { height: day.tokens === 0 ? "2px" : `${Math.max(1, day.pct)}%` },
|
|
9907
|
+
})))),
|
|
9908
|
+
series.empty
|
|
9909
|
+
? React.createElement("div", { className: "ps-budget-noaxis" },
|
|
9910
|
+
`No token usage on ${row.subject} in this range.`)
|
|
9911
|
+
: null)),
|
|
9912
|
+
axisEl,
|
|
9913
|
+
React.createElement("div", { className: "ps-budget-xaxis-title" },
|
|
9914
|
+
`Daily totals · UTC · ${series.rangeLabel.toLowerCase()}`),
|
|
9915
|
+
React.createElement("div", { className: "ps-budget-legend" },
|
|
9916
|
+
React.createElement("span", null, `Peak ${series.peakLabel} tokens/day`),
|
|
9917
|
+
React.createElement("span", null, `${activeDays} of ${dayCount} days used`),
|
|
9918
|
+
series.quotaTokens != null
|
|
9919
|
+
? React.createElement("span", null, `Dashed line: ${series.quotaLabel} a day`)
|
|
9920
|
+
: React.createElement("span", null, "No daily limit, so no line"))));
|
|
9921
|
+
}
|
|
9922
|
+
|
|
9923
|
+
function ProviderSystemSpend({ view }) {
|
|
9924
|
+
const spend = view.systemSpend;
|
|
9925
|
+
if (!spend) return null;
|
|
9926
|
+
const modelLabel = (label) => String(label || "").startsWith(`${view.selectedProvider}:`)
|
|
9927
|
+
? String(label).slice(view.selectedProvider.length + 1)
|
|
9928
|
+
: String(label || "Unknown model");
|
|
9929
|
+
return React.createElement("section", { className: "ps-budget-system-spend", "aria-label": "System spend" },
|
|
9930
|
+
React.createElement("div", { className: "ps-budget-chart-head" },
|
|
9931
|
+
React.createElement("span", { className: "ps-budget-chart-title" },
|
|
9932
|
+
React.createElement("strong", null, "System spend")),
|
|
9933
|
+
React.createElement("span", { className: "ps-budget-sub" }, spend.rangeLabel)),
|
|
9934
|
+
spend.loading && !spend.loaded
|
|
9935
|
+
? React.createElement("div", { className: "ps-budget-empty" }, "Reading system spend…")
|
|
9936
|
+
: null,
|
|
9937
|
+
spend.error
|
|
9938
|
+
? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" }, spend.error)
|
|
9939
|
+
: null,
|
|
9940
|
+
spend.loaded ? React.createElement(React.Fragment, null,
|
|
9941
|
+
React.createElement("div", { className: "ps-budget-system-total" },
|
|
9942
|
+
React.createElement("strong", null, `${spend.tokensLabel} tokens`),
|
|
9943
|
+
React.createElement("span", { className: "ps-budget-sub" }, ` · ${budgetPlural(spend.turns, "turn", "turns")}`)),
|
|
9944
|
+
spend.models.length
|
|
9945
|
+
? React.createElement("div", { className: "ps-budget-system-models" },
|
|
9946
|
+
spend.models.map((model) => React.createElement("div", { key: model.key, className: "ps-budget-system-model" },
|
|
9947
|
+
React.createElement("span", null, modelLabel(model.label)),
|
|
9948
|
+
React.createElement("strong", null, `${model.tokensLabel} · ${budgetPlural(model.turns, "turn", "turns")}`))))
|
|
9949
|
+
: React.createElement("div", { className: "ps-budget-sub" }, "No system spend in this range.")) : null);
|
|
9950
|
+
}
|
|
9951
|
+
|
|
9952
|
+
/**
|
|
9953
|
+
* Everything one provider can be changed to: its limits, its allowance, its
|
|
9954
|
+
* hold, and whether it is a default.
|
|
9955
|
+
*
|
|
9956
|
+
* One sheet, because they are all edits to the row that is already named at the
|
|
9957
|
+
* top of it. Each section states what saving will do before the confirm is
|
|
9958
|
+
* reachable.
|
|
9959
|
+
*/
|
|
9960
|
+
function EditProviderSheet({
|
|
9961
|
+
row, rawRows, models, isAdmin, busy, error, onCancel, onRun,
|
|
9962
|
+
initialPeriod = "day", initialScope = "*",
|
|
9963
|
+
}) {
|
|
9964
|
+
const shared = row.class === "shared";
|
|
9965
|
+
// A limit is a CHANGE, and the database refuses it from anyone who is not
|
|
9966
|
+
// an admin on a shared provider or the owner of a personal one. Offering
|
|
9967
|
+
// the section anyway meant filling in a form, pressing the button and
|
|
9968
|
+
// only then being told — so it is gated the way Allowance and Hold
|
|
9969
|
+
// already were.
|
|
9970
|
+
const mayManage = row.manageable === true;
|
|
9971
|
+
const sections = [
|
|
9972
|
+
...(mayManage ? [{ value: "limit", label: "Limits" }] : []),
|
|
9973
|
+
...(mayManage && isAdmin && shared ? [{ value: "allowance", label: "Allowance" }] : []),
|
|
9974
|
+
...(mayManage && isAdmin ? [{ value: "hold", label: "Hold" }] : []),
|
|
9975
|
+
];
|
|
9976
|
+
const [section, setSection] = React.useState("limit");
|
|
9977
|
+
|
|
9978
|
+
// ── the limit being edited ─────────────────────────────────────────
|
|
9979
|
+
// Opened on the row the reader was standing on: a per-model limit row
|
|
9980
|
+
// means they were looking at THAT limit, and landing them on "Daily · all
|
|
9981
|
+
// models" makes them re-find it.
|
|
9982
|
+
const [period, setPeriod] = React.useState(initialPeriod);
|
|
9983
|
+
const [scopeKind, setScopeKind] = React.useState(initialScope === "*" ? "all" : "model");
|
|
9984
|
+
const [model, setModel] = React.useState(initialScope === "*" ? "" : initialScope);
|
|
9985
|
+
const [tokens, setTokens] = React.useState("");
|
|
9986
|
+
React.useEffect(() => {
|
|
9987
|
+
if (!model && models[0]) setModel(models[0].qualifiedName);
|
|
9988
|
+
}, [models, model]);
|
|
9989
|
+
|
|
9990
|
+
const providerRaw = rawRows.find((raw) => raw.providerName === row.providerName && raw.rowKind !== "model") || null;
|
|
9991
|
+
const chosenModel = scopeKind === "model" ? (model || models[0]?.qualifiedName || "") : null;
|
|
9992
|
+
const targetRaw = scopeKind === "model"
|
|
9993
|
+
? rawRows.find((raw) => raw.providerName === row.providerName && raw.rowKind === "model" && raw.scope === chosenModel) || null
|
|
9994
|
+
: providerRaw;
|
|
9995
|
+
// These are the PROVIDER's figures, never the viewer's share: a limit caps
|
|
9996
|
+
// everyone, so the sentence about it has to be about everyone.
|
|
9997
|
+
const targetCell = targetRaw?.periods?.[period] || null;
|
|
9998
|
+
const existingTokens = targetCell?.quotaTokens ?? null;
|
|
9999
|
+
// NULL is "not known", and it is not zero. A model row exists in the grid
|
|
10000
|
+
// only once that model HAS a limit, so the first limit on a model had no
|
|
10001
|
+
// row to read and reported 0 spent — which withheld the "this blocks now"
|
|
10002
|
+
// warning at exactly the moment it mattered and paused people who had
|
|
10003
|
+
// been told the period was empty.
|
|
10004
|
+
const spentTokens = targetRaw ? (Number(targetCell?.usedTokens) || 0) : null;
|
|
10005
|
+
const spendKnown = spentTokens != null;
|
|
10006
|
+
const resetsLabel = row.cells[period]?.resetsLabel || null;
|
|
10007
|
+
const periodLabel = BUDGET_PERIOD_TITLES[period] || period;
|
|
10008
|
+
const scopeLabel = chosenModel || "all models";
|
|
10009
|
+
|
|
10010
|
+
// The field follows the (period, scope) being pointed at, so it always
|
|
10011
|
+
// shows the limit that is actually there rather than the last one typed.
|
|
10012
|
+
React.useEffect(() => {
|
|
10013
|
+
setTokens(existingTokens == null ? "" : Number(existingTokens).toLocaleString("en-US"));
|
|
10014
|
+
}, [existingTokens, period, chosenModel]);
|
|
10015
|
+
|
|
10016
|
+
const parsed = parseTokenCount(tokens);
|
|
10017
|
+
const limitValid = Boolean(parsed) && (scopeKind !== "model" || Boolean(chosenModel));
|
|
10018
|
+
const replaces = existingTokens != null;
|
|
10019
|
+
// An allowance divides every limit, so what actually stops a person is
|
|
10020
|
+
// their SHARE of the new number, not the number. Comparing against the
|
|
10021
|
+
// raw limit said "nobody is blocked" while saving paused everyone whose
|
|
10022
|
+
// own spend was already past their slice of it.
|
|
10023
|
+
const allowancePctNow = Number.isFinite(row.allowancePct) ? row.allowancePct : 100;
|
|
10024
|
+
const newCeiling = parsed == null || allowancePctNow >= 100
|
|
10025
|
+
? parsed
|
|
10026
|
+
: Math.max(1, Math.floor((parsed * allowancePctNow) / 100));
|
|
10027
|
+
const yourSpent = Number(targetCell?.yourUsedTokens);
|
|
10028
|
+
const wouldPause = spendKnown && parsed !== null && spentTokens >= parsed;
|
|
10029
|
+
const wouldPauseSomeone = !wouldPause && parsed !== null && newCeiling !== parsed
|
|
10030
|
+
&& Number.isFinite(yourSpent) && yourSpent >= newCeiling;
|
|
10031
|
+
|
|
10032
|
+
// ── the allowance ──────────────────────────────────────────────────
|
|
10033
|
+
const currentPct = Number.isFinite(row.allowancePct) ? row.allowancePct : 100;
|
|
10034
|
+
const [pctRaw, setPctRaw] = React.useState(String(currentPct));
|
|
10035
|
+
const pct = Number(String(pctRaw).trim());
|
|
10036
|
+
const pctValid = Number.isFinite(pct) && pct >= 1 && pct <= 100 && Math.floor(pct) === pct;
|
|
10037
|
+
const providerLimits = ["day", "week", "month"]
|
|
10038
|
+
.map((id) => ({ id, label: BUDGET_PERIOD_TITLES[id], tokens: providerRaw?.periods?.[id]?.quotaTokens ?? null }))
|
|
10039
|
+
.filter((entry) => entry.tokens != null);
|
|
10040
|
+
|
|
10041
|
+
// ── the hold ───────────────────────────────────────────────────────
|
|
10042
|
+
const held = Boolean(row.hold);
|
|
10043
|
+
const [holdMode, setHoldMode] = React.useState(held ? "release" : "forever");
|
|
10044
|
+
const [until, setUntil] = React.useState("");
|
|
10045
|
+
// A datetime-local value is wall-clock with no zone. Read it as local time
|
|
10046
|
+
// and send the instant, so a hold set for 18:00 ends at the reader's 18:00.
|
|
10047
|
+
const untilIso = (() => {
|
|
10048
|
+
if (holdMode !== "until" || !until) return null;
|
|
10049
|
+
const at = new Date(until);
|
|
10050
|
+
return Number.isNaN(at.getTime()) ? null : at.toISOString();
|
|
10051
|
+
})();
|
|
10052
|
+
// An instant that has already passed stops nothing. The server accepts it
|
|
10053
|
+
// and stores it, so nothing downstream catches it — and the screen then
|
|
10054
|
+
// confirmed in words that the provider was stopped when it was not.
|
|
10055
|
+
const untilIsPast = Boolean(untilIso) && Date.parse(untilIso) <= Date.now();
|
|
10056
|
+
const holdValid = holdMode !== "until" || (Boolean(untilIso) && !untilIsPast);
|
|
10057
|
+
const holdHint = holdMode !== "until" ? null
|
|
10058
|
+
: untilIsPast ? "That time has already passed. A hold has to end in the future."
|
|
10059
|
+
: (until && !untilIso ? "That is not a time this can read." : null);
|
|
10060
|
+
|
|
10061
|
+
const titles = { limit: "Limits", allowance: "Allowance", hold: "Hold" };
|
|
10062
|
+
const confirms = {
|
|
10063
|
+
limit: replaces ? "Replace limit" : "Save limit",
|
|
10064
|
+
allowance: "Save allowance",
|
|
10065
|
+
hold: holdMode === "release" ? "Release hold" : "Place hold",
|
|
10066
|
+
};
|
|
10067
|
+
const foots = {
|
|
10068
|
+
limit: "Limits are enforced.",
|
|
10069
|
+
allowance: "Applies to everyone.",
|
|
10070
|
+
hold: "Stops new turns.",
|
|
10071
|
+
};
|
|
10072
|
+
const valid = section === "limit" ? limitValid
|
|
10073
|
+
: section === "allowance" ? pctValid
|
|
10074
|
+
: section === "hold" ? holdValid : true;
|
|
10075
|
+
|
|
10076
|
+
const submit = () => {
|
|
10077
|
+
if (section === "limit") {
|
|
10078
|
+
if (limitValid) onRun("limit", { period, model: chosenModel, tokens: parsed });
|
|
10079
|
+
} else if (section === "allowance") {
|
|
10080
|
+
if (pctValid) onRun("allowance", { pct });
|
|
10081
|
+
} else if (section === "hold") {
|
|
10082
|
+
onRun("hold", holdMode === "release"
|
|
10083
|
+
? { release: true, untilUtc: null }
|
|
10084
|
+
: { release: false, untilUtc: untilIso });
|
|
10085
|
+
}
|
|
10086
|
+
};
|
|
10087
|
+
|
|
10088
|
+
const limitBody = React.createElement(React.Fragment, null,
|
|
10089
|
+
React.createElement(SheetField, {
|
|
10090
|
+
label: "Time period",
|
|
10091
|
+
hint: "One limit per period and scope.",
|
|
10092
|
+
},
|
|
10093
|
+
React.createElement(Segmented, {
|
|
10094
|
+
value: period, onChange: setPeriod, options: BUDGET_PERIOD_OPTIONS, label: "Time period",
|
|
10095
|
+
})),
|
|
10096
|
+
React.createElement(SheetField, { label: "Applies to" },
|
|
10097
|
+
React.createElement(Segmented, {
|
|
10098
|
+
value: scopeKind, onChange: setScopeKind, label: "Applies to",
|
|
10099
|
+
options: [{ value: "all", label: "all models" }, { value: "model", label: "one model" }],
|
|
10100
|
+
}),
|
|
10101
|
+
scopeKind === "model" ? React.createElement("select", {
|
|
10102
|
+
className: "ps-budget-input", "aria-label": "Model this limit applies to",
|
|
10103
|
+
value: model, onChange: (event) => setModel(event.target.value),
|
|
10104
|
+
}, models.length === 0
|
|
10105
|
+
? React.createElement("option", { value: "" }, "No models are listed for this provider's type")
|
|
10106
|
+
: models.map((option) => React.createElement("option", {
|
|
10107
|
+
key: option.qualifiedName, value: option.qualifiedName,
|
|
10108
|
+
}, option.qualifiedName))) : null),
|
|
10109
|
+
React.createElement(SheetField, {
|
|
10110
|
+
label: "Tokens",
|
|
10111
|
+
hint: parsed
|
|
10112
|
+
? `= ${parsed.toLocaleString("en-US")} tokens.`
|
|
10113
|
+
: tokens.trim()
|
|
10114
|
+
? "A whole number of tokens: 20,000,000 or 20M."
|
|
10115
|
+
: "Remove the limit to uncap.",
|
|
10116
|
+
},
|
|
10117
|
+
React.createElement("input", {
|
|
10118
|
+
className: `ps-budget-input${tokens && !parsed ? " is-invalid" : ""}`, value: tokens,
|
|
10119
|
+
"aria-label": "Tokens",
|
|
10120
|
+
// No inputMode="numeric": the field accepts "20M", and a numeric
|
|
10121
|
+
// keypad cannot type the M.
|
|
10122
|
+
placeholder: "20,000,000 or 20M",
|
|
10123
|
+
onChange: (event) => setTokens(event.target.value),
|
|
10124
|
+
onKeyDown: (event) => { if (event.key === "Enter") submit(); },
|
|
10125
|
+
})),
|
|
10126
|
+
// What this period has already spent, said out loud rather than alluded
|
|
10127
|
+
// to. It is the provider's own figure — the one the limit measures.
|
|
10128
|
+
React.createElement("div", { className: "ps-budget-consequences" },
|
|
10129
|
+
React.createElement("div", null,
|
|
10130
|
+
`${periodLabel} · ${scopeLabel} — `,
|
|
10131
|
+
spendKnown
|
|
10132
|
+
? React.createElement(React.Fragment, null,
|
|
10133
|
+
React.createElement("strong", null, `${spentTokens.toLocaleString("en-US")} tokens`),
|
|
10134
|
+
shared ? " spent by users this period." : " spent this period.")
|
|
10135
|
+
// Said plainly rather than guessed at zero: this period may
|
|
10136
|
+
// already be spent past the number about to be saved.
|
|
10137
|
+
: React.createElement(React.Fragment, null,
|
|
10138
|
+
React.createElement("strong", null, "not measured yet"),
|
|
10139
|
+
". Current spend will count.")),
|
|
10140
|
+
resetsLabel ? React.createElement("div", { className: "ps-budget-sub" },
|
|
10141
|
+
`Resets ${resetsLabel}.`) : null),
|
|
10142
|
+
replaces ? React.createElement("div", { className: "ps-budget-note is-warn" },
|
|
10143
|
+
`Replaces the existing ${formatCompactNumber(existingTokens)} token limit.`) : null,
|
|
10144
|
+
React.createElement("div", { className: `ps-budget-note${wouldPause || wouldPauseSomeone ? " is-warn" : ""}` },
|
|
10145
|
+
"Current spend counts. Saving does not reset usage.",
|
|
10146
|
+
wouldPause
|
|
10147
|
+
? ` ${spentTokens.toLocaleString("en-US")} already meets ${parsed.toLocaleString("en-US")}, so sessions on ${row.providerName} pause on their next turn${resetsLabel ? ` — until this period ${budgetResetPhrase(resetsLabel)}` : ""}.`
|
|
10148
|
+
: wouldPauseSomeone
|
|
10149
|
+
// The provider has room and a PERSON does not. With an
|
|
10150
|
+
// allowance that is the common case, and the limit alone
|
|
10151
|
+
// never showed it.
|
|
10152
|
+
? ` At ${allowancePctNow}% each that is ${newCeiling.toLocaleString("en-US")} per person, which you have already spent — so sessions pause on their next turn${resetsLabel ? ` — until this period ${budgetResetPhrase(resetsLabel)}` : ""}.`
|
|
10153
|
+
: ""),
|
|
10154
|
+
replaces ? React.createElement("div", null,
|
|
10155
|
+
React.createElement("button", {
|
|
10156
|
+
type: "button", className: "ps-mini-button is-danger", disabled: busy,
|
|
10157
|
+
onClick: () => onRun("removeLimit", { period, model: chosenModel }),
|
|
10158
|
+
}, `Remove the ${periodLabel} · ${scopeLabel} limit`),
|
|
10159
|
+
React.createElement("div", { className: "ps-budget-note" },
|
|
10160
|
+
"Usage history is kept.")) : null);
|
|
10161
|
+
|
|
10162
|
+
const allowanceBody = React.createElement(React.Fragment, null,
|
|
10163
|
+
React.createElement(SheetField, {
|
|
10164
|
+
label: "Share per person",
|
|
10165
|
+
hint: pctValid
|
|
10166
|
+
? (pct >= 100
|
|
10167
|
+
? "100% removes the per-person ceiling."
|
|
10168
|
+
: `Each person may use up to ${pct}% of each limit, per period.`)
|
|
10169
|
+
: "A whole number from 1 to 100.",
|
|
10170
|
+
},
|
|
10171
|
+
React.createElement("input", {
|
|
10172
|
+
className: `ps-budget-input${pctRaw && !pctValid ? " is-invalid" : ""}`, value: pctRaw,
|
|
10173
|
+
// Its own name. A group label is not reliably announced when focus
|
|
10174
|
+
// lands straight on the field, and "edit text, blank" is not a field.
|
|
10175
|
+
"aria-label": "Share per person, as a percentage",
|
|
10176
|
+
inputMode: "numeric", placeholder: "20",
|
|
10177
|
+
onChange: (event) => setPctRaw(event.target.value),
|
|
10178
|
+
onKeyDown: (event) => { if (event.key === "Enter" && pctValid) submit(); },
|
|
10179
|
+
})),
|
|
10180
|
+
React.createElement("div", { className: "ps-budget-consequences" },
|
|
10181
|
+
providerLimits.length === 0
|
|
10182
|
+
? React.createElement("div", { className: "ps-budget-note is-warn" },
|
|
10183
|
+
"No limits yet. The allowance applies when one is added.")
|
|
10184
|
+
: React.createElement(React.Fragment, null,
|
|
10185
|
+
providerLimits.map((entry) => React.createElement("div", { key: entry.id },
|
|
10186
|
+
`${entry.label} · ${formatCompactNumber(entry.tokens)} → `,
|
|
10187
|
+
React.createElement("strong", null,
|
|
10188
|
+
pctValid ? `${formatCompactNumber(Math.floor(entry.tokens * pct / 100))} per person` : "—"))),
|
|
10189
|
+
React.createElement("div", { className: "ps-budget-note" },
|
|
10190
|
+
"The provider limit still caps total spend.")),
|
|
10191
|
+
pctValid && pct < currentPct ? React.createElement("div", { className: "ps-budget-note is-warn" },
|
|
10192
|
+
"Users already past the new ceiling pause on their next turn.") : null));
|
|
10193
|
+
|
|
10194
|
+
const holdBody = React.createElement(React.Fragment, null,
|
|
10195
|
+
React.createElement(SheetField, { label: "What to do" },
|
|
10196
|
+
React.createElement(Segmented, {
|
|
10197
|
+
value: holdMode, onChange: setHoldMode, label: "What to do",
|
|
10198
|
+
options: [
|
|
10199
|
+
...(held ? [{ value: "release", label: "release it" }] : []),
|
|
10200
|
+
{ value: "forever", label: "hold until released" },
|
|
10201
|
+
{ value: "until", label: "hold until a time" },
|
|
10202
|
+
],
|
|
10203
|
+
})),
|
|
10204
|
+
holdMode === "until" ? React.createElement(SheetField, {
|
|
10205
|
+
label: "Ends at",
|
|
10206
|
+
hint: holdHint || "Sessions resume at this time.",
|
|
10207
|
+
},
|
|
10208
|
+
React.createElement("input", {
|
|
10209
|
+
className: `ps-budget-input${until && !holdValid ? " is-invalid" : ""}`,
|
|
10210
|
+
"aria-label": "The hold ends at",
|
|
10211
|
+
type: "datetime-local", value: until,
|
|
10212
|
+
onChange: (event) => setUntil(event.target.value),
|
|
10213
|
+
})) : null,
|
|
10214
|
+
held ? React.createElement("div", { className: "ps-budget-consequences" },
|
|
10215
|
+
React.createElement("div", null, `This provider is on hold: it ${row.hold.label}.`)) : null,
|
|
10216
|
+
React.createElement("div", { className: `ps-budget-note${holdMode === "release" ? "" : " is-warn"}` },
|
|
10217
|
+
holdMode === "release"
|
|
10218
|
+
? `Releasing wakes every session waiting on ${row.providerName}. A session still over a limit stays paused for that reason instead.`
|
|
10219
|
+
: `Every session on ${row.providerName} pauses on its next turn. Messages sent to them are kept and answered when the hold lifts.`));
|
|
10220
|
+
|
|
10221
|
+
return React.createElement(BudgetSheet, {
|
|
10222
|
+
title: titles[section], subtitle: `${row.providerName}${shared ? " · shared" : ""}`,
|
|
10223
|
+
confirmLabel: confirms[section] || "",
|
|
10224
|
+
danger: section === "hold" && holdMode === "release",
|
|
10225
|
+
busy, disabled: !valid, error, onCancel,
|
|
10226
|
+
onConfirm: confirms[section] ? submit : null,
|
|
10227
|
+
cancelLabel: confirms[section] ? "Cancel" : "Done",
|
|
10228
|
+
footNote: foots[section],
|
|
10229
|
+
},
|
|
10230
|
+
sections.length > 1 ? React.createElement(SheetField, { label: "What to change" },
|
|
10231
|
+
React.createElement(Segmented, {
|
|
10232
|
+
value: section, onChange: setSection, options: sections, label: "What to change",
|
|
10233
|
+
})) : null,
|
|
10234
|
+
section === "limit" ? limitBody : null,
|
|
10235
|
+
section === "allowance" ? allowanceBody : null,
|
|
10236
|
+
section === "hold" ? holdBody : null);
|
|
10237
|
+
}
|
|
10238
|
+
|
|
10239
|
+
/**
|
|
10240
|
+
* Add a provider. The name is permanent — sessions refer to it — and the
|
|
10241
|
+
* credential is the key itself, never a pointer to one.
|
|
10242
|
+
*/
|
|
10243
|
+
function CreateProviderSheet({
|
|
10244
|
+
types, existingNames, isAdmin, busy, error, onCancel, onConfirm,
|
|
10245
|
+
initialName = "", initialShared = false, initialType = "",
|
|
10246
|
+
}) {
|
|
10247
|
+
const [name, setName] = React.useState(initialName);
|
|
10248
|
+
const [type, setType] = React.useState(initialType || types[0]?.id || "");
|
|
10249
|
+
const [credential, setCredential] = React.useState("");
|
|
10250
|
+
const [baseUrl, setBaseUrl] = React.useState("");
|
|
10251
|
+
const [shared, setShared] = React.useState(isAdmin && initialShared);
|
|
10252
|
+
const ref = React.useRef(null);
|
|
10253
|
+
React.useEffect(() => { ref.current?.focus(); }, []);
|
|
10254
|
+
React.useEffect(() => { if (!type && types[0]) setType(types[0].id); }, [types, type]);
|
|
10255
|
+
|
|
10256
|
+
const trimmed = name.trim();
|
|
10257
|
+
// A name is unique across the cluster and immutable, so a clash is
|
|
10258
|
+
// permanent. The database refuses it too; refusing here saves the trip.
|
|
10259
|
+
const taken = existingNames.some((existing) => existing.toLowerCase() === trimmed.toLowerCase());
|
|
10260
|
+
// MAJOR 16: the database's own check constraint allows only these, and it
|
|
10261
|
+
// reports every violation with one fixed sentence — so a name refused for
|
|
10262
|
+
// its LENGTH was told to use letters and numbers, which it already did.
|
|
10263
|
+
// Bounded and named here instead, where the rule can be said precisely.
|
|
10264
|
+
const badChars = trimmed !== "" && !/^[A-Za-z0-9._-]+$/.test(trimmed);
|
|
10265
|
+
const tooLong = trimmed.length > 64;
|
|
10266
|
+
const valid = Boolean(trimmed) && !taken && !badChars && !tooLong
|
|
10267
|
+
&& Boolean(type) && Boolean(credential.trim());
|
|
10268
|
+
const isGithub = /github/i.test(type);
|
|
10269
|
+
const submit = () => {
|
|
10270
|
+
if (!valid) return;
|
|
10271
|
+
const input = {
|
|
10272
|
+
name: trimmed, type, shared,
|
|
10273
|
+
credentials: isGithub ? { githubToken: credential.trim() } : { apiKey: credential.trim() },
|
|
10274
|
+
baseUrl: baseUrl.trim() || null,
|
|
10275
|
+
};
|
|
10276
|
+
setCredential("");
|
|
10277
|
+
onConfirm(input);
|
|
10278
|
+
};
|
|
10279
|
+
const cancel = () => {
|
|
10280
|
+
setCredential("");
|
|
10281
|
+
onCancel();
|
|
10282
|
+
};
|
|
10283
|
+
|
|
10284
|
+
return React.createElement(BudgetSheet, {
|
|
10285
|
+
title: isGithub ? "Add GitHub Copilot provider" : "Add model provider",
|
|
10286
|
+
subtitle: shared ? "Shared" : "Personal",
|
|
10287
|
+
confirmLabel: "Add provider", busy, disabled: !valid, error, onCancel: cancel, onConfirm: submit,
|
|
10288
|
+
footNote: "",
|
|
10289
|
+
},
|
|
10290
|
+
React.createElement(SheetField, {
|
|
10291
|
+
label: "Name",
|
|
10292
|
+
hint: taken ? "A provider with that name already exists, and names are unique across the cluster."
|
|
10293
|
+
: tooLong ? `Too long: ${trimmed.length} characters, and a name may be at most 64.`
|
|
10294
|
+
: badChars ? "Letters, numbers, dot, dash and underscore only — no colon, no spaces, no accents: a session refers to it as \"<provider>:<model>\"."
|
|
10295
|
+
: "Permanent. Used in provider:model.",
|
|
10296
|
+
},
|
|
10297
|
+
React.createElement("input", {
|
|
10298
|
+
ref, className: `ps-budget-input${(taken || badChars || tooLong) && trimmed ? " is-invalid" : ""}`,
|
|
10299
|
+
"aria-label": "Provider name",
|
|
10300
|
+
value: name, placeholder: "azure-prod",
|
|
10301
|
+
onChange: (event) => setName(event.target.value),
|
|
10302
|
+
onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
|
|
10303
|
+
})),
|
|
10304
|
+
React.createElement(SheetField, {
|
|
10305
|
+
label: "Type",
|
|
10306
|
+
hint: "Backend and model catalog.",
|
|
10307
|
+
},
|
|
10308
|
+
React.createElement("select", {
|
|
10309
|
+
className: "ps-budget-input", "aria-label": "Provider type",
|
|
10310
|
+
value: type, onChange: (event) => setType(event.target.value),
|
|
10311
|
+
}, types.length === 0
|
|
10312
|
+
? React.createElement("option", { value: "" }, "No provider types are listed on this deployment")
|
|
10313
|
+
: types.map((option) => React.createElement("option", { key: option.id, value: option.id }, option.id)))),
|
|
10314
|
+
React.createElement(SheetField, {
|
|
10315
|
+
label: isGithub ? "GitHub Copilot token" : "API key",
|
|
10316
|
+
hint: "Paste the token value.",
|
|
10317
|
+
},
|
|
10318
|
+
React.createElement("input", {
|
|
10319
|
+
className: "ps-budget-input", type: "password", autoComplete: "off",
|
|
10320
|
+
"aria-label": isGithub ? "GitHub Copilot token" : "API key",
|
|
10321
|
+
value: credential, placeholder: "Paste token",
|
|
10322
|
+
onChange: (event) => setCredential(event.target.value),
|
|
10323
|
+
})),
|
|
10324
|
+
React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
|
|
10325
|
+
React.createElement("input", {
|
|
10326
|
+
className: "ps-budget-input", value: baseUrl, placeholder: "https://…",
|
|
10327
|
+
"aria-label": "Base URL",
|
|
10328
|
+
onChange: (event) => setBaseUrl(event.target.value),
|
|
10329
|
+
})),
|
|
10330
|
+
isAdmin ? React.createElement(SheetField, { label: "Access" },
|
|
10331
|
+
React.createElement(Segmented, {
|
|
10332
|
+
value: shared ? "shared" : "mine", onChange: (value) => setShared(value === "shared"),
|
|
10333
|
+
label: "Access",
|
|
10334
|
+
options: [{ value: "shared", label: "Shared" }, { value: "mine", label: "Only me" }],
|
|
10335
|
+
})) : null);
|
|
10336
|
+
}
|
|
10337
|
+
|
|
10338
|
+
/** Delete a provider. Its sessions are not moved anywhere — they wait. */
|
|
10339
|
+
function DeleteProviderSheet({ row, limitCount, defaults, busy, error, onCancel, onConfirm }) {
|
|
10340
|
+
const isClusterDefault = defaults?.cluster?.provider === row.providerName;
|
|
10341
|
+
const isMyDefault = defaults?.mine?.provider === row.providerName;
|
|
10342
|
+
return React.createElement(BudgetSheet, {
|
|
10343
|
+
title: `Delete ${row.providerName}?`,
|
|
10344
|
+
subtitle: row.class === "shared" ? "Shared provider"
|
|
10345
|
+
: (row.ownedByMe === false ? "User provider · owned by another user" : "User provider"),
|
|
10346
|
+
confirmLabel: "Delete provider", danger: true, busy, error, onCancel, onConfirm,
|
|
10347
|
+
// Not "undoes this": re-creating the name only lets the waiting
|
|
10348
|
+
// sessions resolve again, against a fresh budget. The limits do not
|
|
10349
|
+
// come back.
|
|
10350
|
+
footNote: "Creating the name again lets waiting sessions resume, on a fresh budget.",
|
|
10351
|
+
},
|
|
10352
|
+
// One line per consequence. Each is a fact somebody could be surprised by;
|
|
10353
|
+
// none of them needs a paragraph.
|
|
10354
|
+
React.createElement("div", { className: "ps-budget-consequences" },
|
|
10355
|
+
React.createElement("div", null,
|
|
10356
|
+
"Sessions on it ", React.createElement("strong", null, "wait indefinitely"), "."),
|
|
10357
|
+
limitCount > 0 ? React.createElement("div", null,
|
|
10358
|
+
// budgetPlural already prints the count. Prefixing it printed
|
|
10359
|
+
// "2 2 limits".
|
|
10360
|
+
React.createElement("strong", null, budgetPlural(limitCount, "limit", "limits")),
|
|
10361
|
+
" deleted with it, and they do not come back.") : null,
|
|
10362
|
+
isClusterDefault ? React.createElement("div", null,
|
|
10363
|
+
React.createElement("strong", null, "It is the cluster default."),
|
|
10364
|
+
" The cluster is left with none.") : null,
|
|
10365
|
+
isMyDefault ? React.createElement("div", null,
|
|
10366
|
+
React.createElement("strong", null, "It is your default."),
|
|
10367
|
+
" Yours is cleared.") : null,
|
|
10368
|
+
React.createElement("div", { className: "ps-budget-sub" }, "Usage history is retained under this name.")));
|
|
10369
|
+
}
|
|
10370
|
+
|
|
10371
|
+
/**
|
|
10372
|
+
* What the surface needs out of the store. selectProviderTable builds fresh
|
|
10373
|
+
* arrays on every call, so subscribing to it directly would re-render on every
|
|
10374
|
+
* unrelated dispatch — and a streaming session dispatches per token. These are
|
|
10375
|
+
* the raw references it actually reads, compared by identity.
|
|
10376
|
+
*/
|
|
10377
|
+
function budgetViewDeps(state) {
|
|
10378
|
+
const budget = state.budget || {};
|
|
10379
|
+
return [
|
|
10380
|
+
Boolean(state.ui?.budgetOpen), budget.loading, budget.refreshing, budget.loaded,
|
|
10381
|
+
budget.error, budget.grid, budget.paused, budget.overall, budget.selectedProvider,
|
|
10382
|
+
budget.selectedScope, budget.missingProvider, budget.series,
|
|
10383
|
+
state.auth?.authorization?.role, state.admin?.profile,
|
|
10384
|
+
];
|
|
10385
|
+
}
|
|
10386
|
+
|
|
10387
|
+
/**
|
|
10388
|
+
* The whole surface: a head, one table, and the selected provider's days.
|
|
10389
|
+
*
|
|
10390
|
+
* No device prop: the stylesheet reflows the table into cards on its own, so
|
|
10391
|
+
* there is nothing here for a phone to decide.
|
|
10392
|
+
*/
|
|
10393
|
+
function ProviderBudgetView({ controller }) {
|
|
10394
|
+
const deps = useControllerSelector(controller, budgetViewDeps, shallowEqualObject);
|
|
10395
|
+
const view = React.useMemo(() => selectProviderTable(controller.getState()), [controller, deps]);
|
|
10396
|
+
// The grid as the server sent it. The sheets need the PROVIDER's own
|
|
10397
|
+
// figures — a limit caps everyone — and the table's cells hold whichever
|
|
10398
|
+
// pair the tick is showing, which is the viewer's own half the time.
|
|
10399
|
+
const rawRows = React.useMemo(() => controller.getState().budget?.grid || [], [controller, deps]);
|
|
10400
|
+
|
|
10401
|
+
// What the sheets need and the table does not: which type each provider is
|
|
10402
|
+
// an instance of, and what the two defaults currently name. Read once, and
|
|
10403
|
+
// a failure is not fatal — the controls that need it say they have nothing
|
|
10404
|
+
// to offer.
|
|
10405
|
+
const [models, setModels] = React.useState([]);
|
|
10406
|
+
const [providerTypes, setProviderTypes] = React.useState(new Map());
|
|
10407
|
+
const [defaults, setDefaults] = React.useState(null);
|
|
10408
|
+
const readDefaults = React.useCallback(() => {
|
|
10409
|
+
if (typeof controller.transport.getDefaults !== "function") return;
|
|
10410
|
+
Promise.resolve(controller.transport.getDefaults())
|
|
10411
|
+
.then((rows) => setDefaults(rows || null))
|
|
10412
|
+
.catch(() => {});
|
|
10413
|
+
}, [controller]);
|
|
10414
|
+
React.useEffect(() => {
|
|
10415
|
+
let live = true;
|
|
10416
|
+
if (typeof controller.transport.listModels === "function") {
|
|
10417
|
+
Promise.resolve(controller.transport.listModels())
|
|
10418
|
+
.then((rows) => { if (live) setModels(budgetNormalizeModels(rows)); })
|
|
10419
|
+
.catch(() => {});
|
|
10420
|
+
}
|
|
10421
|
+
if (typeof controller.transport.listProviders === "function") {
|
|
10422
|
+
Promise.resolve(controller.transport.listProviders())
|
|
10423
|
+
.then((rows) => {
|
|
10424
|
+
if (!live) return;
|
|
10425
|
+
const map = new Map();
|
|
10426
|
+
for (const provider of (rows?.providers || [])) map.set(provider.name, provider.typeId || "");
|
|
10427
|
+
setProviderTypes(map);
|
|
10428
|
+
})
|
|
10429
|
+
.catch(() => {});
|
|
10430
|
+
}
|
|
10431
|
+
readDefaults();
|
|
10432
|
+
return () => { live = false; };
|
|
10433
|
+
}, [controller, readDefaults]);
|
|
10434
|
+
|
|
10435
|
+
// Opened by the toolbar, which loads it. Mounting with nothing behind it —
|
|
10436
|
+
// a restored state, a second mount — reads once rather than showing zeros.
|
|
10437
|
+
React.useEffect(() => {
|
|
10438
|
+
const budget = controller.getState().budget || {};
|
|
10439
|
+
if (!budget.loaded && !budget.loading && !budget.error) {
|
|
10440
|
+
controller.loadProviderTable().catch(() => {});
|
|
10441
|
+
}
|
|
10442
|
+
}, [controller]);
|
|
10443
|
+
|
|
10444
|
+
// Limits reset, administrators raise them, sessions resume — and nothing
|
|
10445
|
+
// here would notice.
|
|
10446
|
+
React.useEffect(() => {
|
|
10447
|
+
const id = setInterval(() => { controller.refreshProviderTable().catch(() => {}); }, 60_000);
|
|
10448
|
+
return () => clearInterval(id);
|
|
10449
|
+
}, [controller]);
|
|
10450
|
+
|
|
10451
|
+
// Escape closes the surface — the key every full-screen thing in this app
|
|
10452
|
+
// answers to. Not while a <select> or an input has focus, not under a
|
|
10453
|
+
// modal, and not while a sheet is open: Escape belongs to whatever is
|
|
10454
|
+
// nearest the person.
|
|
10455
|
+
React.useEffect(() => {
|
|
10456
|
+
const onKey = (event) => {
|
|
10457
|
+
if (event.key !== "Escape") return;
|
|
10458
|
+
const target = event.target;
|
|
10459
|
+
const tag = target && target.tagName;
|
|
10460
|
+
if (tag === "SELECT" || tag === "INPUT" || tag === "TEXTAREA") return;
|
|
10461
|
+
if (controller.getState().ui.modal) return;
|
|
10462
|
+
if (document.querySelector(".ps-budget-sheet")) return;
|
|
10463
|
+
event.preventDefault();
|
|
10464
|
+
event.stopPropagation();
|
|
10465
|
+
controller.closeBudget();
|
|
10466
|
+
};
|
|
10467
|
+
window.addEventListener("keydown", onKey, true);
|
|
10468
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
10469
|
+
}, [controller]);
|
|
10470
|
+
|
|
10471
|
+
// ── the sheets ─────────────────────────────────────────────────────
|
|
10472
|
+
const [sheet, setSheet] = React.useState(null);
|
|
10473
|
+
const [sheetBusy, setSheetBusy] = React.useState(false);
|
|
10474
|
+
const [sheetError, setSheetError] = React.useState(null);
|
|
10475
|
+
// The control the sheet was opened FROM. A sheet that closes without
|
|
10476
|
+
// handing focus back drops it on <body>, and the next Tab restarts at the
|
|
10477
|
+
// top of the document — nineteen presses from where the person was.
|
|
10478
|
+
const openerRef = React.useRef(null);
|
|
10479
|
+
const openSheet = (spec) => {
|
|
10480
|
+
openerRef.current = typeof document !== "undefined" ? document.activeElement : null;
|
|
10481
|
+
setSheetError(null); setSheetBusy(false); setSheet(spec);
|
|
10482
|
+
};
|
|
10483
|
+
const closeSheet = React.useCallback(() => {
|
|
10484
|
+
setSheet(null); setSheetError(null); setSheetBusy(false);
|
|
10485
|
+
const opener = openerRef.current;
|
|
10486
|
+
openerRef.current = null;
|
|
10487
|
+
// A control the change itself removed (Delete) is skipped rather than
|
|
10488
|
+
// focused into a detached node.
|
|
10489
|
+
if (opener && typeof opener.focus === "function" && document.contains(opener)) {
|
|
10490
|
+
opener.focus();
|
|
10491
|
+
}
|
|
10492
|
+
}, []);
|
|
10493
|
+
// Every change comes back as {ok, error, code, result} — nothing throws. A
|
|
10494
|
+
// refusal stays in the sheet, in the server's own words, because they name
|
|
10495
|
+
// the remedy and nothing this file could invent would.
|
|
10496
|
+
const runChange = async (run, { keepOpen = false, onDone = null } = {}) => {
|
|
10497
|
+
setSheetBusy(true);
|
|
10498
|
+
setSheetError(null);
|
|
10499
|
+
const outcome = await run();
|
|
10500
|
+
setSheetBusy(false);
|
|
10501
|
+
if (!outcome || outcome.ok !== true) {
|
|
10502
|
+
setSheetError(outcome?.error || "The change was refused.");
|
|
10503
|
+
return;
|
|
10504
|
+
}
|
|
10505
|
+
if (!keepOpen) closeSheet();
|
|
10506
|
+
if (onDone) onDone(outcome.result || null);
|
|
10507
|
+
};
|
|
10508
|
+
const say = (text) => controller.dispatch({ type: "ui/status", text });
|
|
10509
|
+
|
|
10510
|
+
const selected = view.selected;
|
|
10511
|
+
// Edit and Remove are about the PROVIDER even while one of its model rows
|
|
10512
|
+
// is the row being read: a limit is edited on the provider, and there is
|
|
10513
|
+
// no such thing as deleting a model.
|
|
10514
|
+
const selectedProvider = view.selectedProviderRow;
|
|
10515
|
+
const modelsForSelected = selected
|
|
10516
|
+
? budgetModelsForProvider(models, {
|
|
10517
|
+
name: selected.providerName, typeId: providerTypes.get(selected.providerName) || "",
|
|
10518
|
+
})
|
|
10519
|
+
: [];
|
|
10520
|
+
|
|
10521
|
+
let sheetEl = null;
|
|
10522
|
+
if (sheet?.kind === "edit" && selectedProvider) {
|
|
10523
|
+
sheetEl = React.createElement(EditProviderSheet, {
|
|
10524
|
+
row: selectedProvider, rawRows, models: modelsForSelected, isAdmin: view.isAdmin,
|
|
10525
|
+
// MAJOR 17: the sheet opens on the limit the reader was standing
|
|
10526
|
+
// on, rather than always on Daily / all models.
|
|
10527
|
+
initialPeriod: sheet.period || "day",
|
|
10528
|
+
initialScope: sheet.scope || "*",
|
|
10529
|
+
busy: sheetBusy, error: sheetError, onCancel: closeSheet,
|
|
10530
|
+
onRun: (kind, payload) => {
|
|
10531
|
+
const name = selected.providerName;
|
|
10532
|
+
if (kind === "limit") {
|
|
10533
|
+
return runChange(() => controller.setProviderLimit({ provider: name, ...payload }), {
|
|
10534
|
+
onDone: (result) => {
|
|
10535
|
+
const seeded = Number(result?.seededTokens) || 0;
|
|
10536
|
+
say(seeded > 0
|
|
10537
|
+
? `Limit saved. This period had already spent ${formatCompactNumber(seeded)} tokens, and they count against it.`
|
|
10538
|
+
: "Limit saved. This period has spent nothing against it yet.");
|
|
10539
|
+
},
|
|
10540
|
+
});
|
|
10541
|
+
}
|
|
10542
|
+
if (kind === "removeLimit") {
|
|
10543
|
+
return runChange(() => controller.removeProviderLimit({ provider: name, ...payload }), {
|
|
10544
|
+
onDone: () => say(`That period no longer caps ${name}.`),
|
|
10545
|
+
});
|
|
10546
|
+
}
|
|
10547
|
+
if (kind === "allowance") {
|
|
10548
|
+
return runChange(() => controller.setProviderAllowance({ provider: name, pct: payload.pct }), {
|
|
10549
|
+
onDone: () => say(payload.pct >= 100
|
|
10550
|
+
? `Everyone shares the whole of ${name} again.`
|
|
10551
|
+
: `Each person may now use up to ${payload.pct}% of each of ${name}'s limits.`),
|
|
10552
|
+
});
|
|
10553
|
+
}
|
|
10554
|
+
if (kind === "hold") {
|
|
10555
|
+
return runChange(() => controller.setProviderHold({ provider: name, ...payload }), {
|
|
10556
|
+
// Reported from what the row says AFTER the read, not
|
|
10557
|
+
// from what was sent: a hold that is not live must
|
|
10558
|
+
// never be confirmed as one.
|
|
10559
|
+
onDone: () => {
|
|
10560
|
+
if (payload.release) {
|
|
10561
|
+
say(`The hold on ${name} is released. Sessions waiting on it wake up.`);
|
|
10562
|
+
return;
|
|
10563
|
+
}
|
|
10564
|
+
// From the STATE, which the change already
|
|
10565
|
+
// re-read — `view` here is the render that opened
|
|
10566
|
+
// the sheet and predates the write.
|
|
10567
|
+
const raw = (controller.getState().budget?.grid || []).find(
|
|
10568
|
+
(r) => r.providerName === name && r.rowKind !== "model");
|
|
10569
|
+
const live = raw?.holdIndefinite === true
|
|
10570
|
+
|| (raw?.holdUntilUtc ? Date.parse(raw.holdUntilUtc) > Date.now() : false);
|
|
10571
|
+
say(live
|
|
10572
|
+
? `${name} is on hold. No new turn runs against it until it is released.`
|
|
10573
|
+
: `${name} is NOT on hold: the time given has already passed.`);
|
|
10574
|
+
},
|
|
10575
|
+
});
|
|
10576
|
+
}
|
|
10577
|
+
return undefined;
|
|
10578
|
+
},
|
|
10579
|
+
});
|
|
10580
|
+
}
|
|
10581
|
+
|
|
10582
|
+
// ── the head ───────────────────────────────────────────────────────
|
|
10583
|
+
const head = React.createElement("div", { className: "ps-budget-head" },
|
|
10584
|
+
React.createElement("div", { className: "ps-budget-headline" },
|
|
10585
|
+
React.createElement("span", { className: "ps-budget-coin", "aria-hidden": "true" }, "◎"),
|
|
10586
|
+
React.createElement("h2", { className: "ps-budget-h" }, "Providers & Budgets"),
|
|
10587
|
+
React.createElement("span", { className: "ps-budget-sub" }, "Usage and limits")),
|
|
10588
|
+
React.createElement("div", { className: "ps-budget-head-actions" },
|
|
10589
|
+
React.createElement(IconButton, {
|
|
10590
|
+
icon: React.createElement(CogGlyph),
|
|
10591
|
+
label: "Model providers",
|
|
10592
|
+
className: "ps-mini-button",
|
|
10593
|
+
onClick: () => { controller.closeBudget(); void controller.openAdminConsole(); },
|
|
10594
|
+
}),
|
|
10595
|
+
React.createElement(IconButton, {
|
|
10596
|
+
icon: "↻",
|
|
10597
|
+
label: "Refresh providers and budgets",
|
|
10598
|
+
className: "ps-mini-button",
|
|
10599
|
+
disabled: view.loading || view.refreshing,
|
|
10600
|
+
onClick: () => controller.refreshProviderTable().catch(() => {}),
|
|
10601
|
+
}),
|
|
10602
|
+
React.createElement("button", {
|
|
10603
|
+
type: "button", className: "ps-budget-close", onClick: () => controller.closeBudget(),
|
|
10604
|
+
"aria-label": "Close providers and budgets", title: "Close (Esc)",
|
|
10605
|
+
}, "✕")));
|
|
10606
|
+
|
|
10607
|
+
// A read that FAILED is never dressed up as an empty one: "no providers"
|
|
10608
|
+
// and "we could not find out" are different facts, and only one of them is
|
|
10609
|
+
// safe to act on.
|
|
10610
|
+
let body;
|
|
10611
|
+
if (view.failed) {
|
|
10612
|
+
body = React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
|
|
10613
|
+
React.createElement("div", null, "Providers and budgets could not be read. ",
|
|
10614
|
+
React.createElement("button", {
|
|
10615
|
+
type: "button", className: "ps-budget-link",
|
|
10616
|
+
onClick: () => controller.loadProviderTable().catch(() => {}),
|
|
10617
|
+
}, "Try again")),
|
|
10618
|
+
React.createElement("div", { className: "ps-budget-sub" }, view.error));
|
|
10619
|
+
} else if (view.loading && !view.loaded) {
|
|
10620
|
+
body = React.createElement("div", { className: "ps-budget-empty" }, "Reading providers and budgets…");
|
|
10621
|
+
} else {
|
|
10622
|
+
body = React.createElement(React.Fragment, null,
|
|
10623
|
+
// Numbers are still on screen but the last read failed. Say they
|
|
10624
|
+
// are old rather than passing them off as current.
|
|
10625
|
+
view.stale ? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
|
|
10626
|
+
React.createElement("div", null, "These figures are from an earlier read. The most recent read failed."),
|
|
10627
|
+
React.createElement("div", { className: "ps-budget-sub" }, view.error)) : null,
|
|
10628
|
+
// One line, not a band. WHY a particular session is stopped is said
|
|
10629
|
+
// on the session itself, which is where the remedy is; this only
|
|
10630
|
+
// answers "is anything waiting right now".
|
|
10631
|
+
view.paused.count > 0 ? React.createElement("div", {
|
|
10632
|
+
className: `ps-budget-waiting${view.paused.stale ? " is-stale" : ""}`, role: "status",
|
|
10633
|
+
},
|
|
10634
|
+
view.paused.sentence,
|
|
10635
|
+
" ",
|
|
10636
|
+
view.paused.provider ? React.createElement("button", {
|
|
10637
|
+
type: "button", className: "ps-budget-link",
|
|
10638
|
+
onClick: () => {
|
|
10639
|
+
// The link OPENS the row; it does not toggle it shut when
|
|
10640
|
+
// the reader is already standing on it.
|
|
10641
|
+
if (view.selectedProvider === view.paused.provider) return;
|
|
10642
|
+
controller.selectBudgetProvider(view.paused.provider).catch(() => {});
|
|
10643
|
+
},
|
|
10644
|
+
}, `Open ${view.paused.provider}`) : null,
|
|
10645
|
+
// The numbers above were read fine; THIS line is the old one.
|
|
10646
|
+
view.paused.stale ? React.createElement("span", { className: "ps-budget-sub" },
|
|
10647
|
+
" This line is from an earlier read — the last one failed.") : null) : null,
|
|
10648
|
+
// The waiting list failed and there is nothing behind it, so there
|
|
10649
|
+
// is no line to mark. Say the read failed rather than nothing.
|
|
10650
|
+
view.paused.count === 0 && view.paused.error ? React.createElement("div", {
|
|
10651
|
+
className: "ps-budget-waiting is-stale", role: "status",
|
|
10652
|
+
}, "The list of waiting sessions could not be read. The figures below were read successfully.") : null,
|
|
10653
|
+
// Somebody arrived here by clicking a provider name on a stopped
|
|
10654
|
+
// session, and no such provider exists. Without this the selection
|
|
10655
|
+
// is cleared and they are left on a table with nothing selected
|
|
10656
|
+
// and no sign that the name they clicked is the missing thing.
|
|
10657
|
+
// Creating it again is the whole remedy, but provider lifecycle
|
|
10658
|
+
// belongs to Admin Console rather than this usage-policy surface.
|
|
10659
|
+
view.missing ? React.createElement("div", {
|
|
10660
|
+
className: "ps-budget-waiting is-missing", role: "status",
|
|
10661
|
+
},
|
|
10662
|
+
view.missing.sentence,
|
|
10663
|
+
" ",
|
|
10664
|
+
React.createElement("button", {
|
|
10665
|
+
type: "button", className: "ps-budget-link",
|
|
10666
|
+
onClick: () => { controller.closeBudget(); void controller.openAdminConsole(); },
|
|
10667
|
+
}, "Open Model Providers")) : null,
|
|
10668
|
+
React.createElement("div", {
|
|
10669
|
+
className: `ps-budget-body${view.refreshing ? " is-refreshing" : ""}`,
|
|
10670
|
+
},
|
|
10671
|
+
React.createElement(ProviderTable, { controller, view, defaults }),
|
|
10672
|
+
React.createElement("div", { className: "ps-budget-actions" },
|
|
10673
|
+
React.createElement(IconButton, {
|
|
10674
|
+
icon: React.createElement(ManageGlyph),
|
|
10675
|
+
label: "Edit usage policy",
|
|
10676
|
+
className: "ps-mini-button",
|
|
10677
|
+
disabled: !selectedProvider || !selectedProvider.manageable,
|
|
10678
|
+
onClick: () => openSheet({
|
|
10679
|
+
kind: "edit",
|
|
10680
|
+
// Open on the row the reader is standing on: a model
|
|
10681
|
+
// row means they were looking at THAT limit.
|
|
10682
|
+
scope: selected?.kind === "model" ? selected.scope : "*",
|
|
10683
|
+
}),
|
|
10684
|
+
}),
|
|
10685
|
+
React.createElement("span", { className: "ps-budget-sub" },
|
|
10686
|
+
selected ? selected.providerName : "No provider selected")),
|
|
10687
|
+
React.createElement("div", { className: "ps-budget-foot ps-budget-sub" },
|
|
10688
|
+
"Manage providers and defaults in Admin Console."),
|
|
10689
|
+
// Selecting a row opens its days, with the dashed line at whatever
|
|
10690
|
+
// quota the Day cell just showed.
|
|
10691
|
+
selected ? React.createElement(ProviderDayChart, { controller, view }) : null,
|
|
10692
|
+
selected ? React.createElement(ProviderSystemSpend, { view }) : null));
|
|
10693
|
+
}
|
|
10694
|
+
|
|
10695
|
+
return React.createElement("div", { className: "ps-budget-surface" },
|
|
10696
|
+
head,
|
|
10697
|
+
React.createElement("div", { className: "ps-budget" }, body),
|
|
10698
|
+
sheetEl);
|
|
10699
|
+
}
|
|
10700
|
+
|
|
9147
10701
|
function ModalLayer({ controller }) {
|
|
9148
10702
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
9149
10703
|
const touchScale = useControllerSelector(controller, (state) => Boolean(state.ui.touchScale));
|
|
@@ -10097,10 +11651,12 @@ function formatAdminPrincipalLabel(principal) {
|
|
|
10097
11651
|
|
|
10098
11652
|
function AdminConsolePanel({ controller, mobile = false }) {
|
|
10099
11653
|
const view = useControllerSelector(controller, selectAdminConsole, shallowEqualObject);
|
|
10100
|
-
const draftRef = React.useRef(null);
|
|
10101
11654
|
const packages = view.packages || {};
|
|
10102
11655
|
const showPackages = view.section === "packages";
|
|
10103
11656
|
const showWorkers = view.section === "workers";
|
|
11657
|
+
const [providerSheet, setProviderSheet] = React.useState(null);
|
|
11658
|
+
const [providerSheetBusy, setProviderSheetBusy] = React.useState(false);
|
|
11659
|
+
const [providerSheetError, setProviderSheetError] = React.useState(null);
|
|
10104
11660
|
// Workspace pane geometry: user-resizable (drag the pane's left edge for
|
|
10105
11661
|
// width, the Preview header for the tree/preview split), persisted.
|
|
10106
11662
|
const [wsLayout, setWsLayout] = React.useState(() => {
|
|
@@ -10140,39 +11696,25 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10140
11696
|
window.addEventListener("pointercancel", onUp);
|
|
10141
11697
|
}, [wsLayout.navWidth, updateWsLayout]);
|
|
10142
11698
|
|
|
10143
|
-
React.useEffect(() => {
|
|
10144
|
-
if (view.ghcpKey.editing) {
|
|
10145
|
-
// Defer focus to next tick so the input has mounted.
|
|
10146
|
-
const handle = window.requestAnimationFrame(() => {
|
|
10147
|
-
if (draftRef.current) draftRef.current.focus();
|
|
10148
|
-
});
|
|
10149
|
-
return () => window.cancelAnimationFrame(handle);
|
|
10150
|
-
}
|
|
10151
|
-
return undefined;
|
|
10152
|
-
}, [view.ghcpKey.editing]);
|
|
10153
|
-
|
|
10154
11699
|
const onClose = React.useCallback(() => {
|
|
10155
11700
|
controller.closeAdminConsole();
|
|
10156
11701
|
}, [controller]);
|
|
10157
|
-
const
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
10171
|
-
|
|
10172
|
-
}, [controller]);
|
|
10173
|
-
const onDraftChange = React.useCallback((event) => {
|
|
10174
|
-
controller.setAdminGhcpKeyDraft(event.target.value);
|
|
10175
|
-
}, [controller]);
|
|
11702
|
+
const closeProviderSheet = React.useCallback(() => {
|
|
11703
|
+
setProviderSheet(null);
|
|
11704
|
+
setProviderSheetBusy(false);
|
|
11705
|
+
setProviderSheetError(null);
|
|
11706
|
+
}, []);
|
|
11707
|
+
const submitProvider = React.useCallback(async (input) => {
|
|
11708
|
+
setProviderSheetBusy(true);
|
|
11709
|
+
setProviderSheetError(null);
|
|
11710
|
+
const outcome = await controller.createAdminProvider(input);
|
|
11711
|
+
setProviderSheetBusy(false);
|
|
11712
|
+
if (!outcome?.ok) {
|
|
11713
|
+
setProviderSheetError(outcome?.error || "The provider could not be created.");
|
|
11714
|
+
return;
|
|
11715
|
+
}
|
|
11716
|
+
closeProviderSheet();
|
|
11717
|
+
}, [closeProviderSheet, controller]);
|
|
10176
11718
|
|
|
10177
11719
|
const principalLabel = formatAdminPrincipalLabel(view.principal);
|
|
10178
11720
|
|
|
@@ -10187,8 +11729,11 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10187
11729
|
"aria-label": "Close the admin console",
|
|
10188
11730
|
}, "\u2715"));
|
|
10189
11731
|
|
|
10190
|
-
const
|
|
10191
|
-
|
|
11732
|
+
const providerSection = React.createElement(AdminModelProvidersSection, {
|
|
11733
|
+
controller,
|
|
11734
|
+
view,
|
|
11735
|
+
onAddPersonal: () => setProviderSheet({ shared: false, github: true }),
|
|
11736
|
+
onAddShared: () => setProviderSheet({ shared: true, github: false }),
|
|
10192
11737
|
});
|
|
10193
11738
|
|
|
10194
11739
|
const tree = React.createElement(AdminSettingsTree, { controller, view });
|
|
@@ -10199,6 +11744,24 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10199
11744
|
const dialog = packages.addDialog?.open
|
|
10200
11745
|
? React.createElement(AdminAddPackageDialog, { controller, dialog: packages.addDialog })
|
|
10201
11746
|
: null;
|
|
11747
|
+
const createProviderDialog = providerSheet
|
|
11748
|
+
? React.createElement(CreateProviderSheet, {
|
|
11749
|
+
types: view.modelProviders?.providerTypes || [],
|
|
11750
|
+
existingNames: [
|
|
11751
|
+
...(view.modelProviders?.sharedProviders || []),
|
|
11752
|
+
...(view.modelProviders?.myProviders || []),
|
|
11753
|
+
].map((provider) => provider.name),
|
|
11754
|
+
isAdmin: view.isAdmin,
|
|
11755
|
+
initialShared: providerSheet.shared,
|
|
11756
|
+
initialType: providerSheet.github
|
|
11757
|
+
? (view.modelProviders?.providerTypes || []).find((type) => /github/i.test(type.id))?.id || ""
|
|
11758
|
+
: "",
|
|
11759
|
+
busy: providerSheetBusy,
|
|
11760
|
+
error: providerSheetError,
|
|
11761
|
+
onCancel: closeProviderSheet,
|
|
11762
|
+
onConfirm: submitProvider,
|
|
11763
|
+
})
|
|
11764
|
+
: null;
|
|
10202
11765
|
|
|
10203
11766
|
if (mobile) {
|
|
10204
11767
|
// Drill-in stack: settings list → section content; a selected package
|
|
@@ -10218,13 +11781,14 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10218
11781
|
body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree,
|
|
10219
11782
|
React.createElement(AdminWorkersPane, { controller, view }));
|
|
10220
11783
|
} else {
|
|
10221
|
-
body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree,
|
|
11784
|
+
body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree, providerSection);
|
|
10222
11785
|
}
|
|
10223
11786
|
return React.createElement("div", { className: "ps-admin-console is-mobile" },
|
|
10224
11787
|
header,
|
|
10225
11788
|
view.loadError ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, view.loadError) : null,
|
|
10226
11789
|
body,
|
|
10227
|
-
dialog
|
|
11790
|
+
dialog,
|
|
11791
|
+
createProviderDialog);
|
|
10228
11792
|
}
|
|
10229
11793
|
|
|
10230
11794
|
return React.createElement("div", { className: "ps-admin-console is-workspace" },
|
|
@@ -10246,9 +11810,10 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10246
11810
|
"aria-label": "Resize settings column",
|
|
10247
11811
|
})),
|
|
10248
11812
|
React.createElement("div", { className: "ps-admin-main" },
|
|
10249
|
-
showPackages ? detail : showWorkers ? React.createElement(AdminWorkersPane, { controller, view }) :
|
|
11813
|
+
showPackages ? detail : showWorkers ? React.createElement(AdminWorkersPane, { controller, view }) : providerSection),
|
|
10250
11814
|
workspacePane),
|
|
10251
|
-
dialog
|
|
11815
|
+
dialog,
|
|
11816
|
+
createProviderDialog);
|
|
10252
11817
|
}
|
|
10253
11818
|
|
|
10254
11819
|
function AdminWorkersPane({ controller, view }) {
|
|
@@ -10332,7 +11897,17 @@ function AdminSettingsTree({ controller, view }) {
|
|
|
10332
11897
|
? { owner: { provider: row.owner.provider, subject: row.owner.subject } }
|
|
10333
11898
|
: {}),
|
|
10334
11899
|
})
|
|
10335
|
-
: () =>
|
|
11900
|
+
: () => {
|
|
11901
|
+
if (row.id === "providers" || row.id === "myProviders") {
|
|
11902
|
+
controller.setAdminSection("providers");
|
|
11903
|
+
controller.setAdminModelProviderPage("mine");
|
|
11904
|
+
} else if (row.id === "sharedProviders") {
|
|
11905
|
+
controller.setAdminSection("providers");
|
|
11906
|
+
controller.setAdminModelProviderPage("shared");
|
|
11907
|
+
} else {
|
|
11908
|
+
controller.setAdminSection(row.id === "agents" ? "packages" : "workers");
|
|
11909
|
+
}
|
|
11910
|
+
};
|
|
10336
11911
|
return React.createElement("button", {
|
|
10337
11912
|
key: row.id,
|
|
10338
11913
|
type: "button",
|
|
@@ -10358,11 +11933,11 @@ function AdminSettingsTree({ controller, view }) {
|
|
|
10358
11933
|
}),
|
|
10359
11934
|
packages.loading ? React.createElement("div", { className: "ps-admin-tree__hint" }, "Loading packages…") : null,
|
|
10360
11935
|
packages.error ? React.createElement("div", { className: "ps-admin-tree__hint is-error" }, packages.error) : null,
|
|
10361
|
-
React.createElement("button", {
|
|
11936
|
+
view.section === "packages" ? React.createElement("button", {
|
|
10362
11937
|
type: "button",
|
|
10363
11938
|
className: "ps-admin-tree__add",
|
|
10364
11939
|
onClick: () => controller.openAdminAddPackage(),
|
|
10365
|
-
}, "+ Add package"));
|
|
11940
|
+
}, "+ Add package") : null);
|
|
10366
11941
|
}
|
|
10367
11942
|
|
|
10368
11943
|
function AdminPackageDetailPane({ controller, view }) {
|
|
@@ -10774,6 +12349,218 @@ function AdminAddPackageDialog({ controller, dialog }) {
|
|
|
10774
12349
|
: dialog.updateName ? "Import & update" : "Import & publish"))));
|
|
10775
12350
|
}
|
|
10776
12351
|
|
|
12352
|
+
function adminDefaultModel(defaultView) {
|
|
12353
|
+
return defaultView?.configured?.model || "";
|
|
12354
|
+
}
|
|
12355
|
+
|
|
12356
|
+
function AdminDefaultSelect({ label, hint, value, choices, disabled, emptyLabel, onChange }) {
|
|
12357
|
+
return React.createElement("label", { className: "ps-admin-provider-default" },
|
|
12358
|
+
React.createElement("span", { className: "ps-admin-detail__label" }, label),
|
|
12359
|
+
React.createElement("select", {
|
|
12360
|
+
className: "ps-budget-input",
|
|
12361
|
+
value,
|
|
12362
|
+
disabled,
|
|
12363
|
+
"aria-label": label,
|
|
12364
|
+
onChange: (event) => onChange(event.target.value),
|
|
12365
|
+
},
|
|
12366
|
+
React.createElement("option", { value: "" }, emptyLabel),
|
|
12367
|
+
value && !choices.some((choice) => choice.qualifiedName === value)
|
|
12368
|
+
? React.createElement("option", { value }, `${value} (currently configured)`)
|
|
12369
|
+
: null,
|
|
12370
|
+
choices.map((choice) => React.createElement("option", {
|
|
12371
|
+
key: choice.qualifiedName,
|
|
12372
|
+
value: choice.qualifiedName,
|
|
12373
|
+
}, `${choice.provider} · ${choice.model}`))),
|
|
12374
|
+
hint ? React.createElement("span", { className: "ps-admin-console__hint" }, hint) : null);
|
|
12375
|
+
}
|
|
12376
|
+
|
|
12377
|
+
function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, onDelete }) {
|
|
12378
|
+
if (!providers.length) {
|
|
12379
|
+
return React.createElement("p", { className: "ps-admin-console__hint" },
|
|
12380
|
+
personal ? "No personal providers yet." : "No shared providers are configured.");
|
|
12381
|
+
}
|
|
12382
|
+
return React.createElement("div", { className: "ps-admin-provider-list" },
|
|
12383
|
+
providers.map((provider) => React.createElement("div", {
|
|
12384
|
+
key: provider.name,
|
|
12385
|
+
className: "ps-admin-provider-row",
|
|
12386
|
+
},
|
|
12387
|
+
React.createElement("div", { className: "ps-admin-provider-row__identity" },
|
|
12388
|
+
React.createElement("strong", null, provider.name),
|
|
12389
|
+
React.createElement("span", { className: `ps-scope-badge is-${provider.class === "shared" ? "shared" : "user"}` }, provider.class),
|
|
12390
|
+
provider.isMyDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "my default") : null,
|
|
12391
|
+
provider.isClusterDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "cluster default") : null,
|
|
12392
|
+
provider.isSystemDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "system default") : null),
|
|
12393
|
+
React.createElement("div", { className: "ps-admin-provider-row__actions" },
|
|
12394
|
+
personal && isAdmin ? React.createElement("label", { className: "ps-admin-console__system-toggle" },
|
|
12395
|
+
React.createElement("input", {
|
|
12396
|
+
type: "checkbox",
|
|
12397
|
+
checked: provider.systemUseEnabled,
|
|
12398
|
+
disabled: busy,
|
|
12399
|
+
onChange: (event) => onSystemUse(provider.name, event.target.checked),
|
|
12400
|
+
}),
|
|
12401
|
+
"Allow system sessions") : null,
|
|
12402
|
+
React.createElement(IconButton, {
|
|
12403
|
+
icon: React.createElement(TrashGlyph),
|
|
12404
|
+
label: `Delete ${provider.name}`,
|
|
12405
|
+
className: "ps-mini-button is-danger",
|
|
12406
|
+
disabled: busy,
|
|
12407
|
+
onClick: () => onDelete(provider),
|
|
12408
|
+
})))));
|
|
12409
|
+
}
|
|
12410
|
+
|
|
12411
|
+
function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShared }) {
|
|
12412
|
+
const providers = view.modelProviders || {};
|
|
12413
|
+
const pending = Boolean(providers.mutation?.pending);
|
|
12414
|
+
const systemConfigured = adminDefaultModel(providers.systemSessionDefault);
|
|
12415
|
+
const [systemDraft, setSystemDraft] = React.useState(systemConfigured);
|
|
12416
|
+
const [restartDisposition, setRestartDisposition] = React.useState("");
|
|
12417
|
+
const [restartResult, setRestartResult] = React.useState(null);
|
|
12418
|
+
|
|
12419
|
+
React.useEffect(() => {
|
|
12420
|
+
setSystemDraft(systemConfigured);
|
|
12421
|
+
}, [systemConfigured]);
|
|
12422
|
+
|
|
12423
|
+
const choiceFor = (choices, qualifiedName) => choices.find((choice) => choice.qualifiedName === qualifiedName) || null;
|
|
12424
|
+
const setDefault = (scope, choices, qualifiedName) => {
|
|
12425
|
+
void controller.setAdminModelDefault(scope, choiceFor(choices, qualifiedName));
|
|
12426
|
+
};
|
|
12427
|
+
const deleteProvider = (provider) => {
|
|
12428
|
+
if (!window.confirm(`Delete ${provider.name}? Sessions using it will wait until that provider name exists again.`)) return;
|
|
12429
|
+
void controller.deleteAdminProvider(provider.name, { shared: provider.class === "shared" });
|
|
12430
|
+
};
|
|
12431
|
+
const applySystemDefault = async () => {
|
|
12432
|
+
setRestartResult(null);
|
|
12433
|
+
const outcome = await controller.setAdminSystemModelDefault(
|
|
12434
|
+
choiceFor(providers.systemChoices || [], systemDraft),
|
|
12435
|
+
{ restartDisposition: restartDisposition || null },
|
|
12436
|
+
);
|
|
12437
|
+
if (outcome?.ok) setRestartResult(outcome.result?.restart || { requested: false });
|
|
12438
|
+
};
|
|
12439
|
+
const inheritingAgents = (providers.systemAgentRoutes || []).filter((route) => !route.override);
|
|
12440
|
+
const sharedPage = providers.page === "shared" && view.isAdmin;
|
|
12441
|
+
|
|
12442
|
+
const pageHeader = React.createElement("div", { className: "ps-admin-provider-heading" },
|
|
12443
|
+
React.createElement("h3", null, "Model Providers"),
|
|
12444
|
+
React.createElement(IconButton, {
|
|
12445
|
+
icon: "↻",
|
|
12446
|
+
label: "Refresh model providers",
|
|
12447
|
+
className: "ps-mini-button",
|
|
12448
|
+
disabled: providers.loading,
|
|
12449
|
+
onClick: () => controller.refreshAdminModelProviders(),
|
|
12450
|
+
}));
|
|
12451
|
+
|
|
12452
|
+
const myPage = React.createElement(React.Fragment, null,
|
|
12453
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12454
|
+
React.createElement("div", { className: "ps-admin-provider-block__head" },
|
|
12455
|
+
React.createElement("h4", null, "My Providers"),
|
|
12456
|
+
React.createElement(IconButton, {
|
|
12457
|
+
icon: React.createElement(PlusGlyph), label: "Add personal provider",
|
|
12458
|
+
className: "ps-primary-button", onClick: onAddPersonal, disabled: pending,
|
|
12459
|
+
})),
|
|
12460
|
+
React.createElement(AdminProviderRows, {
|
|
12461
|
+
providers: providers.myProviders || [], isAdmin: view.isAdmin, personal: true, busy: pending,
|
|
12462
|
+
onSystemUse: (name, enabled) => controller.setAdminProviderSystemUse(name, enabled),
|
|
12463
|
+
onDelete: deleteProvider,
|
|
12464
|
+
})),
|
|
12465
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12466
|
+
React.createElement(AdminDefaultSelect, {
|
|
12467
|
+
label: "My Session Default",
|
|
12468
|
+
hint: `Effective: ${providers.mySessionDefault?.effective?.model || "none"}.`,
|
|
12469
|
+
value: adminDefaultModel(providers.mySessionDefault),
|
|
12470
|
+
choices: providers.userChoices || [],
|
|
12471
|
+
disabled: pending,
|
|
12472
|
+
emptyLabel: "Use cluster default",
|
|
12473
|
+
onChange: (value) => setDefault("user", providers.userChoices || [], value),
|
|
12474
|
+
})));
|
|
12475
|
+
|
|
12476
|
+
const sharedProvidersPage = React.createElement(React.Fragment, null,
|
|
12477
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12478
|
+
React.createElement("div", { className: "ps-admin-provider-block__head" },
|
|
12479
|
+
React.createElement("h4", null, "Shared Providers"),
|
|
12480
|
+
React.createElement(IconButton, {
|
|
12481
|
+
icon: React.createElement(PlusGlyph), label: "Add shared provider",
|
|
12482
|
+
className: "ps-primary-button", onClick: onAddShared, disabled: pending,
|
|
12483
|
+
})),
|
|
12484
|
+
React.createElement(AdminProviderRows, {
|
|
12485
|
+
providers: providers.sharedProviders || [], isAdmin: true, personal: false, busy: pending,
|
|
12486
|
+
onSystemUse: () => {}, onDelete: deleteProvider,
|
|
12487
|
+
})),
|
|
12488
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12489
|
+
React.createElement(AdminDefaultSelect, {
|
|
12490
|
+
label: "Cluster Session Default",
|
|
12491
|
+
hint: `Effective: ${providers.clusterSessionDefault?.effective?.model || "none"}.`,
|
|
12492
|
+
value: adminDefaultModel(providers.clusterSessionDefault),
|
|
12493
|
+
choices: providers.clusterChoices || [],
|
|
12494
|
+
disabled: pending,
|
|
12495
|
+
emptyLabel: "Use first shared provider",
|
|
12496
|
+
onChange: (value) => setDefault("cluster", providers.clusterChoices || [], value),
|
|
12497
|
+
})),
|
|
12498
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12499
|
+
React.createElement("h4", null, "System Session Default"),
|
|
12500
|
+
React.createElement(AdminDefaultSelect, {
|
|
12501
|
+
label: "Model",
|
|
12502
|
+
hint: "Shared and system-enabled personal providers.",
|
|
12503
|
+
value: systemDraft,
|
|
12504
|
+
choices: providers.systemChoices || [],
|
|
12505
|
+
disabled: pending,
|
|
12506
|
+
emptyLabel: "Use first eligible provider",
|
|
12507
|
+
onChange: setSystemDraft,
|
|
12508
|
+
}),
|
|
12509
|
+
React.createElement("div", { className: "ps-admin-detail__label" }, "Existing system sessions"),
|
|
12510
|
+
React.createElement(Segmented, {
|
|
12511
|
+
value: restartDisposition,
|
|
12512
|
+
onChange: setRestartDisposition,
|
|
12513
|
+
label: "Existing system sessions",
|
|
12514
|
+
options: [
|
|
12515
|
+
{ value: "", label: "Future only" },
|
|
12516
|
+
{ value: "complete", label: "Complete & restart" },
|
|
12517
|
+
{ value: "terminate", label: "Terminate & restart" },
|
|
12518
|
+
{ value: "hard_delete", label: "Hard delete & restart" },
|
|
12519
|
+
],
|
|
12520
|
+
}),
|
|
12521
|
+
React.createElement("p", { className: "ps-admin-console__hint" },
|
|
12522
|
+
`${inheritingAgents.length} agent${inheritingAgents.length === 1 ? "" : "s"} inherit this default.`),
|
|
12523
|
+
React.createElement("button", {
|
|
12524
|
+
type: "button", className: "ps-primary-button", disabled: pending, onClick: applySystemDefault,
|
|
12525
|
+
}, pending && providers.mutation?.pending === "systemDefault" ? "Applying…" : "Apply"),
|
|
12526
|
+
restartResult?.requested ? React.createElement("p", { className: "ps-admin-console__status" },
|
|
12527
|
+
`${restartResult.restarted}/${restartResult.affected} restarted${restartResult.failures?.length ? ` · ${restartResult.failures.length} failed` : ""}.`) : null),
|
|
12528
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12529
|
+
React.createElement("h4", null, "System Agent Overrides"),
|
|
12530
|
+
(providers.systemAgentRoutes || []).length === 0
|
|
12531
|
+
? React.createElement("p", { className: "ps-admin-console__hint" }, "No system agents.")
|
|
12532
|
+
: React.createElement("div", { className: "ps-admin-override-table" },
|
|
12533
|
+
(providers.systemAgentRoutes || []).map((route) => React.createElement("div", {
|
|
12534
|
+
key: route.agentId, className: "ps-admin-override-row",
|
|
12535
|
+
},
|
|
12536
|
+
React.createElement("div", null,
|
|
12537
|
+
React.createElement("strong", null, route.title),
|
|
12538
|
+
React.createElement("span", null, route.effectiveModel || "blocked"),
|
|
12539
|
+
React.createElement("small", null, route.override ? "override" : "system default")),
|
|
12540
|
+
React.createElement("select", {
|
|
12541
|
+
className: "ps-budget-input",
|
|
12542
|
+
value: route.override?.model || "",
|
|
12543
|
+
disabled: pending,
|
|
12544
|
+
"aria-label": `Model override for ${route.title}`,
|
|
12545
|
+
onChange: (event) => {
|
|
12546
|
+
const choice = choiceFor(providers.systemChoices || [], event.target.value);
|
|
12547
|
+
if (choice) void controller.setAdminSystemAgentModel(route.agentId, choice);
|
|
12548
|
+
else void controller.clearAdminSystemAgentModel(route.agentId);
|
|
12549
|
+
},
|
|
12550
|
+
},
|
|
12551
|
+
React.createElement("option", { value: "" }, "Inherit system default"),
|
|
12552
|
+
(providers.systemChoices || []).map((choice) => React.createElement("option", {
|
|
12553
|
+
key: choice.qualifiedName, value: choice.qualifiedName,
|
|
12554
|
+
}, `${choice.provider} · ${choice.model}`))))))));
|
|
12555
|
+
|
|
12556
|
+
return React.createElement("section", { className: "ps-admin-detail ps-admin-model-providers" },
|
|
12557
|
+
pageHeader,
|
|
12558
|
+
providers.error || providers.mutation?.error
|
|
12559
|
+
? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, providers.mutation?.error || providers.error)
|
|
12560
|
+
: null,
|
|
12561
|
+
sharedPage ? sharedProvidersPage : myPage);
|
|
12562
|
+
}
|
|
12563
|
+
|
|
10777
12564
|
function AdminGhcpSection({ view, draftRef, onBeginEdit, onCancelEdit, onClear, onSubmit, onDraftChange, onRefresh, controller }) {
|
|
10778
12565
|
return React.createElement("section", { className: "ps-admin-console__section" },
|
|
10779
12566
|
React.createElement("h3", null, "GitHub Copilot key"),
|
|
@@ -10967,6 +12754,10 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
10967
12754
|
selectedArtifactId: rootState.files.selectedArtifactId || null,
|
|
10968
12755
|
artifactPaneOpen: Boolean(rootState.files.paneOpen),
|
|
10969
12756
|
adminVisible: Boolean(rootState.admin?.visible),
|
|
12757
|
+
// Providers & Budgets takes the workspace, like the admin console. The
|
|
12758
|
+
// reducer keeps the two apart — opening either closes the other — so
|
|
12759
|
+
// this only decides which one is drawn.
|
|
12760
|
+
budgetOpen: Boolean(rootState.ui?.budgetOpen),
|
|
10970
12761
|
}), shallowEqualObject);
|
|
10971
12762
|
const profileSettingsHydratedRef = React.useRef(false);
|
|
10972
12763
|
const lastProfileSettingsJsonRef = React.useRef(null);
|
|
@@ -10993,6 +12784,25 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
10993
12784
|
|
|
10994
12785
|
useKeyboardShortcuts(controller, mobile);
|
|
10995
12786
|
|
|
12787
|
+
// Keyboard takeover (phone): while the on-screen keyboard is up and the
|
|
12788
|
+
// composer summoned it, the conversation owns the visual viewport — the
|
|
12789
|
+
// portal header, toolbar and session list fold away via a body class and
|
|
12790
|
+
// the transcript snaps to newest. The chat pane's own header stays as the
|
|
12791
|
+
// context strip (session title · status · model), and tapping anywhere
|
|
12792
|
+
// outside the composer dismisses the keyboard and restores everything.
|
|
12793
|
+
const keyboardTakeover = useKeyboardTakeover(mobile && mobilePane === "workspace" && !state.adminVisible);
|
|
12794
|
+
React.useEffect(() => {
|
|
12795
|
+
if (typeof document === "undefined") return undefined;
|
|
12796
|
+
document.body.classList.toggle("ps-kb-takeover", keyboardTakeover);
|
|
12797
|
+
return () => document.body.classList.remove("ps-kb-takeover");
|
|
12798
|
+
}, [keyboardTakeover]);
|
|
12799
|
+
React.useEffect(() => {
|
|
12800
|
+
if (!keyboardTakeover) return;
|
|
12801
|
+
// Typing means replying to the newest message; a transcript left
|
|
12802
|
+
// scrolled to last week would have the user typing blind.
|
|
12803
|
+
controller.applyPaneVisualScrollOffset?.("chat", 0, { followBottom: true });
|
|
12804
|
+
}, [keyboardTakeover, controller]);
|
|
12805
|
+
|
|
10996
12806
|
// The agent-driven canvas flip, phone edition. The desktop column follows
|
|
10997
12807
|
// ui.rightPaneMode by itself; the phone's pane lives in local tab state,
|
|
10998
12808
|
// so it follows the flip TICK instead (which fires even when the mode was
|
|
@@ -11580,6 +13390,11 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
11580
13390
|
current === "split" ? "chat" : current === "chat" ? "sessions" : "split"
|
|
11581
13391
|
));
|
|
11582
13392
|
}
|
|
13393
|
+
// The budget surface REPLACES the workspace, so a pane the
|
|
13394
|
+
// phone switches to would render behind it and the tap would
|
|
13395
|
+
// look dead. Picking a pane puts it away. Already closed, the
|
|
13396
|
+
// reducer returns the same state and nothing re-renders.
|
|
13397
|
+
controller.closeBudget();
|
|
11583
13398
|
setMobilePane(paneId);
|
|
11584
13399
|
if (focus) controller.setFocus(focus);
|
|
11585
13400
|
},
|
|
@@ -11587,9 +13402,11 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
11587
13402
|
React.createElement("div", { className: "ps-workspace" },
|
|
11588
13403
|
state.adminVisible
|
|
11589
13404
|
? React.createElement(AdminConsolePanel, { controller, mobile })
|
|
11590
|
-
:
|
|
11591
|
-
?
|
|
11592
|
-
: (
|
|
13405
|
+
: state.budgetOpen
|
|
13406
|
+
? React.createElement(ProviderBudgetView, { controller })
|
|
13407
|
+
: (filesFullscreenActive
|
|
13408
|
+
? fullscreenWorkspace
|
|
13409
|
+
: (mobile ? mobileContent : desktopWorkspace)),
|
|
11593
13410
|
mobileCanvasLayer),
|
|
11594
13411
|
mobile && filesFullscreenActive
|
|
11595
13412
|
? React.createElement(MobileArtifactOverlay, { controller })
|