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.
Files changed (112) hide show
  1. package/mcp/README.md +25 -1
  2. package/mcp/dist/src/auth.d.ts.map +1 -1
  3. package/mcp/dist/src/auth.js +5 -3
  4. package/mcp/dist/src/auth.js.map +1 -1
  5. package/mcp/dist/src/resources/models.d.ts.map +1 -1
  6. package/mcp/dist/src/resources/models.js +21 -14
  7. package/mcp/dist/src/resources/models.js.map +1 -1
  8. package/mcp/dist/src/server.d.ts.map +1 -1
  9. package/mcp/dist/src/server.js +2 -0
  10. package/mcp/dist/src/server.js.map +1 -1
  11. package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/agent-packages.js +28 -16
  13. package/mcp/dist/src/tools/agent-packages.js.map +1 -1
  14. package/mcp/dist/src/tools/models.d.ts.map +1 -1
  15. package/mcp/dist/src/tools/models.js +55 -47
  16. package/mcp/dist/src/tools/models.js.map +1 -1
  17. package/mcp/dist/src/tools/providers.d.ts +18 -0
  18. package/mcp/dist/src/tools/providers.d.ts.map +1 -0
  19. package/mcp/dist/src/tools/providers.js +284 -0
  20. package/mcp/dist/src/tools/providers.js.map +1 -0
  21. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  22. package/mcp/dist/src/tools/sessions.js +10 -1
  23. package/mcp/dist/src/tools/sessions.js.map +1 -1
  24. package/mcp/dist/src/tools/system.js +2 -2
  25. package/mcp/dist/src/tools/system.js.map +1 -1
  26. package/mcp/dist/src/util/respond.d.ts.map +1 -1
  27. package/mcp/dist/src/util/respond.js +6 -0
  28. package/mcp/dist/src/util/respond.js.map +1 -1
  29. package/package.json +3 -3
  30. package/tui/README.md +8 -5
  31. package/tui/src/agents-cli.js +80 -78
  32. package/tui/src/app.js +119 -17
  33. package/tui/src/node-sdk-transport.js +150 -347
  34. package/ui/core/src/controller.js +1079 -23
  35. package/ui/core/src/history.js +25 -1
  36. package/ui/core/src/reducer.js +649 -1
  37. package/ui/core/src/selectors.js +906 -6
  38. package/ui/core/src/state.js +163 -3
  39. package/ui/react/src/components.js +146 -4
  40. package/ui/react/src/web-app.js +1942 -125
  41. package/web/README.md +4 -4
  42. package/web/api/router.js +33 -0
  43. package/web/dist/assets/{abnfDiagram-VRR7QNED-kpxu_PEa.js → abnfDiagram-VRR7QNED-BqKIaTz5.js} +1 -1
  44. package/web/dist/assets/{arc-BnVQmtB5.js → arc-DCSwKNAt.js} +1 -1
  45. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CDynQ_6_.js → architectureDiagram-ZJ3FMSHR-B6j7Bv5B.js} +1 -1
  46. package/web/dist/assets/{blockDiagram-677ZJIJ3-BUpYaSDb.js → blockDiagram-677ZJIJ3-BT1iegLO.js} +1 -1
  47. package/web/dist/assets/{c4Diagram-LMCZKHZV-ZL8eskDB.js → c4Diagram-LMCZKHZV-DXEt-_ny.js} +1 -1
  48. package/web/dist/assets/channel-BLVoqfUa.js +1 -0
  49. package/web/dist/assets/{chunk-2Q5K7J3B-o0Nsyp1T.js → chunk-2Q5K7J3B-B144-8I2.js} +1 -1
  50. package/web/dist/assets/{chunk-32BRIVSS-CH5CSNRP.js → chunk-32BRIVSS-CMHrtYOC.js} +1 -1
  51. package/web/dist/assets/{chunk-5VM5RSS4-C9wwtPWL.js → chunk-5VM5RSS4-4r8OmbGE.js} +1 -1
  52. package/web/dist/assets/{chunk-EX3LRPZG--F9yFbhA.js → chunk-EX3LRPZG-R_j3wosN.js} +1 -1
  53. package/web/dist/assets/{chunk-JWPE2WC7-BPIstyWL.js → chunk-JWPE2WC7-BKlFEYXC.js} +1 -1
  54. package/web/dist/assets/{chunk-MOJQB5TN-5Dm2u-0k.js → chunk-MOJQB5TN-C2S4TPSa.js} +1 -1
  55. package/web/dist/assets/{chunk-RYQCIY6F-Dbtf9iQK.js → chunk-RYQCIY6F-Y2kpzPD7.js} +1 -1
  56. package/web/dist/assets/{chunk-V7JOEXUC-DrZEBJ5l.js → chunk-V7JOEXUC-kAOP_3Kc.js} +1 -1
  57. package/web/dist/assets/{chunk-VR4S4FIN-AqAUynqK.js → chunk-VR4S4FIN-B2Au1EfR.js} +1 -1
  58. package/web/dist/assets/{chunk-XXDRQBXY-DQlFaqCy.js → chunk-XXDRQBXY-CZfTG6DD.js} +1 -1
  59. package/web/dist/assets/classDiagram-OUVF2IWQ-BgaYclQI.js +1 -0
  60. package/web/dist/assets/classDiagram-v2-EOCWNBFH-BgaYclQI.js +1 -0
  61. package/web/dist/assets/{cose-bilkent-JH36ORCC-RAtPG1Ou.js → cose-bilkent-JH36ORCC--tNLpMEb.js} +1 -1
  62. package/web/dist/assets/{cynefin-VYW2F7L2-CbeHaUWZ.js → cynefin-VYW2F7L2-BeIRFyvX.js} +1 -1
  63. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-B6paqF5Y.js → cynefinDiagram-TSTJHNR4-Bm5lb_xz.js} +1 -1
  64. package/web/dist/assets/{dagre-VKFMJZFB-DziTBO2K.js → dagre-VKFMJZFB-Cz1i_B0y.js} +1 -1
  65. package/web/dist/assets/{diagram-FQU43EPY-CPRUiLcr.js → diagram-FQU43EPY-BF0j7c1T.js} +1 -1
  66. package/web/dist/assets/{diagram-G47NLZAW-CIMVdcL1.js → diagram-G47NLZAW-C2QWJ-Y4.js} +1 -1
  67. package/web/dist/assets/{diagram-NH7WQ7WH-CHPFllxR.js → diagram-NH7WQ7WH-BiNeThAA.js} +1 -1
  68. package/web/dist/assets/{diagram-OA4YK3LP-DwS6unBJ.js → diagram-OA4YK3LP-Cq7_37rx.js} +1 -1
  69. package/web/dist/assets/{diagram-WEI45ONY-DKWgkObI.js → diagram-WEI45ONY-Cl0GjIma.js} +1 -1
  70. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-zAVWV6-D.js → ebnfDiagram-CCIWWBDH-C-dTpVrf.js} +1 -1
  71. package/web/dist/assets/{erDiagram-Q63AITRT-D0tYX4h0.js → erDiagram-Q63AITRT-RPSg6Lws.js} +1 -1
  72. package/web/dist/assets/{flowDiagram-23GEKE2U-Cfq4I7R0.js → flowDiagram-23GEKE2U-CslnZm0D.js} +1 -1
  73. package/web/dist/assets/{ganttDiagram-NO4QXBWP-wgBAHr5I.js → ganttDiagram-NO4QXBWP-dzr6nimS.js} +1 -1
  74. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BTIPX5PR.js → gitGraphDiagram-IHSO6WYX-UpjDHGP0.js} +1 -1
  75. package/web/dist/assets/index-C-ujPKpy.css +1 -0
  76. package/web/dist/assets/index-Cf_e477H.js +24 -0
  77. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CRLiQ87D.js → infoDiagram-FWYZ7A6U-R4qLdwe7.js} +1 -1
  78. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CwzwUis1.js → ishikawaDiagram-FXEZZL3T-Cst7PjW4.js} +1 -1
  79. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B9pqMeFw.js → journeyDiagram-5HDEW3XC-CugDTBMp.js} +1 -1
  80. package/web/dist/assets/{kanban-definition-HUTT4EX6-CZ5Xky4p.js → kanban-definition-HUTT4EX6-D6Yf2Lgg.js} +1 -1
  81. package/web/dist/assets/{linear-BLZ2eH4a.js → linear-hUntimCu.js} +1 -1
  82. package/web/dist/assets/{mermaid.core-CPN1IVjz.js → mermaid.core-BhKlcGBI.js} +4 -4
  83. package/web/dist/assets/{mindmap-definition-LN4V7U3C-MdC8d2tc.js → mindmap-definition-LN4V7U3C-DKVBRUj4.js} +1 -1
  84. package/web/dist/assets/{papaparse.min-CtrVJupf.js → papaparse.min-DbTWBfDP.js} +1 -1
  85. package/web/dist/assets/{pegDiagram-2B236MQR-B2DqAc-A.js → pegDiagram-2B236MQR-DeilQyRr.js} +1 -1
  86. package/web/dist/assets/{pieDiagram-ENE6RG2P-C3QAoXZh.js → pieDiagram-ENE6RG2P-CSVE3nUJ.js} +1 -1
  87. package/web/dist/assets/pilotswarm-DaQpAPI0.js +97 -0
  88. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-BoUr5qFH.js → quadrantDiagram-ABIIQ3AL-Di-WUa_j.js} +1 -1
  89. package/web/dist/assets/{railroadDiagram-RFXS5EU6-bZzE4KFN.js → railroadDiagram-RFXS5EU6-jBlJSP-m.js} +1 -1
  90. package/web/dist/assets/{requirementDiagram-TGXJPOKE-B2ENAqLS.js → requirementDiagram-TGXJPOKE-BlLuScOr.js} +1 -1
  91. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-AJZ3h46Z.js → sankeyDiagram-HTMAVEWB-D76NurlB.js} +1 -1
  92. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-QUf0VVE7.js → sequenceDiagram-DBY2YBRQ-BGPIUJo0.js} +1 -1
  93. package/web/dist/assets/{sizeCapture-X5ZJPWSS-Cv9RcUy6.js → sizeCapture-X5ZJPWSS-DKmGUrSI.js} +1 -1
  94. package/web/dist/assets/{stateDiagram-2N3HPSRC-hDiye45t.js → stateDiagram-2N3HPSRC-DAYHO_nW.js} +1 -1
  95. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-jZoHf_Ku.js +1 -0
  96. package/web/dist/assets/{swimlanes-5IMT3BWC-hSk_OF85.js → swimlanes-5IMT3BWC-CiRowk4M.js} +2 -2
  97. package/web/dist/assets/swimlanesDiagram-G3AALYLV-C0ms7UKB.js +8 -0
  98. package/web/dist/assets/{timeline-definition-FHXFAJF6-ccOZEJ5e.js → timeline-definition-FHXFAJF6-iN7kQL24.js} +1 -1
  99. package/web/dist/assets/{vennDiagram-L72KCM5P-Bk5DLH_V.js → vennDiagram-L72KCM5P-C6TtJ36b.js} +1 -1
  100. package/web/dist/assets/{wardleyDiagram-EHGQE667-C20AX8ot.js → wardleyDiagram-EHGQE667-CBiv7EU-.js} +1 -1
  101. package/web/dist/assets/{xychartDiagram-FW5EYKEG-DaHqmi-R.js → xychartDiagram-FW5EYKEG-zXUejHih.js} +1 -1
  102. package/web/dist/index.html +3 -3
  103. package/web/runtime.js +220 -2
  104. package/web/server.js +35 -7
  105. package/web/dist/assets/channel-Bi5A6sHR.js +0 -1
  106. package/web/dist/assets/classDiagram-OUVF2IWQ-D8dCxI54.js +0 -1
  107. package/web/dist/assets/classDiagram-v2-EOCWNBFH-D8dCxI54.js +0 -1
  108. package/web/dist/assets/index-OHj-gGHp.js +0 -24
  109. package/web/dist/assets/index-eTH1FBtN.css +0 -1
  110. package/web/dist/assets/pilotswarm-CF1nmpMy.js +0 -97
  111. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Bu4ufvaU.js +0 -1
  112. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTFePb1Y.js +0 -8
