pilotswarm 0.5.47 → 0.5.49

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 (82) hide show
  1. package/mcp/dist/src/tools/providers.d.ts.map +1 -1
  2. package/mcp/dist/src/tools/providers.js +12 -0
  3. package/mcp/dist/src/tools/providers.js.map +1 -1
  4. package/package.json +2 -2
  5. package/tui/src/node-sdk-transport.js +5 -0
  6. package/ui/core/src/commands.js +1 -0
  7. package/ui/core/src/controller.js +85 -3
  8. package/ui/core/src/history.js +5 -37
  9. package/ui/core/src/index.js +1 -0
  10. package/ui/core/src/reducer.js +42 -0
  11. package/ui/core/src/selectors.js +153 -4
  12. package/ui/core/src/state.js +20 -0
  13. package/ui/core/src/table-layout.js +155 -0
  14. package/ui/react/src/web-app.js +479 -249
  15. package/web/dist/assets/{abnfDiagram-VRR7QNED-VPL8m_9W.js → abnfDiagram-VRR7QNED-CXF6f3n3.js} +1 -1
  16. package/web/dist/assets/{arc-T5I3-c1B.js → arc-MlDVINID.js} +1 -1
  17. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DEP70bg6.js → architectureDiagram-ZJ3FMSHR-Bdz1J9H-.js} +1 -1
  18. package/web/dist/assets/{blockDiagram-677ZJIJ3-CvBu1MbO.js → blockDiagram-677ZJIJ3-tO706-rr.js} +1 -1
  19. package/web/dist/assets/{c4Diagram-LMCZKHZV-B5qwF4gm.js → c4Diagram-LMCZKHZV-BuZrMrYZ.js} +1 -1
  20. package/web/dist/assets/channel-moQWQGwr.js +1 -0
  21. package/web/dist/assets/{chunk-2Q5K7J3B-zVW8dS7X.js → chunk-2Q5K7J3B-BWWIPnCk.js} +1 -1
  22. package/web/dist/assets/{chunk-32BRIVSS-DWctxZZS.js → chunk-32BRIVSS-TyL8x2Q5.js} +1 -1
  23. package/web/dist/assets/{chunk-5VM5RSS4-Bq7_Vmf9.js → chunk-5VM5RSS4-ZTwZyN5B.js} +1 -1
  24. package/web/dist/assets/{chunk-EX3LRPZG-B8CFB3v-.js → chunk-EX3LRPZG-CjWsKz70.js} +1 -1
  25. package/web/dist/assets/{chunk-JWPE2WC7-B7gLPjgZ.js → chunk-JWPE2WC7-BNQrzsa3.js} +1 -1
  26. package/web/dist/assets/{chunk-MOJQB5TN-B2x6q12r.js → chunk-MOJQB5TN-B51xJi0l.js} +1 -1
  27. package/web/dist/assets/{chunk-RYQCIY6F-XorU9Stx.js → chunk-RYQCIY6F-BCdsh9nc.js} +1 -1
  28. package/web/dist/assets/{chunk-V7JOEXUC-CsLa6pw5.js → chunk-V7JOEXUC-C7FFxC7C.js} +1 -1
  29. package/web/dist/assets/{chunk-VR4S4FIN-BBv9mwmj.js → chunk-VR4S4FIN-DWoFV0Pb.js} +1 -1
  30. package/web/dist/assets/{chunk-XXDRQBXY-Cjm4mm6l.js → chunk-XXDRQBXY-plM48yQb.js} +1 -1
  31. package/web/dist/assets/classDiagram-OUVF2IWQ-33Sv-tmv.js +1 -0
  32. package/web/dist/assets/classDiagram-v2-EOCWNBFH-33Sv-tmv.js +1 -0
  33. package/web/dist/assets/{cose-bilkent-JH36ORCC-Zw9ZQbVl.js → cose-bilkent-JH36ORCC-cPPsd8jq.js} +1 -1
  34. package/web/dist/assets/{cynefin-VYW2F7L2-BYnR9YLM.js → cynefin-VYW2F7L2-DLoMRcB-.js} +1 -1
  35. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Cx8L0dOv.js → cynefinDiagram-TSTJHNR4-DbxB1EcR.js} +1 -1
  36. package/web/dist/assets/{dagre-VKFMJZFB-7GaqVcqz.js → dagre-VKFMJZFB-Cl5J3v7U.js} +1 -1
  37. package/web/dist/assets/{diagram-FQU43EPY-BWSRPJvU.js → diagram-FQU43EPY-C_5l69HJ.js} +1 -1
  38. package/web/dist/assets/{diagram-G47NLZAW-BELuAE1e.js → diagram-G47NLZAW-B_nxM69x.js} +1 -1
  39. package/web/dist/assets/{diagram-NH7WQ7WH-DGXhPpri.js → diagram-NH7WQ7WH-BogLLCcM.js} +1 -1
  40. package/web/dist/assets/{diagram-OA4YK3LP-DV8HqN0T.js → diagram-OA4YK3LP-1HZ1DJgO.js} +1 -1
  41. package/web/dist/assets/{diagram-WEI45ONY-Clv1X3ZL.js → diagram-WEI45ONY-CZ9akwtz.js} +1 -1
  42. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-TpQx36oi.js → ebnfDiagram-CCIWWBDH-CCg2iaF2.js} +1 -1
  43. package/web/dist/assets/{erDiagram-Q63AITRT-DVVWd00c.js → erDiagram-Q63AITRT-BCYw6hLk.js} +1 -1
  44. package/web/dist/assets/{flowDiagram-23GEKE2U-Cdz_8_Dw.js → flowDiagram-23GEKE2U-B3O-UeAj.js} +1 -1
  45. package/web/dist/assets/{ganttDiagram-NO4QXBWP-zlco-o7u.js → ganttDiagram-NO4QXBWP-GJzz1tR4.js} +1 -1
  46. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-CfAZW1En.js → gitGraphDiagram-IHSO6WYX-Ch1cfaOe.js} +1 -1
  47. package/web/dist/assets/index-BUp5hFdX.css +1 -0
  48. package/web/dist/assets/{index-tQXCQojP.js → index-wYKm3Bnl.js} +6 -6
  49. package/web/dist/assets/{infoDiagram-FWYZ7A6U-0R-8efwI.js → infoDiagram-FWYZ7A6U-Bjo2ImAx.js} +1 -1
  50. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-lPpoXGlp.js → ishikawaDiagram-FXEZZL3T-Cknf936W.js} +1 -1
  51. package/web/dist/assets/{journeyDiagram-5HDEW3XC-D05vSVqu.js → journeyDiagram-5HDEW3XC-Chy2vDKP.js} +1 -1
  52. package/web/dist/assets/{kanban-definition-HUTT4EX6-ClaDdESv.js → kanban-definition-HUTT4EX6-BL4WMBwi.js} +1 -1
  53. package/web/dist/assets/{linear-CUnhHMvY.js → linear-B8XSGnZ8.js} +1 -1
  54. package/web/dist/assets/{mermaid.core-DaYS-X8Y.js → mermaid.core-CFdkmvEY.js} +4 -4
  55. package/web/dist/assets/{mindmap-definition-LN4V7U3C-N480VHds.js → mindmap-definition-LN4V7U3C-CHOBIkSR.js} +1 -1
  56. package/web/dist/assets/{papaparse.min-CNTcNgd8.js → papaparse.min-DnG1sqyK.js} +1 -1
  57. package/web/dist/assets/{pegDiagram-2B236MQR-CVWyJ5ck.js → pegDiagram-2B236MQR-VuMpZEdm.js} +1 -1
  58. package/web/dist/assets/{pieDiagram-ENE6RG2P-UR9uohPf.js → pieDiagram-ENE6RG2P-CkFTLWPp.js} +1 -1
  59. package/web/dist/assets/pilotswarm-CLJw14xm.js +97 -0
  60. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-D2SIOetn.js → quadrantDiagram-ABIIQ3AL-B74MHjsI.js} +1 -1
  61. package/web/dist/assets/{railroadDiagram-RFXS5EU6-OgN6qgOU.js → railroadDiagram-RFXS5EU6-CwcT-BI1.js} +1 -1
  62. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BSUURBBz.js → requirementDiagram-TGXJPOKE-CfV8rmaQ.js} +1 -1
  63. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-BBZhmSBZ.js → sankeyDiagram-HTMAVEWB-BECA4LLx.js} +1 -1
  64. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-C10IDNdW.js → sequenceDiagram-DBY2YBRQ-C80kbBkw.js} +1 -1
  65. package/web/dist/assets/{sizeCapture-X5ZJPWSS-B9787MHa.js → sizeCapture-X5ZJPWSS-BgdLZC94.js} +1 -1
  66. package/web/dist/assets/{stateDiagram-2N3HPSRC-BmSBo7es.js → stateDiagram-2N3HPSRC-DY_mnuOT.js} +1 -1
  67. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Dw3VhZ5V.js +1 -0
  68. package/web/dist/assets/{swimlanes-5IMT3BWC-DOXpv_KQ.js → swimlanes-5IMT3BWC-9m8kjDvw.js} +2 -2
  69. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DoOI0AiK.js +8 -0
  70. package/web/dist/assets/{timeline-definition-FHXFAJF6-SjhGfPdI.js → timeline-definition-FHXFAJF6-CRDqu4Os.js} +1 -1
  71. package/web/dist/assets/{vennDiagram-L72KCM5P-BiujYen6.js → vennDiagram-L72KCM5P-B2KNeFvN.js} +1 -1
  72. package/web/dist/assets/{wardleyDiagram-EHGQE667-6nLXBvwc.js → wardleyDiagram-EHGQE667-DC0vN3Ub.js} +1 -1
  73. package/web/dist/assets/{xychartDiagram-FW5EYKEG-Di7ioTvW.js → xychartDiagram-FW5EYKEG-DOY6Rwu4.js} +1 -1
  74. package/web/dist/index.html +3 -3
  75. package/web/runtime.js +10 -0
  76. package/web/dist/assets/channel-D5LojeMw.js +0 -1
  77. package/web/dist/assets/classDiagram-OUVF2IWQ-C5edc5Or.js +0 -1
  78. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C5edc5Or.js +0 -1
  79. package/web/dist/assets/index-CMtzFJre.css +0 -1
  80. package/web/dist/assets/pilotswarm-BG6hk0oE.js +0 -97
  81. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BqpsfoJI.js +0 -1
  82. package/web/dist/assets/swimlanesDiagram-G3AALYLV-k8WG_bWt.js +0 -8
