pilotswarm 0.5.42 → 0.5.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) 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/reducer.js +649 -1
  36. package/ui/core/src/selectors.js +891 -6
  37. package/ui/core/src/state.js +163 -3
  38. package/ui/react/src/components.js +146 -4
  39. package/ui/react/src/web-app.js +1732 -50
  40. package/web/README.md +4 -4
  41. package/web/api/router.js +33 -0
  42. package/web/dist/assets/{abnfDiagram-VRR7QNED-Jh9nLlRI.js → abnfDiagram-VRR7QNED-67Q0WWge.js} +1 -1
  43. package/web/dist/assets/{arc-CmN4jFMW.js → arc-BvcYFngS.js} +1 -1
  44. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DgUSVwl7.js → architectureDiagram-ZJ3FMSHR-CPBN0rk8.js} +1 -1
  45. package/web/dist/assets/{blockDiagram-677ZJIJ3-CCJEXNgp.js → blockDiagram-677ZJIJ3-D_hp1Oah.js} +1 -1
  46. package/web/dist/assets/{c4Diagram-LMCZKHZV-DG11zUre.js → c4Diagram-LMCZKHZV-DAOVGMIW.js} +1 -1
  47. package/web/dist/assets/channel-DiW7lUT8.js +1 -0
  48. package/web/dist/assets/{chunk-2Q5K7J3B-Cl8MUWGh.js → chunk-2Q5K7J3B-DajZuaot.js} +1 -1
  49. package/web/dist/assets/{chunk-32BRIVSS-BaLutEQY.js → chunk-32BRIVSS-C6pXBsgt.js} +1 -1
  50. package/web/dist/assets/{chunk-5VM5RSS4-BOPcnMeW.js → chunk-5VM5RSS4-780edrmi.js} +1 -1
  51. package/web/dist/assets/{chunk-EX3LRPZG-bY2tdfJ2.js → chunk-EX3LRPZG-C0e4NVuz.js} +1 -1
  52. package/web/dist/assets/{chunk-JWPE2WC7-CASBfQW6.js → chunk-JWPE2WC7-uRe83I6C.js} +1 -1
  53. package/web/dist/assets/{chunk-MOJQB5TN-WrEHfiAj.js → chunk-MOJQB5TN-B0WPoPjh.js} +1 -1
  54. package/web/dist/assets/{chunk-RYQCIY6F-XQoZii_K.js → chunk-RYQCIY6F-eRFuHHyG.js} +1 -1
  55. package/web/dist/assets/{chunk-V7JOEXUC-BvA5V7VF.js → chunk-V7JOEXUC-C8FDXprk.js} +1 -1
  56. package/web/dist/assets/{chunk-VR4S4FIN-SK_jCKYo.js → chunk-VR4S4FIN-DPFpLQdB.js} +1 -1
  57. package/web/dist/assets/{chunk-XXDRQBXY-CtZZImS8.js → chunk-XXDRQBXY-CoGQOPKW.js} +1 -1
  58. package/web/dist/assets/classDiagram-OUVF2IWQ-CpAhBd5x.js +1 -0
  59. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CpAhBd5x.js +1 -0
  60. package/web/dist/assets/{cose-bilkent-JH36ORCC-PRXhsdiX.js → cose-bilkent-JH36ORCC-BTBSfO5v.js} +1 -1
  61. package/web/dist/assets/{cynefin-VYW2F7L2-CzUocqvb.js → cynefin-VYW2F7L2-DgTkQEoK.js} +1 -1
  62. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DYGEqQ23.js → cynefinDiagram-TSTJHNR4-DiBMlKoP.js} +1 -1
  63. package/web/dist/assets/{dagre-VKFMJZFB-CAyb_zBo.js → dagre-VKFMJZFB-Qv-4dcbk.js} +1 -1
  64. package/web/dist/assets/{diagram-FQU43EPY-CvpXMmL-.js → diagram-FQU43EPY-CgyAb7yx.js} +1 -1
  65. package/web/dist/assets/{diagram-G47NLZAW-DBQRZJS0.js → diagram-G47NLZAW-BxN-KcGp.js} +1 -1
  66. package/web/dist/assets/{diagram-NH7WQ7WH-BGzD61gL.js → diagram-NH7WQ7WH-BiellpWz.js} +1 -1
  67. package/web/dist/assets/{diagram-OA4YK3LP-DqQf4oMW.js → diagram-OA4YK3LP-C8Ktgrwv.js} +1 -1
  68. package/web/dist/assets/{diagram-WEI45ONY-CYHoWtg7.js → diagram-WEI45ONY-Bj5K1X91.js} +1 -1
  69. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-oMOMgcs8.js → ebnfDiagram-CCIWWBDH-DNQI2XT9.js} +1 -1
  70. package/web/dist/assets/{erDiagram-Q63AITRT-DaZaseAH.js → erDiagram-Q63AITRT-D5Xkllu6.js} +1 -1
  71. package/web/dist/assets/{flowDiagram-23GEKE2U-BIe9TtAL.js → flowDiagram-23GEKE2U-DJpTtMuj.js} +1 -1
  72. package/web/dist/assets/{ganttDiagram-NO4QXBWP-DitrY3hM.js → ganttDiagram-NO4QXBWP-C-EQlCJf.js} +1 -1
  73. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-DRjIH_99.js → gitGraphDiagram-IHSO6WYX-C8vpSbvW.js} +1 -1
  74. package/web/dist/assets/index-C2ioXNJJ.js +24 -0
  75. package/web/dist/assets/index-CVYm02UI.css +1 -0
  76. package/web/dist/assets/{infoDiagram-FWYZ7A6U-BJCx4g9_.js → infoDiagram-FWYZ7A6U-CKNzsR3r.js} +1 -1
  77. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CLxmaTWp.js → ishikawaDiagram-FXEZZL3T-CjyQq3lf.js} +1 -1
  78. package/web/dist/assets/{journeyDiagram-5HDEW3XC-D2_VI8CC.js → journeyDiagram-5HDEW3XC-CK3EEkJt.js} +1 -1
  79. package/web/dist/assets/{kanban-definition-HUTT4EX6-DnUq9E7I.js → kanban-definition-HUTT4EX6-CHQ8YkPx.js} +1 -1
  80. package/web/dist/assets/{linear-mKMwWrAB.js → linear-CUYirs1h.js} +1 -1
  81. package/web/dist/assets/{mermaid.core-CApAWULn.js → mermaid.core-Di8sQC4g.js} +4 -4
  82. package/web/dist/assets/{mindmap-definition-LN4V7U3C-D2twxIMO.js → mindmap-definition-LN4V7U3C-D38dmHP5.js} +1 -1
  83. package/web/dist/assets/{papaparse.min-BhDLFNak.js → papaparse.min-DWBU_pXS.js} +1 -1
  84. package/web/dist/assets/{pegDiagram-2B236MQR-D2RtIeDP.js → pegDiagram-2B236MQR-BU759QCo.js} +1 -1
  85. package/web/dist/assets/{pieDiagram-ENE6RG2P-vonumKaj.js → pieDiagram-ENE6RG2P-BbpfHbsj.js} +1 -1
  86. package/web/dist/assets/pilotswarm-DrM3pXOV.js +97 -0
  87. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CAZL1kJH.js → quadrantDiagram-ABIIQ3AL-BYZ3XHZn.js} +1 -1
  88. package/web/dist/assets/{railroadDiagram-RFXS5EU6-CgpDomFp.js → railroadDiagram-RFXS5EU6-D18gkZBJ.js} +1 -1
  89. package/web/dist/assets/{requirementDiagram-TGXJPOKE-9B6yfsXV.js → requirementDiagram-TGXJPOKE-D6vo26Ib.js} +1 -1
  90. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_FAnwxi.js → sankeyDiagram-HTMAVEWB-CxQwXnRE.js} +1 -1
  91. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-BbL6OCms.js → sequenceDiagram-DBY2YBRQ-CfGisw4A.js} +1 -1
  92. package/web/dist/assets/{sizeCapture-X5ZJPWSS-DIRKFU8M.js → sizeCapture-X5ZJPWSS-BDK7-yA-.js} +1 -1
  93. package/web/dist/assets/{stateDiagram-2N3HPSRC-pUX5NF32.js → stateDiagram-2N3HPSRC-8IyidYSV.js} +1 -1
  94. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-F7qYkvpr.js +1 -0
  95. package/web/dist/assets/{swimlanes-5IMT3BWC-DO7BjQ4-.js → swimlanes-5IMT3BWC-CgNqXCvM.js} +2 -2
  96. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CUKrHLkY.js +8 -0
  97. package/web/dist/assets/{timeline-definition-FHXFAJF6-BcbYRQ7s.js → timeline-definition-FHXFAJF6-DAJsGj6d.js} +1 -1
  98. package/web/dist/assets/{vennDiagram-L72KCM5P-CUt_orN5.js → vennDiagram-L72KCM5P-DDVdvG5k.js} +1 -1
  99. package/web/dist/assets/{wardleyDiagram-EHGQE667-BlUPWQdi.js → wardleyDiagram-EHGQE667-CiAs_m72.js} +1 -1
  100. package/web/dist/assets/{xychartDiagram-FW5EYKEG-CDW7A5_U.js → xychartDiagram-FW5EYKEG-D6OLJtsD.js} +1 -1
  101. package/web/dist/index.html +3 -3
  102. package/web/runtime.js +220 -2
  103. package/web/server.js +35 -7
  104. package/web/dist/assets/channel-BQBGhjIw.js +0 -1
  105. package/web/dist/assets/classDiagram-OUVF2IWQ-BIr20mVO.js +0 -1
  106. package/web/dist/assets/classDiagram-v2-EOCWNBFH-BIr20mVO.js +0 -1
  107. package/web/dist/assets/index-Cl6-pI9g.js +0 -24
  108. package/web/dist/assets/index-DRLA5aNR.css +0 -1
  109. package/web/dist/assets/pilotswarm-DETFHWYc.js +0 -97
  110. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-J_okbpZN.js +0 -1
  111. package/web/dist/assets/swimlanesDiagram-G3AALYLV-gTxQyRZa.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,