@@ -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
- }), [viewState.activeSessionId, viewState.auth, viewState.branding, viewState.canvasBySessionId, viewState.canvasPrefs, viewState.connectionMode, viewState.filterQuery, viewState.listDeselected, viewState.ownerFilter, viewState.pinnedIds, viewState.manualOrder, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
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 inputRef = React.useRef(null);
5226
- React.useEffect(() => {
5227
- const node = inputRef.current;
5228
- if (node) { node.focus(); node.select(); }
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, portalLinkOrigins.length >= 2 ? "Session links copied" : "Link copied"),
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
- portalLinkOrigins.length >= 2
5238
- ? "All entry-point links were copied — use the one reachable from the recipient's network."
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("div", { className: "ps-link-row" },
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
- // Multi-entry-point deployments: ONE text box holding every labeled
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
- const links = buildPortalLinks(url, portalLinkOrigins);
6405
- if (links.length <= 1) {
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-canvas-share-section" },
6468
- React.createElement("div", { className: "ps-canvas-share-head" }, "Anyone with session access"),
6469
- React.createElement("div", { className: "ps-canvas-share-sub" }, "Opens the portal signed in, canvas full screen. Session visibility rules apply."),
6470
- linkRows(sessionLink, "session")),
6471
-
6472
- React.createElement("div", { className: "ps-canvas-share-section" },
6473
- React.createElement("div", { className: "ps-canvas-share-head" }, "Anyone with the link"),
6474
- React.createElement("div", { className: "ps-canvas-share-sub" },
6475
- "View only, live, no sign-in. One link at a time — resetting makes the previous link stop working immediately."),
6476
- info === null
6477
- ? React.createElement("div", { className: "ps-canvas-share-sub" }, "Checking…")
6478
- : freshLink
6479
- ? React.createElement(React.Fragment, null,
6480
- linkRows(freshLink, "public"),
6481
- React.createElement("div", { className: "ps-canvas-share-sub is-warn" },
6482
- "Copy it now — the link is shown only this once."))
6483
- : info.exists
6484
- ? React.createElement("div", { className: "ps-canvas-share-sub" },
6485
- `A link exists${info.createdAt ? ` (created ${new Date(info.createdAt).toLocaleString()})` : ""}. For safety it cannot be re-shown — Reset to get a new one.`)
6486
- : React.createElement("div", { className: "ps-canvas-share-sub" }, "No public link yet."),
6487
- React.createElement("div", { className: "ps-canvas-share-actions" },
6488
- React.createElement("button", { type: "button", className: "ps-mini-button", disabled: busy, onClick: () => mint(info?.exists) },
6489
- info?.exists ? "Reset link" : "Create link"),
6490
- info?.exists
6491
- ? React.createElement("button", { type: "button", className: "ps-mini-button", disabled: busy, onClick: remove }, "Remove link")
6492
- : null)),
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 act on
8500
- // the app rather than on the conversation — so they sit apart from the
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 onRefresh = React.useCallback(() => {
10158
- controller.refreshAdminProfile().catch(() => {});
10159
- }, [controller]);
10160
- const onBeginEdit = React.useCallback(() => {
10161
- controller.beginAdminEditGhcpKey();
10162
- }, [controller]);
10163
- const onCancelEdit = React.useCallback(() => {
10164
- controller.cancelAdminEditGhcpKey();
10165
- }, [controller]);
10166
- const onClear = React.useCallback(() => {
10167
- controller.clearAdminGhcpKey().catch(() => {});
10168
- }, [controller]);
10169
- const onSubmit = React.useCallback((event) => {
10170
- event.preventDefault();
10171
- controller.saveAdminGhcpKey().catch(() => {});
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 ghcpSection = React.createElement(AdminGhcpSection, {
10191
- view, draftRef, onBeginEdit, onCancelEdit, onClear, onSubmit, onDraftChange, onRefresh, controller,
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, ghcpSection);
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 }) : ghcpSection),
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
- : () => controller.setAdminSection(row.id === "agents" ? "packages" : row.id === "workers" ? "workers" : "ghcp");
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
- : (filesFullscreenActive
11591
- ? fullscreenWorkspace
11592
- : (mobile ? mobileContent : desktopWorkspace)),
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 })