@@ -47,6 +47,7 @@ import {
47
47
  selectModelPickerModal,
48
48
  selectNavigationError,
49
49
  selectProviderTable,
50
+ selectUsageSummary,
50
51
  selectReasoningEffortPickerModal,
51
52
  selectContextTierPickerModal,
52
53
  selectRenameSessionModal,
@@ -63,6 +64,8 @@ import {
63
64
  normalizeStoredLayoutAdjustments,
64
65
  normalizeStoredPinnedSessionIds,
65
66
  normalizeStoredSessionOrder,
67
+ computeFitWidthColumnLayout,
68
+ normalizeTableCellText,
66
69
  } from "pilotswarm/ui-core";
67
70
  import { useControllerSelector } from "./use-controller-state.js";
68
71
 
@@ -643,31 +646,6 @@ const PORTAL_COLUMN_RESIZER_PX = 16;
643
646
  */
644
647
  const PORTAL_CANVAS_MIN_PX = 480;
645
648
 
646
- /**
647
- * Wide enough to OPEN the canvas by default on a first visit.
648
- *
649
- * Sessions and chat need roughly 224px + 500px to stay comfortable, and the
650
- * canvas wants PORTAL_CANVAS_MIN_PX beside them. 1440 clears that with room to
651
- * spare and is a real device width (a 14" MacBook, a 900p desktop), so the
652
- * common laptop gets a canvas and a small window does not get three cramped
653
- * columns.
654
- */
655
- const PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX = 1440;
656
-
657
- /**
658
- * Whether this profile has ever expressed a preference about the two columns.
659
- *
660
- * The width-based default must fire only in its absence: someone who CLOSED
661
- * the canvas has stored `{canvasOpen: false}`, which is a choice and reads
662
- * nothing like "unset". A legacy rightPaneMode counts too — it is the same
663
- * preference in the older shape.
664
- */
665
- function hasStoredDesktopPanes(settings) {
666
- if (!settings || typeof settings !== "object") return false;
667
- if (settings.desktopPanes && typeof settings.desktopPanes === "object") return true;
668
- return settings.rightPaneMode === "canvas" || settings.rightPaneMode === "panes";
669
- }
670
-
671
649
  /**
672
650
  * The widths the two optional columns OPEN at, in pixels. 2:1 — the canvas is
673
651
  * the thing being looked at; diagnostics is a readout beside it.
@@ -2254,139 +2232,6 @@ function renderInlineMarkdown(source, theme, keyPrefix = "md") {
2254
2232
  });
2255
2233
  }
2256
2234
 
2257
- function normalizeTableCellText(value = "") {
2258
- return String(value || "")
2259
- .replace(/\s+/g, " ")
2260
- .trim();
2261
- }
2262
-
2263
- const FIT_WIDTH_FLEX_HEADER_KEYWORDS = [
2264
- "description",
2265
- "mechanism",
2266
- "details",
2267
- "notes",
2268
- "summary",
2269
- "message",
2270
- "comment",
2271
- "reason",
2272
- "body",
2273
- "content",
2274
- "explanation",
2275
- "rationale",
2276
- "one-liner",
2277
- ];
2278
-
2279
- const FIT_WIDTH_FLEX_MIN_MAX_LEN = 24;
2280
- const FIT_WIDTH_RIGID_CHAR_CAP = 32;
2281
- const FIT_WIDTH_MIN_RIGID_CHARS = 6;
2282
- const FIT_WIDTH_FLEX_MIN_CHARS = 30;
2283
- const FIT_WIDTH_FLEX_MAX_CHARS = 56;
2284
- const FIT_WIDTH_FLEX_MIN_FRACTION = 0.4;
2285
- const FIT_WIDTH_FLEX_TO_RIGID_RATIO = 1.4;
2286
- const FIT_WIDTH_MIN_EXTRA_CHARS = 2;
2287
-
2288
- /**
2289
- * Compute per-column layout for a fit-width markdown / chat table.
2290
- *
2291
- * Strategy:
2292
- * 1. Measure max cell length and "wrappability" (cells with whitespace) per column.
2293
- * 2. Identify a single "flex" column — long, prose-like, ideally with a
2294
- * header keyword like Description / Mechanism / Notes. This column
2295
- * absorbs overflow by wrapping aggressively.
2296
- * 3. Give every other column a budget = clamp(maxLen + padding, MIN, RIGID_CAP),
2297
- * so rigid columns stay at their content-fit width even when one
2298
- * sibling column has hundreds of characters of prose.
2299
- * 4. Give the flex column a bounded share of the rigid-column budget. A
2300
- * very long cell should wrap; it should not steal so much percentage
2301
- * width that compact columns like Count / Status collapse on phones.
2302
- * 5. Return a minimum table width in ch so the wrapper can scroll
2303
- * horizontally rather than forcing all columns below readable size.
2304
- *
2305
- * Returns { widths: ["12.34%", ...], flexIndex: number, minWidth: "64ch" }
2306
- * when a flex column is identified — the table renderer then forces
2307
- * table-layout: fixed, adds an `is-flex-column` class to the chosen column,
2308
- * and gives the table a readable minimum width. Returns null when no flex
2309
- * column is found, in which case the renderer falls back to the browser's
2310
- * auto-table-layout (which is already good for short / uniform tables).
2311
- *
2312
- * Background: the previous behavior used the browser's auto-table-layout
2313
- * unconditionally. That works well when columns are uniform but is biased
2314
- * toward wide columns when one column has prose hundreds of characters
2315
- * long (e.g. a Mechanism / Description column) — auto-layout distributes
2316
- * width proportional to (max-content − min-content), which lets the prose
2317
- * column squeeze the rigid columns down to a few characters each.
2318
- */
2319
- function computeFitWidthColumnLayout(rows = []) {
2320
- const columnCount = Math.max(0, ...rows.map((row) => row.length));
2321
- if (columnCount <= 0) return null;
2322
-
2323
- const headerRow = rows[0] || [];
2324
- const dataRowCount = Math.max(1, rows.length - 1);
2325
-
2326
- const stats = Array.from({ length: columnCount }, () => ({
2327
- max: 0,
2328
- spaceCells: 0,
2329
- }));
2330
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
2331
- const row = rows[rowIndex];
2332
- for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
2333
- const text = normalizeTableCellText(row[columnIndex] || "");
2334
- if (text.length > stats[columnIndex].max) stats[columnIndex].max = text.length;
2335
- if (rowIndex > 0 && /\s/.test(text)) stats[columnIndex].spaceCells += 1;
2336
- }
2337
- }
2338
-
2339
- let flexIndex = -1;
2340
- let flexScore = 0;
2341
- for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
2342
- const stat = stats[columnIndex];
2343
- if (stat.max < FIT_WIDTH_FLEX_MIN_MAX_LEN) continue;
2344
- const spaceRatio = stat.spaceCells / dataRowCount;
2345
- if (spaceRatio < 0.4) continue;
2346
- const headerText = normalizeTableCellText(headerRow[columnIndex] || "").toLowerCase();
2347
- const headerBonus = FIT_WIDTH_FLEX_HEADER_KEYWORDS.some((keyword) => headerText.includes(keyword)) ? 60 : 0;
2348
- const score = stat.max + headerBonus;
2349
- if (score > flexScore) {
2350
- flexScore = score;
2351
- flexIndex = columnIndex;
2352
- }
2353
- }
2354
-
2355
- if (flexIndex < 0) return null;
2356
-
2357
- const rigidBudgets = stats.map((stat, columnIndex) => {
2358
- if (columnIndex === flexIndex) return 0;
2359
- return Math.max(FIT_WIDTH_MIN_RIGID_CHARS, Math.min(stat.max + 2, FIT_WIDTH_RIGID_CHAR_CAP));
2360
- });
2361
- const sumRigid = rigidBudgets.reduce((sum, value) => sum + value, 0);
2362
- const flexStat = stats[flexIndex];
2363
- const flexBudget = Math.max(
2364
- FIT_WIDTH_FLEX_MIN_CHARS,
2365
- Math.min(
2366
- flexStat.max * 0.45,
2367
- FIT_WIDTH_FLEX_MAX_CHARS,
2368
- Math.max(FIT_WIDTH_FLEX_MIN_CHARS, sumRigid * FIT_WIDTH_FLEX_TO_RIGID_RATIO),
2369
- ),
2370
- );
2371
- const budgets = rigidBudgets.map((value, columnIndex) => (
2372
- columnIndex === flexIndex ? flexBudget : value
2373
- ));
2374
-
2375
- const totalBudget = budgets.reduce((sum, value) => sum + value, 0);
2376
- if (totalBudget > 0 && budgets[flexIndex] / totalBudget < FIT_WIDTH_FLEX_MIN_FRACTION) {
2377
- const sumRigid = totalBudget - budgets[flexIndex];
2378
- budgets[flexIndex] = sumRigid * (FIT_WIDTH_FLEX_MIN_FRACTION / (1 - FIT_WIDTH_FLEX_MIN_FRACTION));
2379
- }
2380
-
2381
- const finalTotal = budgets.reduce((sum, value) => sum + value, 0);
2382
- if (!(finalTotal > 0)) return null;
2383
- return {
2384
- widths: budgets.map((value) => `${((value / finalTotal) * 100).toFixed(2)}%`),
2385
- flexIndex,
2386
- minWidth: `${Math.ceil(finalTotal + FIT_WIDTH_MIN_EXTRA_CHARS)}ch`,
2387
- };
2388
- }
2389
-
2390
2235
  function buildTableColumnLabels(headerRows = [], columnCount = 0) {
2391
2236
  return Array.from({ length: columnCount }, (_, columnIndex) => {
2392
2237
  const label = (Array.isArray(headerRows) ? headerRows : [])
@@ -9137,6 +8982,17 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9137
8982
  onClick: () => controller.handleCommand(adminVisible ? UI_COMMANDS.CLOSE_ADMIN_CONSOLE : UI_COMMANDS.OPEN_ADMIN_CONSOLE).catch(() => {}),
9138
8983
  active: adminVisible,
9139
8984
  }]),