@@ -4003,7 +4004,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
4003
4004
  */
4004
4005
  const SESSION_DETAIL_NONE = "—";
4005
4006
 
4006
- function SessionDetailBox({ session, childCount = 0 }) {
4007
+ function SessionDetailBox({ session, childCount = 0, pause = null, controller = null }) {
4007
4008
  // EVERY field renders on EVERY selection, empty ones as an em dash. The box
4008
4009
  // is a fixed grid of rows, so moving through the list cannot change its
4009
4010
  // height — a box that grew and shrank would shove the list under the
@@ -4060,6 +4061,37 @@ function SessionDetailBox({ session, childCount = 0 }) {
4060
4061
 
4061
4062
  const statusSummary = selectSessionStatusSummary(session);
4062
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
+
4063
4095
  return React.createElement("div", { className: "ps-session-detail-box" },
4064
4096
  // The row above ellipsizes to one line, so this is the only place the
4065
4097
  // whole name is legible. Two lines are RESERVED whether or not they are
@@ -4076,7 +4108,10 @@ function SessionDetailBox({ session, childCount = 0 }) {
4076
4108
  // selectSessionStatusSummary for why raw made this flip idle/waiting.
4077
4109
  field("Updated", statusSummary ? `${statusSummary.relative} (${statusSummary.status})` : null),
4078
4110
  field("Children", children),
4079
- 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);
4080
4115
  }
4081
4116
 
4082
4117
  /**
@@ -4477,6 +4512,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4477
4512
  // every row until these two lines existed.
4478
4513
  canvasBySessionId: state.canvas?.bySessionId,
4479
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,
4480
4520
  }), shallowEqualObject);
4481
4521
  const computedRows = React.useMemo(() => selectSessionRows({
4482
4522
  sessions: {
@@ -4504,7 +4544,8 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4504
4544
  bySessionId: viewState.canvasBySessionId,
4505
4545
  prefs: viewState.canvasPrefs,
4506
4546
  },
4507
- }), [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]);
4508
4549
  // Hold the previous rows when a poll produced identical output.
4509
4550
  const rows = useStableValue(computedRows);
4510
4551
  const activeSession = viewState.activeSessionId
@@ -5237,6 +5278,10 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5237
5278
  ? React.createElement(SessionDetailBox, {
5238
5279
  session: activeSession,
5239
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,
5240
5285
  })
5241
5286
  : null),
5242
5287
  (manageOpen && activeSession && !activeSession.isGroup)
@@ -5341,6 +5386,15 @@ function PlusGlyph() {
5341
5386
  React.createElement("line", { x1: "5", y1: "12", x2: "19", y2: "12" }));
5342
5387
  }
5343
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
+
5344
5398
  // Theme picker. Deliberately a contrast disc rather than a sun/moon: the picker
5345
5399
  // offers DOOM, WinAMP, win95 and friends, not a light/dark binary.
5346
5400
  function ContrastGlyph() {
@@ -5371,6 +5425,17 @@ function CogGlyph() {
5371
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" }));
5372
5426
  }
5373
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
+
5374
5439
  // Restart a system session. Was "↻" — the one text codepoint in an otherwise
5375
5440
  // all-SVG row, so it rendered a weight lighter and sat a pixel high.
5376
5441
  function RestartGlyph() {
@@ -8452,6 +8517,7 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8452
8517
  setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
8453
8518
  }, [mobile]);
8454
8519
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
8520
+ const budgetOpen = useControllerSelector(controller, (state) => Boolean(state.ui?.budgetOpen));
8455
8521
  const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
8456
8522
  const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
8457
8523
  const canvasMaximized = useControllerSelector(controller, (s) => s?.ui?.canvasMaximized === true);
@@ -8521,6 +8587,19 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8521
8587
  // (loaded), green + yellow (loaded with unseen changes).
8522
8588
  loadedDot: canvasView.exists,
8523
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
+ },
8524
8603
  // Diagnostics — Inspector + Activity as one column, desktop only.
8525
8604
  //
8526
8605
  // They used to be tabs inside the chat pane and were always present.
@@ -8613,10 +8692,10 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8613
8692
  );
8614
8693
  }
8615
8694
 
8616
- // Two groups. Diagnostics and the admin console are TOOLS — they act on
8617
- // the app rather than on the conversation — so they sit apart from the
8618
- // session controls, hard right. Everything else keeps its order.
8619
- 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"]);
8620
8699
 
8621
8700
  // While the canvas is full screen, ANY other button first drops full
8622
8701
  // screen and then does its own job. Pressing Filter and watching nothing
@@ -9261,6 +9340,1364 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
9261
9340
 
9262
9341
 
9263
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
+
9264
10701
  function ModalLayer({ controller }) {
9265
10702
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
9266
10703
  const touchScale = useControllerSelector(controller, (state) => Boolean(state.ui.touchScale));
@@ -10214,10 +11651,12 @@ function formatAdminPrincipalLabel(principal) {
10214
11651
 
10215
11652
  function AdminConsolePanel({ controller, mobile = false }) {
10216
11653
  const view = useControllerSelector(controller, selectAdminConsole, shallowEqualObject);
10217
- const draftRef = React.useRef(null);
10218
11654
  const packages = view.packages || {};
10219
11655
  const showPackages = view.section === "packages";
10220
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);
10221
11660
  // Workspace pane geometry: user-resizable (drag the pane's left edge for
10222
11661
  // width, the Preview header for the tree/preview split), persisted.
10223
11662
  const [wsLayout, setWsLayout] = React.useState(() => {
@@ -10257,39 +11696,25 @@ function AdminConsolePanel({ controller, mobile = false }) {
10257
11696
  window.addEventListener("pointercancel", onUp);
10258
11697
  }, [wsLayout.navWidth, updateWsLayout]);
10259
11698
 
10260
- React.useEffect(() => {
10261
- if (view.ghcpKey.editing) {
10262
- // Defer focus to next tick so the input has mounted.
10263
- const handle = window.requestAnimationFrame(() => {
10264
- if (draftRef.current) draftRef.current.focus();
10265
- });
10266
- return () => window.cancelAnimationFrame(handle);
10267
- }
10268
- return undefined;
10269
- }, [view.ghcpKey.editing]);
10270
-
10271
11699
  const onClose = React.useCallback(() => {
10272
11700
  controller.closeAdminConsole();
10273
11701
  }, [controller]);
10274
- const onRefresh = React.useCallback(() => {
10275
- controller.refreshAdminProfile().catch(() => {});
10276
- }, [controller]);
10277
- const onBeginEdit = React.useCallback(() => {
10278
- controller.beginAdminEditGhcpKey();
10279
- }, [controller]);
10280
- const onCancelEdit = React.useCallback(() => {
10281
- controller.cancelAdminEditGhcpKey();
10282
- }, [controller]);
10283
- const onClear = React.useCallback(() => {
10284
- controller.clearAdminGhcpKey().catch(() => {});
10285
- }, [controller]);
10286
- const onSubmit = React.useCallback((event) => {
10287
- event.preventDefault();
10288
- controller.saveAdminGhcpKey().catch(() => {});
10289
- }, [controller]);
10290
- const onDraftChange = React.useCallback((event) => {
10291
- controller.setAdminGhcpKeyDraft(event.target.value);
10292
- }, [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]);
10293
11718
 
10294
11719
  const principalLabel = formatAdminPrincipalLabel(view.principal);
10295
11720
 
@@ -10304,8 +11729,11 @@ function AdminConsolePanel({ controller, mobile = false }) {
10304
11729
  "aria-label": "Close the admin console",
10305
11730
  }, "\u2715"));
10306
11731
 
10307
- const ghcpSection = React.createElement(AdminGhcpSection, {
10308
- 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 }),
10309
11737
  });
10310
11738
 
10311
11739
  const tree = React.createElement(AdminSettingsTree, { controller, view });
@@ -10316,6 +11744,24 @@ function AdminConsolePanel({ controller, mobile = false }) {
10316
11744
  const dialog = packages.addDialog?.open
10317
11745
  ? React.createElement(AdminAddPackageDialog, { controller, dialog: packages.addDialog })
10318
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;
10319
11765
 
10320
11766
  if (mobile) {
10321
11767
  // Drill-in stack: settings list → section content; a selected package
@@ -10335,13 +11781,14 @@ function AdminConsolePanel({ controller, mobile = false }) {
10335
11781
  body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree,
10336
11782
  React.createElement(AdminWorkersPane, { controller, view }));
10337
11783
  } else {
10338
- body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree, ghcpSection);
11784
+ body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree, providerSection);
10339
11785
  }
10340
11786
  return React.createElement("div", { className: "ps-admin-console is-mobile" },
10341
11787
  header,
10342
11788
  view.loadError ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, view.loadError) : null,
10343
11789
  body,
10344
- dialog);
11790
+ dialog,
11791
+ createProviderDialog);
10345
11792
  }
10346
11793
 
10347
11794
  return React.createElement("div", { className: "ps-admin-console is-workspace" },
@@ -10363,9 +11810,10 @@ function AdminConsolePanel({ controller, mobile = false }) {
10363
11810
  "aria-label": "Resize settings column",
10364
11811
  })),
10365
11812
  React.createElement("div", { className: "ps-admin-main" },
10366
- showPackages ? detail : showWorkers ? React.createElement(AdminWorkersPane, { controller, view }) : ghcpSection),
11813
+ showPackages ? detail : showWorkers ? React.createElement(AdminWorkersPane, { controller, view }) : providerSection),
10367
11814
  workspacePane),
10368
- dialog);
11815
+ dialog,
11816
+ createProviderDialog);
10369
11817
  }
10370
11818
 
10371
11819
  function AdminWorkersPane({ controller, view }) {
@@ -10449,7 +11897,17 @@ function AdminSettingsTree({ controller, view }) {
10449
11897
  ? { owner: { provider: row.owner.provider, subject: row.owner.subject } }
10450
11898
  : {}),
10451
11899
  })
10452
- : () => 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
+ };
10453
11911
  return React.createElement("button", {
10454
11912
  key: row.id,
10455
11913
  type: "button",
@@ -10475,11 +11933,11 @@ function AdminSettingsTree({ controller, view }) {
10475
11933
  }),
10476
11934
  packages.loading ? React.createElement("div", { className: "ps-admin-tree__hint" }, "Loading packages…") : null,
10477
11935
  packages.error ? React.createElement("div", { className: "ps-admin-tree__hint is-error" }, packages.error) : null,
10478
- React.createElement("button", {
11936
+ view.section === "packages" ? React.createElement("button", {
10479
11937
  type: "button",
10480
11938
  className: "ps-admin-tree__add",
10481
11939
  onClick: () => controller.openAdminAddPackage(),
10482
- }, "+ Add package"));
11940
+ }, "+ Add package") : null);
10483
11941
  }
10484
11942
 
10485
11943
  function AdminPackageDetailPane({ controller, view }) {
@@ -10891,6 +12349,219 @@ function AdminAddPackageDialog({ controller, dialog }) {
10891
12349
  : dialog.updateName ? "Import & update" : "Import & publish"))));
10892
12350
  }
10893
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("button", {
12457
+ type: "button", className: "ps-primary-button",
12458
+ onClick: onAddPersonal, disabled: pending,
12459
+ }, "Add provider")),
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("button", {
12481
+ type: "button", className: "ps-primary-button",
12482
+ onClick: onAddShared, disabled: pending,
12483
+ }, "Add provider")),
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("div", { className: "ps-admin-provider-apply" },
12522
+ React.createElement("p", { className: "ps-admin-console__hint" },
12523
+ `${inheritingAgents.length} agent${inheritingAgents.length === 1 ? "" : "s"} inherit this default.`),
12524
+ React.createElement("button", {
12525
+ type: "button", className: "ps-primary-button", disabled: pending, onClick: applySystemDefault,
12526
+ }, pending && providers.mutation?.pending === "systemDefault" ? "Applying…" : "Apply")),
12527
+ restartResult?.requested ? React.createElement("p", { className: "ps-admin-console__status" },
12528
+ `${restartResult.restarted}/${restartResult.affected} restarted${restartResult.failures?.length ? ` · ${restartResult.failures.length} failed` : ""}.`) : null),
12529
+ React.createElement("div", { className: "ps-admin-provider-block" },
12530
+ React.createElement("h4", null, "System Agent Overrides"),
12531
+ (providers.systemAgentRoutes || []).length === 0
12532
+ ? React.createElement("p", { className: "ps-admin-console__hint" }, "No system agents.")
12533
+ : React.createElement("div", { className: "ps-admin-override-table" },
12534
+ (providers.systemAgentRoutes || []).map((route) => React.createElement("div", {
12535
+ key: route.agentId, className: "ps-admin-override-row",
12536
+ },
12537
+ React.createElement("div", { className: "ps-admin-override-row__agent" },
12538
+ React.createElement("strong", null, route.title),
12539
+ React.createElement("span", null, route.effectiveModel || "blocked"),
12540
+ React.createElement("small", null, route.override ? "override" : "system default")),
12541
+ React.createElement("select", {
12542
+ className: "ps-budget-input",
12543
+ value: route.override?.model || "",
12544
+ disabled: pending,
12545
+ "aria-label": `Model override for ${route.title}`,
12546
+ onChange: (event) => {
12547
+ const choice = choiceFor(providers.systemChoices || [], event.target.value);
12548
+ if (choice) void controller.setAdminSystemAgentModel(route.agentId, choice);
12549
+ else void controller.clearAdminSystemAgentModel(route.agentId);
12550
+ },
12551
+ },
12552
+ React.createElement("option", { value: "" }, "Inherit system default"),
12553
+ (providers.systemChoices || []).map((choice) => React.createElement("option", {
12554
+ key: choice.qualifiedName, value: choice.qualifiedName,
12555
+ }, `${choice.provider} · ${choice.model}`))))))));
12556
+
12557
+ return React.createElement("section", { className: "ps-admin-detail ps-admin-model-providers" },
12558
+ pageHeader,
12559
+ providers.error || providers.mutation?.error
12560
+ ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, providers.mutation?.error || providers.error)
12561
+ : null,
12562
+ sharedPage ? sharedProvidersPage : myPage);
12563
+ }
12564
+
10894
12565
  function AdminGhcpSection({ view, draftRef, onBeginEdit, onCancelEdit, onClear, onSubmit, onDraftChange, onRefresh, controller }) {
10895
12566
  return React.createElement("section", { className: "ps-admin-console__section" },
10896
12567
  React.createElement("h3", null, "GitHub Copilot key"),
@@ -11084,6 +12755,10 @@ export function PilotSwarmWebApp({ controller }) {
11084
12755
  selectedArtifactId: rootState.files.selectedArtifactId || null,
11085
12756
  artifactPaneOpen: Boolean(rootState.files.paneOpen),
11086
12757
  adminVisible: Boolean(rootState.admin?.visible),
12758
+ // Providers & Budgets takes the workspace, like the admin console. The
12759
+ // reducer keeps the two apart — opening either closes the other — so
12760
+ // this only decides which one is drawn.
12761
+ budgetOpen: Boolean(rootState.ui?.budgetOpen),
11087
12762
  }), shallowEqualObject);
11088
12763
  const profileSettingsHydratedRef = React.useRef(false);
11089
12764
  const lastProfileSettingsJsonRef = React.useRef(null);
@@ -11716,6 +13391,11 @@ export function PilotSwarmWebApp({ controller }) {
11716
13391
  current === "split" ? "chat" : current === "chat" ? "sessions" : "split"
11717
13392
  ));
11718
13393
  }
13394
+ // The budget surface REPLACES the workspace, so a pane the
13395
+ // phone switches to would render behind it and the tap would
13396
+ // look dead. Picking a pane puts it away. Already closed, the
13397
+ // reducer returns the same state and nothing re-renders.
13398
+ controller.closeBudget();
11719
13399
  setMobilePane(paneId);
11720
13400
  if (focus) controller.setFocus(focus);
11721
13401
  },
@@ -11723,9 +13403,11 @@ export function PilotSwarmWebApp({ controller }) {
11723
13403
  React.createElement("div", { className: "ps-workspace" },
11724
13404
  state.adminVisible
11725
13405
  ? React.createElement(AdminConsolePanel, { controller, mobile })
11726
- : (filesFullscreenActive
11727
- ? fullscreenWorkspace
11728
- : (mobile ? mobileContent : desktopWorkspace)),
13406
+ : state.budgetOpen
13407
+ ? React.createElement(ProviderBudgetView, { controller })
13408
+ : (filesFullscreenActive
13409
+ ? fullscreenWorkspace
13410
+ : (mobile ? mobileContent : desktopWorkspace)),
11729
13411
  mobileCanvasLayer),
11730
13412
  mobile && filesFullscreenActive
11731
13413
  ? React.createElement(MobileArtifactOverlay, { controller })