8985
+ // The Workspace MODE button. Budget and the Admin Console replace the
8986
+ // workspace; this is the way back, as a destination of its own rather
8987
+ // than "close whatever is open". Desktop only — the phone has no
8988
+ // admin console and its view cycle already sits in the header.
8989
+ ...(mobile ? [] : [{
8990
+ key: "workspace",
8991
+ icon: React.createElement(MainLayoutGlyph),
8992
+ label: "Workspace — sessions, chat and panels",
8993
+ onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_WORKSPACE).catch(() => {}),
8994
+ active: !adminVisible && !budgetOpen,
8995
+ }]),
9140
8996
  ];
9141
8997
 
9142
8998
  const renderButton = (def) => {
@@ -9206,17 +9062,27 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9206
9062
  );
9207
9063
  }
9208
9064
 
9209
- // Two groups. Budget, Diagnostics and the admin console are TOOLS — they
9210
- // act on the app rather than on the conversation — so they sit apart from
9211
- // the session controls, hard right. Everything else keeps its order.
9212
- const RIGHT_GROUP = new Set(["budget", "diagnostics", "admin"]);
9065
+ // Two kinds of button, two clusters (desktop only):
9066
+ //
9067
+ // left : the workspace's — new, filter, canvas, diagnostics
9068
+ // right : modes [workspace, budget, admin] │ theme · sign-out
9069
+ //
9070
+ // The whole left cluster belongs to the workspace — new session, filter,
9071
+ // canvas and diagnostics all act on it — so it exists only in Workspace
9072
+ // mode. Budget and the Admin Console replace the workspace; nothing on
9073
+ // the left applies there. Modes are exclusive (the controller closes one
9074
+ // when the other opens) and the Workspace button is the way back.
9075
+ const mode = adminVisible ? "admin" : (budgetOpen ? "budget" : "workspace");
9076
+ const ACTIONS = ["new", "filter"];
9077
+ const PANELS = ["canvas", "diagnostics"];
9078
+ const MODES = ["workspace", "budget", "admin"];
9213
9079
 
9214
9080
  // While the canvas is full screen, ANY other button first drops full
9215
9081
  // screen and then does its own job. Pressing Filter and watching nothing
9216
9082
  // happen behind a canvas — or watching a panel open where you cannot see
9217
9083
  // it — is the confusing half of every full-screen mode. The Canvas toggle
9218
- // is exempt: it already means "put the canvas away", and closing drops the
9219
- // flag in the reducer.
9084
+ // is exempt: it already means "put the canvas away", and closing drops
9085
+ // the flag in the reducer.
9220
9086
  const withRestore = (def) => (!canvasMaximized || def.key === "canvas" ? def : {
9221
9087
  ...def,
9222
9088
  onClick: (...args) => {
@@ -9224,21 +9090,35 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9224
9090
  return def.onClick?.(...args);
9225
9091
  },
9226
9092
  });
9093
+ const pick = (keys) => keys
9094
+ .map((key) => buttonDefs.find((d) => d.key === key))
9095
+ .filter(Boolean)
9096
+ .map(withRestore)
9097
+ .map(renderButton);
9098
+ const divider = (key) => React.createElement("span", { key, className: "ps-toolbar-divider", "aria-hidden": "true" });
9099
+
9100
+ // One run, no bar: all four act on the workspace.
9101
+ const leftCluster = mode === "workspace"
9102
+ ? [...pick(ACTIONS), ...pick(PANELS)]
9103
+ : [];
9104
+ const rightCluster = [
9105
+ ...pick(MODES),
9106
+ divider("mode-divider"),
9107
+ ...pick(["theme"]),
9108
+ ];
9227
9109
 
9228
- const leftButtons = buttonDefs.filter((d) => !RIGHT_GROUP.has(d.key)).map(withRestore);
9229
- const rightButtons = buttonDefs.filter((d) => RIGHT_GROUP.has(d.key)).map(withRestore);
9230
9110
  const toolbar = React.createElement("div", { className: "ps-toolbar" },
9231
9111
  // Three columns: left rail | main controls | right rail. Full-screen
9232
9112
  // canvas uses the otherwise-empty left rail for the normal actions so
9233
9113
  // they do not crowd the canvas metadata and controls across the top.
9234
9114
  React.createElement("div", { className: "ps-toolbar-side is-left" },
9235
- canvasMaximized ? leftButtons.map(renderButton) : null),
9115
+ canvasMaximized ? leftCluster : null),
9236
9116
  React.createElement("div", { className: "ps-toolbar-actions" },
9237
- canvasMaximized ? null : leftButtons.map(renderButton)),
9117
+ canvasMaximized ? null : leftCluster),
9238
9118
  React.createElement("div", { className: "ps-toolbar-side is-right" },
9239
9119
  // The portal header parks its version/status meta here, LEFT of
9240
9120
  // the tool buttons, and its sign-out glyph in the slot after
9241
- // them — one right-aligned cluster: meta · bug · settings · out.
9121
+ // them — one right-aligned cluster: meta · mode · theme · out.
9242
9122
  React.createElement("span", { className: "ps-toolbar-meta-slot", id: "ps-toolbar-meta-slot" }),
9243
9123
  // While the canvas is full screen its header controls (rev, zoom,
9244
9124
  // zen, restore) portal INTO this slot from CanvasPane — zoom state
@@ -9250,9 +9130,7 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9250
9130
  canvasMaximized
9251
9131
  ? React.createElement("span", { className: "ps-toolbar-divider", "aria-hidden": "true" })
9252
9132
  : null,
9253
- rightButtons.length
9254
- ? React.createElement("div", { className: "ps-toolbar-actions is-tools" }, rightButtons.map(renderButton))
9255
- : null,
9133
+ React.createElement("div", { className: "ps-toolbar-actions is-tools" }, rightCluster),
9256
9134
  React.createElement("span", { className: "ps-toolbar-signout-slot", id: "ps-toolbar-signout-slot" })),
9257
9135
  );
9258
9136
 
@@ -9723,7 +9601,12 @@ function ChatRightResizeHandle({ controller, target, adjust = 0 }) {
9723
9601
  const st = dragStateRef.current;
9724
9602
  if (!st) return;
9725
9603
  // Pointer right = chat grows = the neighbour gives up the pixels.
9726
- controller.dispatch({ type: actionType, [key]: st.startAdjust - (event.clientX - st.startX) });
9604
+ // Pointer left stops where chat reaches its floor: past that the
9605
+ // grid cannot give the right block more pixels, and an adjust
9606
+ // that keeps climbing anyway only inflates the stored width so
9607
+ // the next drag has to unwind it before anything moves.
9608
+ const next = st.startAdjust - (event.clientX - st.startX);
9609
+ controller.dispatch({ type: actionType, [key]: Math.min(next, st.maxAdjust) });
9727
9610
  };
9728
9611
  const onUp = () => {
9729
9612
  const st = dragStateRef.current;
@@ -9751,7 +9634,18 @@ function ChatRightResizeHandle({ controller, target, adjust = 0 }) {
9751
9634
  if (event.button !== 0) return;
9752
9635
  event.preventDefault();
9753
9636
  capturePointerForDrag(event);
9754
- dragStateRef.current = { startX: event.clientX, startAdjust: Number(adjust) || 0 };
9637
+ // How far the right block may still grow: exactly what chat can
9638
+ // give before it reaches its floor. Measured at drag start from
9639
+ // the chat slot (the main grid's last column), the same element
9640
+ // the grid template protects with its own minimum.
9641
+ const chatSlot = document.querySelector(".ps-workspace-main-grid > .ps-workspace-pane-slot:last-child");
9642
+ const chatWidth = chatSlot ? chatSlot.getBoundingClientRect().width : Number.POSITIVE_INFINITY;
9643
+ const startAdjust = Number(adjust) || 0;
9644
+ dragStateRef.current = {
9645
+ startX: event.clientX,
9646
+ startAdjust,
9647
+ maxAdjust: startAdjust + Math.max(0, chatWidth - PORTAL_MIN_CHAT_COLUMN_PX),
9648
+ };
9755
9649
  setDragging(true);
9756
9650
  document.body.classList.add("is-resizing-pane-x");
9757
9651
  },
@@ -9788,7 +9682,7 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
9788
9682
  // real space between the two panes. Its bounds are asymmetric:
9789
9683
  // -column/2 makes Inspector 0px; column/2-divider makes Activity
9790
9684
  // 0px. Using one symmetric half-range left an 8-9px pane remnant.
9791
- const next = clamp(st.start + (event.clientY - st.startY), st.minAdjust, st.maxAdjust);
9685
+ const next = clampNumber(st.start + (event.clientY - st.startY), st.minAdjust, st.maxAdjust);
9792
9686
  controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: next });
9793
9687
  };
9794
9688
  const onUp = () => {
@@ -9821,7 +9715,7 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
9821
9715
  const { minAdjust, maxAdjust } = getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight);
9822
9716
  dragStateRef.current = {
9823
9717
  startY: event.clientY,
9824
- start: clamp(Number(splitAdjust) || 0, minAdjust, maxAdjust),
9718
+ start: clampNumber(Number(splitAdjust) || 0, minAdjust, maxAdjust),
9825
9719
  minAdjust,
9826
9720
  maxAdjust,
9827
9721
  };
@@ -9838,7 +9732,7 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
9838
9732
  const { minAdjust, maxAdjust } = getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight);
9839
9733
  controller.dispatch({
9840
9734
  type: "ui/diagnosticsSplitAdjust",
9841
- diagnosticsSplitAdjust: clamp(
9735
+ diagnosticsSplitAdjust: clampNumber(
9842
9736
  (Number(splitAdjust) || 0) + (event.key === "ArrowDown" ? dividerHeight : -dividerHeight),
9843
9737
  minAdjust,
9844
9738
  maxAdjust,
@@ -10131,7 +10025,25 @@ function budgetCellEl(cell) {
10131
10025
  }, `/ ${cell.quotaLabel}`));
10132
10026
  }
10133
10027
 
10134
- /** What a cell says on hover: the period, its reset, and whether it is capped. */
10028
+ /**
10029
+ * The four parts of a period cell's used figure, in one cell of the split
10030
+ * row under a SELECTED provider or model row. A limit is on the total, so
10031
+ * the table's cell stays the one number a limit is about; this row says
10032
+ * what went into it, column by column.
10033
+ */
10034
+ function budgetSplitCellEl(cell) {
10035
+ if (!cell.split) return React.createElement("span", { className: "ps-budget-split" }, "—");
10036
+ // Top row: the two parts that add up to the figure. Bottom row: what
10037
+ // the input was made of — served from the cache, written to it.
10038
+ const cacheTitle = "Parts of the input, not additions to it: the total is input + output.";
10039
+ return React.createElement("span", { className: "ps-budget-split" },
10040
+ React.createElement("span", null, "in ", formatCompactNumber(cell.split.input)),
10041
+ React.createElement("span", null, "out ", formatCompactNumber(cell.split.output)),
10042
+ React.createElement("span", { title: cacheTitle }, "cache r ", formatCompactNumber(cell.split.cacheRead)),
10043
+ React.createElement("span", { title: cacheTitle }, "w ", formatCompactNumber(cell.split.cacheWrite)));
10044
+ }
10045
+
10046
+ /** What a cell says on hover: the period, its reset, whether it is capped, and what the figure is made of. */
10135
10047
  function budgetCellTitle(row, column, cell) {
10136
10048
  const who = row.kind === "model" ? `${row.providerName} · ${row.label}` : row.providerName;
10137
10049
  const cap = cell.uncapped
@@ -10142,7 +10054,8 @@ function budgetCellTitle(row, column, cell) {
10142
10054
  const blocked = cell.providerExhausted
10143
10055
  ? " The provider's own limit for this period is spent, so nothing runs against it whatever your share says."
10144
10056
  : "";
10145
- return `${who} · ${column.label} ${cap}. It ${budgetResetPhrase(cell.resetsLabel)}.${blocked}`;
10057
+ const split = cell.splitText ? ` Made of ${cell.splitText}.` : "";
10058
+ return `${who} · ${column.label} — ${cap}. It ${budgetResetPhrase(cell.resetsLabel)}.${blocked}${split}`;
10146
10059
  }
10147
10060
 
10148
10061
  /**
@@ -10247,6 +10160,19 @@ function ProviderTable({ controller, view, defaults = null }) {
10247
10160
  title: budgetCellTitle(row, column, row.cells[column.id]),
10248
10161
  }, budgetCellEl(row.cells[column.id]))));
10249
10162
 
10163
+ // Under a selected row: what each of its three figures is made of.
10164
+ const splitRow = (row) => React.createElement("tr", {
10165
+ key: `${row.key}:split`,
10166
+ className: `ps-budget-grid-split${row.kind === "model" ? " is-model" : ""}`,
10167
+ "aria-label": `What ${row.kind === "model" ? row.label : row.providerName} spent, by kind of token`,
10168
+ },
10169
+ // Empty on purpose: the name column keeps the split cells under their
10170
+ // period columns; the numbers explain themselves.
10171
+ React.createElement("td", { className: "ps-budget-grid-split-name" }),
10172
+ view.columns.map((column) => React.createElement("td", {
10173
+ key: column.id, className: "ps-budget-grid-num is-split", "data-period": column.label,
10174
+ }, budgetSplitCellEl(row.cells[column.id]))));
10175
+
10250
10176
  return React.createElement(React.Fragment, null,
10251
10177
  React.createElement("div", { className: "ps-budget-table-head" },
10252
10178
  React.createElement("span", { className: "ps-budget-label" },
@@ -10279,7 +10205,8 @@ function ProviderTable({ controller, view, defaults = null }) {
10279
10205
  view.columns.map((column) => React.createElement("th", {
10280
10206
  key: column.id, className: "ps-budget-grid-h is-num", scope: "col",
10281
10207
  }, column.label)))),
10282
- React.createElement("tbody", null, view.rows.map(bodyRow))));
10208
+ // A selected row is followed by its split row.
10209
+ React.createElement("tbody", null, view.rows.flatMap((row) => (row.selected ? [bodyRow(row), splitRow(row)] : [bodyRow(row)])))));
10283
10210
  }
10284
10211
 
10285
10212
  /**
@@ -10806,14 +10733,26 @@ function CreateProviderSheet({
10806
10733
  // Bounded and named here instead, where the rule can be said precisely.
10807
10734
  const badChars = trimmed !== "" && !/^[A-Za-z0-9._-]+$/.test(trimmed);
10808
10735
  const tooLong = trimmed.length > 64;
10736
+ // A workload-identity type carries no key: the worker authenticates as
10737
+ // itself. So the form asks for nothing to paste, and requiring a
10738
+ // credential would make the type impossible to add from this screen.
10739
+ const workloadIdentity = types.find((option) => option.id === type)?.usesWorkloadIdentity === true;
10740
+ // It runs on the worker's identity, which is the organization's, so it can
10741
+ // only be shared — a personal one would spend the cluster's account under
10742
+ // a name no administrator can cap. The server refuses it too.
10743
+ const sharedOnly = workloadIdentity;
10744
+ const effectiveShared = sharedOnly ? true : shared;
10809
10745
  const valid = Boolean(trimmed) && !taken && !badChars && !tooLong
10810
- && Boolean(type) && Boolean(credential.trim());
10746
+ && Boolean(type) && (workloadIdentity || Boolean(credential.trim()))
10747
+ && (!sharedOnly || isAdmin);
10811
10748
  const isGithub = /github/i.test(type);
10812
10749
  const submit = () => {
10813
10750
  if (!valid) return;
10814
10751
  const input = {
10815
- name: trimmed, type, shared,
10816
- credentials: isGithub ? { githubToken: credential.trim() } : { apiKey: credential.trim() },
10752
+ name: trimmed, type, shared: effectiveShared,
10753
+ credentials: workloadIdentity
10754
+ ? { kind: "workloadIdentity" }
10755
+ : isGithub ? { githubToken: credential.trim() } : { apiKey: credential.trim() },
10817
10756
  baseUrl: baseUrl.trim() || null,
10818
10757
  };
10819
10758
  setCredential("");
@@ -10826,7 +10765,7 @@ function CreateProviderSheet({
10826
10765
 
10827
10766
  return React.createElement(BudgetSheet, {
10828
10767
  title: updating ? `Update key for ${trimmed}` : (isGithub ? "Add GitHub Copilot provider" : "Add model provider"),
10829
- subtitle: shared ? "Shared" : "Personal",
10768
+ subtitle: effectiveShared ? "Shared" : "Personal",
10830
10769
  confirmLabel: updating ? "Update key" : "Add provider", busy, disabled: !valid, error, onCancel: cancel, onConfirm: submit,
10831
10770
  // The whole point of an update is what does NOT change. Saying so is
10832
10771
  // the difference between "replace the key" and "did I just make a
@@ -10862,33 +10801,45 @@ function CreateProviderSheet({
10862
10801
  }, types.length === 0
10863
10802
  ? React.createElement("option", { value: "" }, "No provider types are listed on this deployment")
10864
10803
  : types.map((option) => React.createElement("option", { key: option.id, value: option.id }, option.id)))),
10865
- React.createElement(SheetField, {
10866
- label: updating
10867
- ? (isGithub ? "New GitHub Copilot token" : "New API key")
10868
- : (isGithub ? "GitHub Copilot token" : "API key"),
10869
- hint: updating
10870
- ? "Replaces the current one. The existing key is never shown."
10871
- : "Paste the token value.",
10872
- },
10873
- React.createElement("input", {
10874
- className: "ps-budget-input", type: "password", autoComplete: "off",
10875
- "aria-label": updating
10876
- ? (isGithub ? "New GitHub Copilot token" : "New API key")
10877
- : (isGithub ? "GitHub Copilot token" : "API key"),
10878
- value: credential, placeholder: "Paste token",
10879
- onChange: (event) => { onDirty?.(); setCredential(event.target.value); },
10880
- // While updating this is the only editable field, and the Name input
10881
- // that carries the other Enter handler is disabled — so without this
10882
- // the sheet cannot be submitted from the keyboard at all.
10883
- onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
10884
- })),
10804
+ workloadIdentity
10805
+ ? React.createElement(SheetField, {
10806
+ label: "Authentication",
10807
+ hint: "Nothing is stored. The worker mints a short-lived token for each request.",
10808
+ },
10809
+ React.createElement("div", { className: "ps-budget-input is-static", role: "note" },
10810
+ "Use the Workload Identity configured in the worker"))
10811
+ : React.createElement(SheetField, {
10812
+ label: updating
10813
+ ? (isGithub ? "New GitHub Copilot token" : "New API key")
10814
+ : (isGithub ? "GitHub Copilot token" : "API key"),
10815
+ hint: updating
10816
+ ? "Replaces the current one. The existing key is never shown."
10817
+ : "Paste the token value.",
10818
+ },
10819
+ React.createElement("input", {
10820
+ className: "ps-budget-input", type: "password", autoComplete: "off",
10821
+ "aria-label": updating
10822
+ ? (isGithub ? "New GitHub Copilot token" : "New API key")
10823
+ : (isGithub ? "GitHub Copilot token" : "API key"),
10824
+ value: credential, placeholder: "Paste token",
10825
+ onChange: (event) => { onDirty?.(); setCredential(event.target.value); },
10826
+ // While updating this is the only editable field, and the Name input
10827
+ // that carries the other Enter handler is disabled — so without this
10828
+ // the sheet cannot be submitted from the keyboard at all.
10829
+ onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
10830
+ })),
10885
10831
  !updating ? React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
10886
10832
  React.createElement("input", {
10887
10833
  className: "ps-budget-input", value: baseUrl, placeholder: "https://…",
10888
10834
  "aria-label": "Base URL",
10889
10835
  onChange: (event) => setBaseUrl(event.target.value),
10890
10836
  })) : null,
10891
- isAdmin && !updating ? React.createElement(SheetField, { label: "Access" },
10837
+ sharedOnly && !updating ? React.createElement(SheetField, {
10838
+ label: "Access",
10839
+ hint: "It runs on the worker's own identity, which belongs to the organization, so it cannot be personal.",
10840
+ },
10841
+ React.createElement("div", { className: "ps-budget-input is-static", role: "note" }, "Shared"))
10842
+ : isAdmin && !updating ? React.createElement(SheetField, { label: "Access" },
10892
10843
  React.createElement(Segmented, {
10893
10844
  value: shared ? "shared" : "mine", onChange: (value) => setShared(value === "shared"),
10894
10845
  label: "Access",
@@ -10941,10 +10892,289 @@ function budgetViewDeps(state) {
10941
10892
  Boolean(state.ui?.budgetOpen), budget.loading, budget.refreshing, budget.loaded,
10942
10893
  budget.error, budget.grid, budget.paused, budget.overall, budget.selectedProvider,
10943
10894
  budget.selectedScope, budget.missingProvider, budget.series,
10895
+ budget.tab, budget.summary,
10944
10896
  state.auth?.authorization?.role, state.admin?.profile,
10945
10897
  ];
10946
10898
  }
10947
10899
 
10900
+ // ─── Cluster summary tab ────────────────────────────────────────────────
10901
+
10902
+ const SUMMARY_RANGES = [14, 30, 90];
10903
+ const SUMMARY_PRESETS = [
10904
+ { id: "all", label: "All providers" },
10905
+ { id: "shared", label: "Shared providers" },
10906
+ { id: "users", label: "User providers" },
10907
+ ];
10908
+
10909
+ function summaryPresetLabel(view) {
10910
+ if (view.preset === "all") return "All providers";
10911
+ if (view.preset === "shared") return "Shared providers";
10912
+ if (view.preset === "users") return "User providers";
10913
+ const n = view.selectedProviders.length;
10914
+ return n === 0 ? "No providers" : (n === 1 ? view.selectedProviders[0] : `${n} providers`);
10915
+ }
10916
+
10917
+ /**
10918
+ * The provider picker: three presets and a checkbox per provider. A preset
10919
+ * expands to an exact list of names before the request goes out — the
10920
+ * database never has to know what "Shared" meant on the day.
10921
+ */
10922
+ function SummaryProviderPicker({ view, onChange }) {
10923
+ const [open, setOpen] = React.useState(false);
10924
+ const ref = React.useRef(null);
10925
+ React.useEffect(() => {
10926
+ if (!open) return undefined;
10927
+ const onDown = (event) => { if (ref.current && !ref.current.contains(event.target)) setOpen(false); };
10928
+ const onKey = (event) => { if (event.key === "Escape") setOpen(false); };
10929
+ document.addEventListener("pointerdown", onDown);
10930
+ document.addEventListener("keydown", onKey);
10931
+ return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
10932
+ }, [open]);
10933
+
10934
+ const namesFor = (preset) => (preset === "all" ? [] : view.providers
10935
+ .filter((p) => (preset === "shared" ? p.class === "shared" : p.class !== "shared"))
10936
+ .map((p) => p.name));
10937
+ const checked = new Set(view.preset === "all"
10938
+ ? view.providers.map((p) => p.name)
10939
+ : (view.preset === "custom" ? view.selectedProviders : namesFor(view.preset)));
10940
+
10941
+ // A preset is a whole answer, so the menu closes on it; a checkbox is
10942
+ // one step of a hand-picked list, so the menu stays open for the next.
10943
+ const choosePreset = (preset) => { onChange({ preset, providers: namesFor(preset) }); setOpen(false); };
10944
+ const toggle = (name) => {
10945
+ const next = new Set(checked);
10946
+ if (next.has(name)) next.delete(name); else next.add(name);
10947
+ onChange({ preset: "custom", providers: Array.from(next) });
10948
+ };
10949
+
10950
+ return React.createElement("div", { className: "ps-summary-picker", ref },
10951
+ React.createElement("button", {
10952
+ type: "button",
10953
+ className: `ps-mini-button ps-summary-picker__button${open ? " is-open" : ""}`,
10954
+ "aria-haspopup": "listbox",
10955
+ "aria-expanded": open,
10956
+ "aria-label": "Providers filter",
10957
+ onClick: () => setOpen((v) => !v),
10958
+ }, summaryPresetLabel(view), " ", React.createElement("span", { "aria-hidden": "true" }, "▾")),
10959
+ open ? React.createElement("div", { className: "ps-summary-picker__menu", role: "listbox", "aria-label": "Providers" },
10960
+ SUMMARY_PRESETS.map((preset) => React.createElement("button", {
10961
+ key: preset.id,
10962
+ type: "button",
10963
+ role: "option",
10964
+ "aria-selected": view.preset === preset.id,
10965
+ className: `ps-summary-picker__preset${view.preset === preset.id ? " is-on" : ""}`,
10966
+ onClick: () => choosePreset(preset.id),
10967
+ }, preset.label)),
10968
+ React.createElement("div", { className: "ps-summary-picker__rule", "aria-hidden": "true" }),
10969
+ React.createElement("button", {
10970
+ type: "button", className: "ps-summary-picker__preset is-clear",
10971
+ onClick: () => onChange({ preset: "custom", providers: [] }),
10972
+ }, "Clear all"),
10973
+ view.providers.length === 0
10974
+ ? React.createElement("div", { className: "ps-summary-picker__empty" }, "No providers to choose from.")
10975
+ : view.providers.map((p) => React.createElement("label", { key: p.name, className: "ps-summary-picker__row" },
10976
+ React.createElement("input", {
10977
+ type: "checkbox",
10978
+ checked: checked.has(p.name),
10979
+ onChange: () => toggle(p.name),
10980
+ "aria-label": `Include ${p.name}`,
10981
+ }),
10982
+ React.createElement("span", { className: "ps-summary-picker__name" }, p.name),
10983
+ React.createElement("span", { className: `ps-scope-badge is-${p.class}` }, p.class === "shared" ? "shared" : "user")))) : null);
10984
+ }
10985
+
10986
+ function summaryTokens(n) {
10987
+ return formatCompactNumber(Number(n) || 0);
10988
+ }
10989
+
10990
+ /**
10991
+ * The stacked bar chart: one bar per UTC day, drawn as SVG so it needs no
10992
+ * library and scales with the pane. A bar is the day's total, input +
10993
+ * output; the input segment is drawn as two — the part that was not in
10994
+ * the cache, and the part served from or written to it — because the cache
10995
+ * figures are parts of the input, not additions to it.
10996
+ * Linear by default; when one day is more than ten times the median of the
10997
+ * others, the axis goes to log10(1 + n) so the rest are not hairlines.
10998
+ */
10999
+ /**
11000
+ * Log or linear? Log when one day is more than ten times the median of the
11001
+ * days that had anything — otherwise a spike turns every other day into a
11002
+ * hairline. Shared by the chart and the card header that names the scale.
11003
+ */
11004
+ function summaryLogScale(series) {
11005
+ const totals = series.map((d) => d.total);
11006
+ const max = Math.max(0, ...totals);
11007
+ const sorted = totals.filter((t) => t > 0).sort((a, b) => a - b);
11008
+ const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
11009
+ return median > 0 && max > median * 10;
11010
+ }
11011
+
11012
+ function SummaryChart({ series }) {
11013
+ const W = 720; const H = 190; const padL = 58; const padB = 22; const padT = 8;
11014
+ const totals = series.map((d) => d.total);
11015
+ const max = Math.max(0, ...totals);
11016
+ const log = summaryLogScale(series);
11017
+ const scale = (v) => (log ? Math.log10(1 + v) : v);
11018
+ const top = scale(max) || 1;
11019
+ const innerH = H - padB - padT;
11020
+ const y = (v) => innerH - (scale(v) / top) * innerH;
11021
+ const n = Math.max(1, series.length);
11022
+ const slot = (W - padL) / n;
11023
+ const barW = Math.max(3, slot * 0.66);
11024
+ // Ticks: quarters of the range on a linear axis; powers of ten on a log
11025
+ // axis, so the labels are numbers a person recognises (10K, 1M) rather
11026
+ // than wherever a quarter of log10(max) happens to land.
11027
+ const ticks = log
11028
+ ? [0, ...Array.from({ length: Math.floor(Math.log10(Math.max(10, max))) }, (_, i) => Math.pow(10, i + 1))]
11029
+ .filter((v) => v <= max || v === 0)
11030
+ .map((v) => ({ f: scale(v) / top, label: summaryTokens(v) }))
11031
+ : [0, 0.25, 0.5, 0.75, 1].map((f) => ({ f, label: summaryTokens(f * max) }));
11032
+ const labelEvery = n > 40 ? 10 : (n > 20 ? 5 : 1);
11033
+ return React.createElement("svg", {
11034
+ className: "ps-summary-chart", viewBox: `0 0 ${W} ${H}`, role: "img",
11035
+ "aria-label": `Tokens per day, ${series.length} days${log ? ", log scale" : ""}`,
11036
+ },
11037
+ ticks.map((t) => React.createElement("g", { key: t.label + t.f },
11038
+ React.createElement("line", { x1: padL, x2: W, y1: padT + innerH - t.f * innerH, y2: padT + innerH - t.f * innerH, className: "ps-summary-chart__grid" }),
11039
+ React.createElement("text", { x: padL - 6, y: padT + innerH - t.f * innerH + 3, className: "ps-summary-chart__tick", textAnchor: "end" }, t.label))),
11040
+ series.map((d, i) => {
11041
+ const x = padL + i * slot + (slot - barW) / 2;
11042
+ // Stack bottom-up: uncached input, cache read, cache write,
11043
+ // output. The four add up to input + output — the day's total —
11044
+ // because the two cache parts are carved out of the input. Under
11045
+ // a log axis the stack is the log of the running total, so
11046
+ // segment heights stay honest to the whole bar.
11047
+ const cr = Math.min(d.input, d.cacheRead);
11048
+ const cw = Math.min(d.input - cr, d.cacheWrite);
11049
+ const cum1 = d.input - cr - cw; const cum2 = cum1 + cr; const cum3 = d.input; const cum4 = cum3 + d.output;
11050
+ const yb = padT + innerH;
11051
+ const y1 = padT + y(cum1); const y2 = padT + y(cum2); const y3 = padT + y(cum3); const y4 = padT + y(cum4);
11052
+ const title = `${d.day}: ${summaryTokens(d.total)} tokens · in ${summaryTokens(d.input)} (cache r ${summaryTokens(d.cacheRead)} · w ${summaryTokens(d.cacheWrite)}) · out ${summaryTokens(d.output)} · ${d.turns} turns`;
11053
+ return React.createElement("g", { key: d.day, className: "ps-summary-chart__bar" },
11054
+ React.createElement("title", null, title),
11055
+ React.createElement("rect", { x, y: y1, width: barW, height: Math.max(0, yb - y1), className: "is-in" }),
11056
+ React.createElement("rect", { x, y: y2, width: barW, height: Math.max(0, y1 - y2), className: "is-cache" }),
11057
+ React.createElement("rect", { x, y: y3, width: barW, height: Math.max(0, y2 - y3), className: "is-cache-w" }),
11058
+ React.createElement("rect", { x, y: y4, width: barW, height: Math.max(0, y3 - y4), className: "is-out" }),
11059
+ (i % labelEvery === 0 || i === n - 1)
11060
+ ? React.createElement("text", { x: x + barW / 2, y: H - 6, className: "ps-summary-chart__day", textAnchor: "middle" }, d.day.slice(5))
11061
+ : null);
11062
+ }));
11063
+ }
11064
+
11065
+ function SummarySparkline({ values }) {
11066
+ const max = Math.max(0, ...values);
11067
+ // 90 days at 4px is a 450px column; narrower bars past a month.
11068
+ return React.createElement("span", {
11069
+ className: "ps-summary-spark", "aria-hidden": "true",
11070
+ style: { gridAutoColumns: values.length > 30 ? "2px" : "4px" },
11071
+ },
11072
+ values.map((v, i) => React.createElement("i", { key: i, style: { height: `${max > 0 ? Math.max(v > 0 ? 8 : 0, (v / max) * 100) : 0}%` } })));
11073
+ }
11074
+
11075
+ function SummaryKpi({ label, w }) {
11076
+ return React.createElement("div", { className: "ps-summary-kpi" },
11077
+ React.createElement("div", { className: "ps-summary-kpi__label" }, label),
11078
+ React.createElement("div", { className: "ps-summary-kpi__value" }, summaryTokens(w.total)),
11079
+ // total = in + out; the cache pair is what the input was made of.
11080
+ React.createElement("div", { className: "ps-summary-kpi__split", title: "Total is input + output. Cache read and write are parts of the input, not additions to it." },
11081
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-in" }), "in ", summaryTokens(w.input)),
11082
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-cache" }), "cache r ", summaryTokens(w.cacheRead)),
11083
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-cache-w" }), "w ", summaryTokens(w.cacheWrite)),
11084
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-out" }), "out ", summaryTokens(w.output)),
11085
+ React.createElement("span", null, `· ${w.turns} turns`)));
11086
+ }
11087
+
11088
+ function summaryViewDeps(state) {
11089
+ const s = state.budget?.summary || {};
11090
+ return [s.loading, s.error, s.fetchedAt, s.days, s.preset, (s.providers || []).join(","), (state.budget?.grid || []).length];
11091
+ }
11092
+
11093
+ /**
11094
+ * The Cluster summary tab. Everything on it comes from ONE answer to
11095
+ * getProviderUsageSummary, so the KPIs, the chart and the model table can
11096
+ * never disagree about which turns were counted.
11097
+ */
11098
+ function ClusterSummaryPane({ controller }) {
11099
+ const deps = useControllerSelector(controller, summaryViewDeps, shallowEqualObject);
11100
+ const view = React.useMemo(() => selectUsageSummary(controller.getState()), [controller, deps]);
11101
+ React.useEffect(() => {
11102
+ if (!view.loaded && !view.loading && !view.error) controller.loadUsageSummary().catch(() => {});
11103
+ }, [controller]);
11104
+
11105
+ const setRange = (days) => controller.setUsageSummaryFilter({ days }).catch(() => {});
11106
+ const setProviders = ({ preset, providers }) => controller.setUsageSummaryFilter({ preset, providers }).catch(() => {});
11107
+ const classes = view.classes.filter((c) => c.total > 0);
11108
+ const rangeLabel = `last ${view.days} days`;
11109
+
11110
+ return React.createElement("div", { className: `ps-summary${view.loading ? " is-loading" : ""}` },
11111
+ React.createElement("div", { className: "ps-summary__filters" },
11112
+ React.createElement(SummaryProviderPicker, { view, onChange: setProviders }),
11113
+ React.createElement("div", { className: "ps-budget-seg", role: "group", "aria-label": "Window" },
11114
+ SUMMARY_RANGES.map((days) => React.createElement("button", {
11115
+ key: days, type: "button",
11116
+ className: `ps-budget-seg__button${view.days === days ? " is-on" : ""}`,
11117
+ "aria-pressed": view.days === days,
11118
+ "aria-label": `Last ${days} days`,
11119
+ onClick: () => setRange(days),
11120
+ }, `${days}d`))),
11121
+ React.createElement("span", { className: "ps-summary__scope" },
11122
+ view.scope === "cluster" ? "Whole cluster, system sessions included" : "Your own turns")),
11123
+ view.error ? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
11124
+ React.createElement("div", null, "The summary could not be read. "),
11125
+ React.createElement("div", { className: "ps-budget-sub" }, view.error)) : null,
11126
+ React.createElement("div", { className: "ps-summary__kpis" },
11127
+ React.createElement(SummaryKpi, { label: "Today (UTC)", w: view.windows.day }),
11128
+ React.createElement(SummaryKpi, { label: "Last 7 days", w: view.windows.week }),
11129
+ React.createElement(SummaryKpi, { label: "Last 30 days", w: view.windows.month })),
11130
+ React.createElement("div", { className: "ps-summary__card" },
11131
+ React.createElement("div", { className: "ps-summary__card-head" },
11132
+ React.createElement("b", null, "Tokens per day"),
11133
+ React.createElement("span", { className: "ps-summary__legend" },
11134
+ React.createElement("span", { title: "Prompt tokens that were neither served from nor written to the cache" }, React.createElement("i", { className: "ps-summary-dot is-in" }), "input"),
11135
+ React.createElement("span", { title: "The part of the input served from the cache" }, React.createElement("i", { className: "ps-summary-dot is-cache" }), "cache read"),
11136
+ React.createElement("span", { title: "The part of the input written to the cache" }, React.createElement("i", { className: "ps-summary-dot is-cache-w" }), "cache write"),
11137
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-out" }), "output")),
11138
+ React.createElement("span", { className: "ps-summary__muted" }, `${rangeLabel} · ${summaryTokens(view.windowTotal)} tokens`),
11139
+ // Named here, not painted over the bars: a spike is exactly
11140
+ // when the tallest bar reaches the top-right corner.
11141
+ summaryLogScale(view.series)
11142
+ ? React.createElement("span", { className: "ps-summary__tag", title: "One day is more than ten times the median, so the axis is log10 — otherwise every other day is a hairline." }, "log scale")
11143
+ : null),
11144
+ view.windowTotal > 0 || view.loaded
11145
+ ? React.createElement(SummaryChart, { series: view.series })
11146
+ : null,
11147
+ classes.length > 1 ? React.createElement("div", { className: "ps-summary__muted" },
11148
+ "By charge: ", classes.map((c, i) => `${i ? " · " : ""}${c.chargeClass} ${summaryTokens(c.total)}`).join("")) : null),
11149
+ React.createElement("div", { className: "ps-summary__card" },
11150
+ React.createElement("div", { className: "ps-summary__card-head" },
11151
+ React.createElement("b", null, "By model"),
11152
+ React.createElement("span", { className: "ps-summary__muted" }, `${rangeLabel} · across providers, efforts and context tiers`)),
11153
+ view.models.length === 0
11154
+ ? React.createElement("div", { className: "ps-budget-empty" }, view.loaded ? "No token usage in this window." : (view.loading ? "Reading…" : ""))
11155
+ : React.createElement("div", { className: "ps-summary__table-wrap" },
11156
+ React.createElement("table", { className: "ps-summary__table" },
11157
+ React.createElement("thead", null, React.createElement("tr", null,
11158
+ [["Model"], ["Turns"], ["Providers", "Distinct provider names in the ledger for this window — including providers since deleted"],
11159
+ ["Input", "Prompt tokens, including what was served from and written to the cache"], ["Output"],
11160
+ ["Cache read", "The part of Input served from the cache"], ["Cache write", "The part of Input written to the cache"],
11161
+ ["Total", "Input + Output"], ["Share"], ["Trend"]]
11162
+ .map(([h, title]) => React.createElement("th", { key: h, title }, h)))),
11163
+ React.createElement("tbody", null, view.models.map((m) => React.createElement("tr", { key: m.model },
11164
+ React.createElement("td", { className: "is-model" }, m.model),
11165
+ React.createElement("td", null, m.turns),
11166
+ React.createElement("td", null, m.providers),
11167
+ React.createElement("td", null, summaryTokens(m.input)),
11168
+ React.createElement("td", null, summaryTokens(m.output)),
11169
+ React.createElement("td", null, summaryTokens(m.cacheRead)),
11170
+ React.createElement("td", null, summaryTokens(m.cacheWrite)),
11171
+ React.createElement("td", { className: "is-total" }, summaryTokens(m.total)),
11172
+ React.createElement("td", { className: "is-share" },
11173
+ React.createElement("i", { className: "ps-summary-share", style: { width: `${Math.max(1, Math.round(m.share * 100))}px` } }),
11174
+ `${(m.share * 100).toFixed(m.share < 0.1 ? 1 : 0)}%`),
11175
+ React.createElement("td", null, React.createElement(SummarySparkline, { values: m.sparkline })))))))));
11176
+ }
11177
+
10948
11178
  /**
10949
11179
  * The whole surface: a head, one table, and the selected provider's days.
10950
11180
  *
@@ -11146,24 +11376,17 @@ function ProviderBudgetView({ controller }) {
11146
11376
  React.createElement("span", { className: "ps-budget-coin", "aria-hidden": "true" }, "◎"),
11147
11377
  React.createElement("h2", { className: "ps-budget-h" }, "Providers & Budgets"),
11148
11378
  React.createElement("span", { className: "ps-budget-sub" }, "Usage and limits")),
11379
+ // Refresh only. Leaving (to the workspace, or to the admin console)
11380
+ // is the toolbar's Mode cluster; a ✕ and a gear here were second,
11381
+ // unlabelled copies of buttons already on screen. Esc still closes.
11149
11382
  React.createElement("div", { className: "ps-budget-head-actions" },
11150
- React.createElement(IconButton, {
11151
- icon: React.createElement(CogGlyph),
11152
- label: "Model providers",
11153
- className: "ps-mini-button",
11154
- onClick: () => { controller.closeBudget(); void controller.openAdminConsole(); },
11155
- }),
11156
11383
  React.createElement(IconButton, {
11157
11384
  icon: "↻",
11158
11385
  label: "Refresh providers and budgets",
11159
11386
  className: "ps-mini-button",
11160
11387
  disabled: view.loading || view.refreshing,
11161
11388
  onClick: () => controller.refreshProviderTable().catch(() => {}),
11162
- }),
11163
- React.createElement("button", {
11164
- type: "button", className: "ps-budget-close", onClick: () => controller.closeBudget(),
11165
- "aria-label": "Close providers and budgets", title: "Close (Esc)",
11166
- }, "✕")));
11389
+ })));
11167
11390
 
11168
11391
  // A read that FAILED is never dressed up as an empty one: "no providers"
11169
11392
  // and "we could not find out" are different facts, and only one of them is
@@ -11255,7 +11478,18 @@ function ProviderBudgetView({ controller }) {
11255
11478
 
11256
11479
  return React.createElement("div", { className: "ps-budget-surface" },
11257
11480
  head,
11258
- React.createElement("div", { className: "ps-budget" }, body),
11481
+ // Two tabs: the per-provider table (meters, people's turns, limits)
11482
+ // and the Cluster summary (the ledger, every turn, by model).
11483
+ React.createElement("div", { className: "ps-budget-tabs", role: "tablist" },
11484
+ [["providers", "Providers"], ["summary", "Cluster summary"]].map(([id, label]) => React.createElement("button", {
11485
+ key: id, type: "button", role: "tab",
11486
+ "aria-selected": (controller.getState().budget?.tab || "providers") === id,
11487
+ className: `ps-budget-tab${(controller.getState().budget?.tab || "providers") === id ? " is-on" : ""}`,
11488
+ onClick: () => controller.setBudgetTab(id),
11489
+ }, label))),
11490
+ (controller.getState().budget?.tab || "providers") === "summary"
11491
+ ? React.createElement("div", { className: "ps-budget" }, React.createElement(ClusterSummaryPane, { controller }))
11492
+ : React.createElement("div", { className: "ps-budget" }, body),
11259
11493
  sheetEl);
11260
11494
  }
11261
11495
 
@@ -12342,18 +12576,11 @@ function AdminConsolePanel({ controller, mobile = false }) {
12342
12576
  closeProviderSheet();
12343
12577
  }, [closeProviderSheet, controller, providerSheet]);
12344
12578
 
12345
- const principalLabel = formatAdminPrincipalLabel(view.principal);
12346
-
12579
+ // Title only. The signed-in person is already in the portal header, and
12580
+ // the way out is the toolbar's Mode cluster (Workspace) \u2014 a \u2715 here was a
12581
+ // second, unlabelled way to do the same thing.
12347
12582
  const header = React.createElement("header", { className: "ps-admin-console__header" },
12348
- React.createElement("h2", null, view.isAdmin ? "Admin Console" : "Settings"),
12349
- React.createElement("span", { className: "ps-admin-console__who" }, principalLabel),
12350
- React.createElement("button", {
12351
- type: "button",
12352
- className: "ps-mini-button is-icon",
12353
- onClick: onClose,
12354
- title: view.isAdmin ? "Close the admin console" : "Close settings",
12355
- "aria-label": view.isAdmin ? "Close the admin console" : "Close settings",
12356
- }, "\u2715"));
12583
+ React.createElement("h2", null, view.isAdmin ? "Admin Console" : "Settings"));
12357
12584
 
12358
12585
  const providerSection = React.createElement(AdminModelProvidersSection, {
12359
12586
  controller,
@@ -13182,7 +13409,9 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
13182
13409
  // drops the cluster-default flag, the allowance, any hold, the
13183
13410
  // system-use routing and the usage history, i.e. everything this
13184
13411
  // feature exists to preserve.
13185
- (personal || isAdmin) ? React.createElement("button", {
13412
+ // A workload-identity provider has no stored key: the worker
13413
+ // authenticates as itself, and the server refuses the update.
13414
+ (personal || isAdmin) && !provider.usesWorkloadIdentity ? React.createElement("button", {
13186
13415
  type: "button",
13187
13416
  className: "ps-mini-button",
13188
13417
  // Several providers means several identical "Update Key"
@@ -13279,6 +13508,12 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
13279
13508
  React.createElement(AdminProviderRows, {
13280
13509
  providers: providers.sharedProviders || [], isAdmin: true, personal: false, busy: pending,
13281
13510
  onSystemUse: () => {}, onDelete: deleteProvider,
13511
+ // The same handler as the personal rows: it reads
13512
+ // provider.class and opens the sheet in shared mode. 0.5.47
13513
+ // showed the button on shared rows without wiring this, so
13514
+ // the click threw "onUpdate is not a function" and nothing
13515
+ // opened.
13516
+ onUpdate: onUpdatePersonal,
13282
13517
  })),
13283
13518
  React.createElement("div", { className: "ps-admin-provider-block" },
13284
13519
  React.createElement(AdminDefaultSelect, {
@@ -13728,29 +13963,15 @@ export function PilotSwarmWebApp({ controller }) {
13728
13963
  defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
13729
13964
  controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
13730
13965
  );
13731
- // First visit on a desktop wide enough to hold it: open the
13732
- // canvas. A workspace that opens as chat alone hides the thing
13733
- // most sessions are FOR, and the canvas needs real width to be
13734
- // worth showing below the breakpoint chat alone is better.
13735
- //
13736
- // Folded into the settings rather than dispatched separately.
13737
- // A separate dispatch raced the re-baseline at the end of this
13738
- // same poll: the new value was recorded as "already saved",
13739
- // never written, and the next poll put it back. Going through
13740
- // the settings means the normal apply-and-save path carries it.
13741
- //
13742
- // Only when NOTHING is stored. An explicit choice, including
13743
- // closing the canvas, is a preference from then on. Not
13744
- // live-responsive either: narrowing the window never closes a
13745
- // canvas the user opened on purpose.
13746
- const wantsDefaultCanvas = !hasStoredDesktopPanes(remoteNormalized)
13747
- && !isNarrowViewport()
13748
- && window.innerWidth >= PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX;
13966
+ // The default workspace is sessions + chat, nothing else. A
13967
+ // first visit on a wide desktop used to open the canvas as
13968
+ // well; that was withdrawn (2026-08-27) the canvas and
13969
+ // diagnostics open when a person opens them (or an agent
13970
+ // draws, see canvas/flip), and a stored choice is honoured.
13749
13971
  const settings = materializeProfileSettings(
13750
13972
  profile?.profileSettings,
13751
13973
  defaultProfileSettingsRef.current,
13752
13974
  );
13753
- if (wantsDefaultCanvas) settings.desktopPanes = { canvasOpen: true, diagnosticsOpen: false };
13754
13975
  const settingsJson = JSON.stringify(settings);
13755
13976
  const currentSettingsBeforeApply = profileSettingsFromViewState(
13756
13977
  controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
@@ -14014,7 +14235,16 @@ export function PilotSwarmWebApp({ controller }) {
14014
14235
  // rail width is remembered for THIS session only.
14015
14236
  ? `${zenRailPx}px var(--ps-resizer-track, 16px) minmax(0, 1fr)`
14016
14237
  : (rightSideActive
14017
- ? `minmax(0, 1fr) var(--ps-resizer-track, 16px) min(${rightSidePx}px, 60%)`
14238
+ // The right block gets its pixels until CHAT is at its
14239
+ // floor — not "60% of the window". With canvas and
14240
+ // diagnostics both open, a 60% cap bound early (a
14241
+ // 640px canvas + 320px diagnostics is 60% of a
14242
+ // 1600px window), and past it a drag on the chat
14243
+ // seam could not widen the block: the canvas kept its
14244
+ // fixed pixels and the diagnostics column, the flex
14245
+ // one, gave them up — so the chat↔canvas seam moved
14246
+ // the canvas↔diagnostics seam instead.
14247
+ ? `minmax(0, 1fr) var(--ps-resizer-track, 16px) min(${rightSidePx}px, calc(100% - var(--ps-resizer-track, 16px) - ${PORTAL_MIN_CHAT_COLUMN_PX}px))`
14018
14248
  : "minmax(0, 1fr)")),
14019
14249
  },
14020
14250
  },