pilotswarm 0.5.47 → 0.5.48

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 +66 -0
  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 +112 -0
  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 +427 -223
  15. package/web/dist/assets/{abnfDiagram-VRR7QNED-VPL8m_9W.js → abnfDiagram-VRR7QNED-6rYy4Wj8.js} +1 -1
  16. package/web/dist/assets/{arc-T5I3-c1B.js → arc-CrRZ1Za7.js} +1 -1
  17. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DEP70bg6.js → architectureDiagram-ZJ3FMSHR-CPe2QuQL.js} +1 -1
  18. package/web/dist/assets/{blockDiagram-677ZJIJ3-CvBu1MbO.js → blockDiagram-677ZJIJ3-DfT6vWOg.js} +1 -1
  19. package/web/dist/assets/{c4Diagram-LMCZKHZV-B5qwF4gm.js → c4Diagram-LMCZKHZV-BO-VXBT8.js} +1 -1
  20. package/web/dist/assets/channel-HtOTPdHg.js +1 -0
  21. package/web/dist/assets/{chunk-2Q5K7J3B-zVW8dS7X.js → chunk-2Q5K7J3B-nq8p0xwC.js} +1 -1
  22. package/web/dist/assets/{chunk-32BRIVSS-DWctxZZS.js → chunk-32BRIVSS-DstJjLIA.js} +1 -1
  23. package/web/dist/assets/{chunk-5VM5RSS4-Bq7_Vmf9.js → chunk-5VM5RSS4-Bq0MPH_Q.js} +1 -1
  24. package/web/dist/assets/{chunk-EX3LRPZG-B8CFB3v-.js → chunk-EX3LRPZG-BPofXJWl.js} +1 -1
  25. package/web/dist/assets/{chunk-JWPE2WC7-B7gLPjgZ.js → chunk-JWPE2WC7-BBr7v6mo.js} +1 -1
  26. package/web/dist/assets/{chunk-MOJQB5TN-B2x6q12r.js → chunk-MOJQB5TN-DA7ca6Jr.js} +1 -1
  27. package/web/dist/assets/{chunk-RYQCIY6F-XorU9Stx.js → chunk-RYQCIY6F-fdXZe4nw.js} +1 -1
  28. package/web/dist/assets/{chunk-V7JOEXUC-CsLa6pw5.js → chunk-V7JOEXUC-BSATEdk2.js} +1 -1
  29. package/web/dist/assets/{chunk-VR4S4FIN-BBv9mwmj.js → chunk-VR4S4FIN-RR27eQST.js} +1 -1
  30. package/web/dist/assets/{chunk-XXDRQBXY-Cjm4mm6l.js → chunk-XXDRQBXY-Bh8VimxP.js} +1 -1
  31. package/web/dist/assets/classDiagram-OUVF2IWQ-C2P3dws0.js +1 -0
  32. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C2P3dws0.js +1 -0
  33. package/web/dist/assets/{cose-bilkent-JH36ORCC-Zw9ZQbVl.js → cose-bilkent-JH36ORCC-eP239MQJ.js} +1 -1
  34. package/web/dist/assets/{cynefin-VYW2F7L2-BYnR9YLM.js → cynefin-VYW2F7L2-Sb8l4Vm6.js} +1 -1
  35. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Cx8L0dOv.js → cynefinDiagram-TSTJHNR4-BrWMkhw9.js} +1 -1
  36. package/web/dist/assets/{dagre-VKFMJZFB-7GaqVcqz.js → dagre-VKFMJZFB-DT5kNKEx.js} +1 -1
  37. package/web/dist/assets/{diagram-FQU43EPY-BWSRPJvU.js → diagram-FQU43EPY-Dy0Bxyod.js} +1 -1
  38. package/web/dist/assets/{diagram-G47NLZAW-BELuAE1e.js → diagram-G47NLZAW-R_yWa2-6.js} +1 -1
  39. package/web/dist/assets/{diagram-NH7WQ7WH-DGXhPpri.js → diagram-NH7WQ7WH-BV2cGmgX.js} +1 -1
  40. package/web/dist/assets/{diagram-OA4YK3LP-DV8HqN0T.js → diagram-OA4YK3LP-auayiDmq.js} +1 -1
  41. package/web/dist/assets/{diagram-WEI45ONY-Clv1X3ZL.js → diagram-WEI45ONY-CE7bHIYE.js} +1 -1
  42. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-TpQx36oi.js → ebnfDiagram-CCIWWBDH-C3SMk6Uu.js} +1 -1
  43. package/web/dist/assets/{erDiagram-Q63AITRT-DVVWd00c.js → erDiagram-Q63AITRT-CnTs6X2D.js} +1 -1
  44. package/web/dist/assets/{flowDiagram-23GEKE2U-Cdz_8_Dw.js → flowDiagram-23GEKE2U-DobYm7mq.js} +1 -1
  45. package/web/dist/assets/{ganttDiagram-NO4QXBWP-zlco-o7u.js → ganttDiagram-NO4QXBWP-AkTQtAsR.js} +1 -1
  46. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-CfAZW1En.js → gitGraphDiagram-IHSO6WYX-CpLWsqgx.js} +1 -1
  47. package/web/dist/assets/{index-tQXCQojP.js → index-BCzNnCl0.js} +6 -6
  48. package/web/dist/assets/index-BUp5hFdX.css +1 -0
  49. package/web/dist/assets/{infoDiagram-FWYZ7A6U-0R-8efwI.js → infoDiagram-FWYZ7A6U-ByzTSgj_.js} +1 -1
  50. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-lPpoXGlp.js → ishikawaDiagram-FXEZZL3T-CaL9Wi8i.js} +1 -1
  51. package/web/dist/assets/{journeyDiagram-5HDEW3XC-D05vSVqu.js → journeyDiagram-5HDEW3XC-BillueZJ.js} +1 -1
  52. package/web/dist/assets/{kanban-definition-HUTT4EX6-ClaDdESv.js → kanban-definition-HUTT4EX6-DI_IRVWQ.js} +1 -1
  53. package/web/dist/assets/{linear-CUnhHMvY.js → linear-Cabaq-RD.js} +1 -1
  54. package/web/dist/assets/{mermaid.core-DaYS-X8Y.js → mermaid.core-D8KnBjxP.js} +4 -4
  55. package/web/dist/assets/{mindmap-definition-LN4V7U3C-N480VHds.js → mindmap-definition-LN4V7U3C-Bl7FpPwk.js} +1 -1
  56. package/web/dist/assets/{papaparse.min-CNTcNgd8.js → papaparse.min-CcjjKk0h.js} +1 -1
  57. package/web/dist/assets/{pegDiagram-2B236MQR-CVWyJ5ck.js → pegDiagram-2B236MQR-CmsG2owD.js} +1 -1
  58. package/web/dist/assets/{pieDiagram-ENE6RG2P-UR9uohPf.js → pieDiagram-ENE6RG2P-CLA_mcHl.js} +1 -1
  59. package/web/dist/assets/pilotswarm-DMCBXi5A.js +97 -0
  60. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-D2SIOetn.js → quadrantDiagram-ABIIQ3AL-hT9bnnUG.js} +1 -1
  61. package/web/dist/assets/{railroadDiagram-RFXS5EU6-OgN6qgOU.js → railroadDiagram-RFXS5EU6-CNC0kFW6.js} +1 -1
  62. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BSUURBBz.js → requirementDiagram-TGXJPOKE-C-nSthhK.js} +1 -1
  63. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-BBZhmSBZ.js → sankeyDiagram-HTMAVEWB-C5JdV5bI.js} +1 -1
  64. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-C10IDNdW.js → sequenceDiagram-DBY2YBRQ-BMKBxKk9.js} +1 -1
  65. package/web/dist/assets/{sizeCapture-X5ZJPWSS-B9787MHa.js → sizeCapture-X5ZJPWSS-CbwNw-TR.js} +1 -1
  66. package/web/dist/assets/{stateDiagram-2N3HPSRC-BmSBo7es.js → stateDiagram-2N3HPSRC-ChvGrUa_.js} +1 -1
  67. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DLbEwVA2.js +1 -0
  68. package/web/dist/assets/{swimlanes-5IMT3BWC-DOXpv_KQ.js → swimlanes-5IMT3BWC-D9Uzkbi7.js} +2 -2
  69. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DMzhdO6y.js +8 -0
  70. package/web/dist/assets/{timeline-definition-FHXFAJF6-SjhGfPdI.js → timeline-definition-FHXFAJF6-ctFEfgUp.js} +1 -1
  71. package/web/dist/assets/{vennDiagram-L72KCM5P-BiujYen6.js → vennDiagram-L72KCM5P-DGrJhlyF.js} +1 -1
  72. package/web/dist/assets/{wardleyDiagram-EHGQE667-6nLXBvwc.js → wardleyDiagram-EHGQE667-BGqtZ8dQ.js} +1 -1
  73. package/web/dist/assets/{xychartDiagram-FW5EYKEG-Di7ioTvW.js → xychartDiagram-FW5EYKEG-DAXXs9xC.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
  /**
@@ -10941,10 +10868,289 @@ function budgetViewDeps(state) {
10941
10868
  Boolean(state.ui?.budgetOpen), budget.loading, budget.refreshing, budget.loaded,
10942
10869
  budget.error, budget.grid, budget.paused, budget.overall, budget.selectedProvider,
10943
10870
  budget.selectedScope, budget.missingProvider, budget.series,
10871
+ budget.tab, budget.summary,
10944
10872
  state.auth?.authorization?.role, state.admin?.profile,
10945
10873
  ];
10946
10874
  }
10947
10875
 
10876
+ // ─── Cluster summary tab ────────────────────────────────────────────────
10877
+
10878
+ const SUMMARY_RANGES = [14, 30, 90];
10879
+ const SUMMARY_PRESETS = [
10880
+ { id: "all", label: "All providers" },
10881
+ { id: "shared", label: "Shared providers" },
10882
+ { id: "users", label: "User providers" },
10883
+ ];
10884
+
10885
+ function summaryPresetLabel(view) {
10886
+ if (view.preset === "all") return "All providers";
10887
+ if (view.preset === "shared") return "Shared providers";
10888
+ if (view.preset === "users") return "User providers";
10889
+ const n = view.selectedProviders.length;
10890
+ return n === 0 ? "No providers" : (n === 1 ? view.selectedProviders[0] : `${n} providers`);
10891
+ }
10892
+
10893
+ /**
10894
+ * The provider picker: three presets and a checkbox per provider. A preset
10895
+ * expands to an exact list of names before the request goes out — the
10896
+ * database never has to know what "Shared" meant on the day.
10897
+ */
10898
+ function SummaryProviderPicker({ view, onChange }) {
10899
+ const [open, setOpen] = React.useState(false);
10900
+ const ref = React.useRef(null);
10901
+ React.useEffect(() => {
10902
+ if (!open) return undefined;
10903
+ const onDown = (event) => { if (ref.current && !ref.current.contains(event.target)) setOpen(false); };
10904
+ const onKey = (event) => { if (event.key === "Escape") setOpen(false); };
10905
+ document.addEventListener("pointerdown", onDown);
10906
+ document.addEventListener("keydown", onKey);
10907
+ return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
10908
+ }, [open]);
10909
+
10910
+ const namesFor = (preset) => (preset === "all" ? [] : view.providers
10911
+ .filter((p) => (preset === "shared" ? p.class === "shared" : p.class !== "shared"))
10912
+ .map((p) => p.name));
10913
+ const checked = new Set(view.preset === "all"
10914
+ ? view.providers.map((p) => p.name)
10915
+ : (view.preset === "custom" ? view.selectedProviders : namesFor(view.preset)));
10916
+
10917
+ // A preset is a whole answer, so the menu closes on it; a checkbox is
10918
+ // one step of a hand-picked list, so the menu stays open for the next.
10919
+ const choosePreset = (preset) => { onChange({ preset, providers: namesFor(preset) }); setOpen(false); };
10920
+ const toggle = (name) => {
10921
+ const next = new Set(checked);
10922
+ if (next.has(name)) next.delete(name); else next.add(name);
10923
+ onChange({ preset: "custom", providers: Array.from(next) });
10924
+ };
10925
+
10926
+ return React.createElement("div", { className: "ps-summary-picker", ref },
10927
+ React.createElement("button", {
10928
+ type: "button",
10929
+ className: `ps-mini-button ps-summary-picker__button${open ? " is-open" : ""}`,
10930
+ "aria-haspopup": "listbox",
10931
+ "aria-expanded": open,
10932
+ "aria-label": "Providers filter",
10933
+ onClick: () => setOpen((v) => !v),
10934
+ }, summaryPresetLabel(view), " ", React.createElement("span", { "aria-hidden": "true" }, "▾")),
10935
+ open ? React.createElement("div", { className: "ps-summary-picker__menu", role: "listbox", "aria-label": "Providers" },
10936
+ SUMMARY_PRESETS.map((preset) => React.createElement("button", {
10937
+ key: preset.id,
10938
+ type: "button",
10939
+ role: "option",
10940
+ "aria-selected": view.preset === preset.id,
10941
+ className: `ps-summary-picker__preset${view.preset === preset.id ? " is-on" : ""}`,
10942
+ onClick: () => choosePreset(preset.id),
10943
+ }, preset.label)),
10944
+ React.createElement("div", { className: "ps-summary-picker__rule", "aria-hidden": "true" }),
10945
+ React.createElement("button", {
10946
+ type: "button", className: "ps-summary-picker__preset is-clear",
10947
+ onClick: () => onChange({ preset: "custom", providers: [] }),
10948
+ }, "Clear all"),
10949
+ view.providers.length === 0
10950
+ ? React.createElement("div", { className: "ps-summary-picker__empty" }, "No providers to choose from.")
10951
+ : view.providers.map((p) => React.createElement("label", { key: p.name, className: "ps-summary-picker__row" },
10952
+ React.createElement("input", {
10953
+ type: "checkbox",
10954
+ checked: checked.has(p.name),
10955
+ onChange: () => toggle(p.name),
10956
+ "aria-label": `Include ${p.name}`,
10957
+ }),
10958
+ React.createElement("span", { className: "ps-summary-picker__name" }, p.name),
10959
+ React.createElement("span", { className: `ps-scope-badge is-${p.class}` }, p.class === "shared" ? "shared" : "user")))) : null);
10960
+ }
10961
+
10962
+ function summaryTokens(n) {
10963
+ return formatCompactNumber(Number(n) || 0);
10964
+ }
10965
+
10966
+ /**
10967
+ * The stacked bar chart: one bar per UTC day, drawn as SVG so it needs no
10968
+ * library and scales with the pane. A bar is the day's total, input +
10969
+ * output; the input segment is drawn as two — the part that was not in
10970
+ * the cache, and the part served from or written to it — because the cache
10971
+ * figures are parts of the input, not additions to it.
10972
+ * Linear by default; when one day is more than ten times the median of the
10973
+ * others, the axis goes to log10(1 + n) so the rest are not hairlines.
10974
+ */
10975
+ /**
10976
+ * Log or linear? Log when one day is more than ten times the median of the
10977
+ * days that had anything — otherwise a spike turns every other day into a
10978
+ * hairline. Shared by the chart and the card header that names the scale.
10979
+ */
10980
+ function summaryLogScale(series) {
10981
+ const totals = series.map((d) => d.total);
10982
+ const max = Math.max(0, ...totals);
10983
+ const sorted = totals.filter((t) => t > 0).sort((a, b) => a - b);
10984
+ const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
10985
+ return median > 0 && max > median * 10;
10986
+ }
10987
+
10988
+ function SummaryChart({ series }) {
10989
+ const W = 720; const H = 190; const padL = 58; const padB = 22; const padT = 8;
10990
+ const totals = series.map((d) => d.total);
10991
+ const max = Math.max(0, ...totals);
10992
+ const log = summaryLogScale(series);
10993
+ const scale = (v) => (log ? Math.log10(1 + v) : v);
10994
+ const top = scale(max) || 1;
10995
+ const innerH = H - padB - padT;
10996
+ const y = (v) => innerH - (scale(v) / top) * innerH;
10997
+ const n = Math.max(1, series.length);
10998
+ const slot = (W - padL) / n;
10999
+ const barW = Math.max(3, slot * 0.66);
11000
+ // Ticks: quarters of the range on a linear axis; powers of ten on a log
11001
+ // axis, so the labels are numbers a person recognises (10K, 1M) rather
11002
+ // than wherever a quarter of log10(max) happens to land.
11003
+ const ticks = log
11004
+ ? [0, ...Array.from({ length: Math.floor(Math.log10(Math.max(10, max))) }, (_, i) => Math.pow(10, i + 1))]
11005
+ .filter((v) => v <= max || v === 0)
11006
+ .map((v) => ({ f: scale(v) / top, label: summaryTokens(v) }))
11007
+ : [0, 0.25, 0.5, 0.75, 1].map((f) => ({ f, label: summaryTokens(f * max) }));
11008
+ const labelEvery = n > 40 ? 10 : (n > 20 ? 5 : 1);
11009
+ return React.createElement("svg", {
11010
+ className: "ps-summary-chart", viewBox: `0 0 ${W} ${H}`, role: "img",
11011
+ "aria-label": `Tokens per day, ${series.length} days${log ? ", log scale" : ""}`,
11012
+ },
11013
+ ticks.map((t) => React.createElement("g", { key: t.label + t.f },
11014
+ React.createElement("line", { x1: padL, x2: W, y1: padT + innerH - t.f * innerH, y2: padT + innerH - t.f * innerH, className: "ps-summary-chart__grid" }),
11015
+ React.createElement("text", { x: padL - 6, y: padT + innerH - t.f * innerH + 3, className: "ps-summary-chart__tick", textAnchor: "end" }, t.label))),
11016
+ series.map((d, i) => {
11017
+ const x = padL + i * slot + (slot - barW) / 2;
11018
+ // Stack bottom-up: uncached input, cache read, cache write,
11019
+ // output. The four add up to input + output — the day's total —
11020
+ // because the two cache parts are carved out of the input. Under
11021
+ // a log axis the stack is the log of the running total, so
11022
+ // segment heights stay honest to the whole bar.
11023
+ const cr = Math.min(d.input, d.cacheRead);
11024
+ const cw = Math.min(d.input - cr, d.cacheWrite);
11025
+ const cum1 = d.input - cr - cw; const cum2 = cum1 + cr; const cum3 = d.input; const cum4 = cum3 + d.output;
11026
+ const yb = padT + innerH;
11027
+ const y1 = padT + y(cum1); const y2 = padT + y(cum2); const y3 = padT + y(cum3); const y4 = padT + y(cum4);
11028
+ 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`;
11029
+ return React.createElement("g", { key: d.day, className: "ps-summary-chart__bar" },
11030
+ React.createElement("title", null, title),
11031
+ React.createElement("rect", { x, y: y1, width: barW, height: Math.max(0, yb - y1), className: "is-in" }),
11032
+ React.createElement("rect", { x, y: y2, width: barW, height: Math.max(0, y1 - y2), className: "is-cache" }),
11033
+ React.createElement("rect", { x, y: y3, width: barW, height: Math.max(0, y2 - y3), className: "is-cache-w" }),
11034
+ React.createElement("rect", { x, y: y4, width: barW, height: Math.max(0, y3 - y4), className: "is-out" }),
11035
+ (i % labelEvery === 0 || i === n - 1)
11036
+ ? React.createElement("text", { x: x + barW / 2, y: H - 6, className: "ps-summary-chart__day", textAnchor: "middle" }, d.day.slice(5))
11037
+ : null);
11038
+ }));
11039
+ }
11040
+
11041
+ function SummarySparkline({ values }) {
11042
+ const max = Math.max(0, ...values);
11043
+ // 90 days at 4px is a 450px column; narrower bars past a month.
11044
+ return React.createElement("span", {
11045
+ className: "ps-summary-spark", "aria-hidden": "true",
11046
+ style: { gridAutoColumns: values.length > 30 ? "2px" : "4px" },
11047
+ },
11048
+ values.map((v, i) => React.createElement("i", { key: i, style: { height: `${max > 0 ? Math.max(v > 0 ? 8 : 0, (v / max) * 100) : 0}%` } })));
11049
+ }
11050
+
11051
+ function SummaryKpi({ label, w }) {
11052
+ return React.createElement("div", { className: "ps-summary-kpi" },
11053
+ React.createElement("div", { className: "ps-summary-kpi__label" }, label),
11054
+ React.createElement("div", { className: "ps-summary-kpi__value" }, summaryTokens(w.total)),
11055
+ // total = in + out; the cache pair is what the input was made of.
11056
+ 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." },
11057
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-in" }), "in ", summaryTokens(w.input)),
11058
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-cache" }), "cache r ", summaryTokens(w.cacheRead)),
11059
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-cache-w" }), "w ", summaryTokens(w.cacheWrite)),
11060
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-out" }), "out ", summaryTokens(w.output)),
11061
+ React.createElement("span", null, `· ${w.turns} turns`)));
11062
+ }
11063
+
11064
+ function summaryViewDeps(state) {
11065
+ const s = state.budget?.summary || {};
11066
+ return [s.loading, s.error, s.fetchedAt, s.days, s.preset, (s.providers || []).join(","), (state.budget?.grid || []).length];
11067
+ }
11068
+
11069
+ /**
11070
+ * The Cluster summary tab. Everything on it comes from ONE answer to
11071
+ * getProviderUsageSummary, so the KPIs, the chart and the model table can
11072
+ * never disagree about which turns were counted.
11073
+ */
11074
+ function ClusterSummaryPane({ controller }) {
11075
+ const deps = useControllerSelector(controller, summaryViewDeps, shallowEqualObject);
11076
+ const view = React.useMemo(() => selectUsageSummary(controller.getState()), [controller, deps]);
11077
+ React.useEffect(() => {
11078
+ if (!view.loaded && !view.loading && !view.error) controller.loadUsageSummary().catch(() => {});
11079
+ }, [controller]);
11080
+
11081
+ const setRange = (days) => controller.setUsageSummaryFilter({ days }).catch(() => {});
11082
+ const setProviders = ({ preset, providers }) => controller.setUsageSummaryFilter({ preset, providers }).catch(() => {});
11083
+ const classes = view.classes.filter((c) => c.total > 0);
11084
+ const rangeLabel = `last ${view.days} days`;
11085
+
11086
+ return React.createElement("div", { className: `ps-summary${view.loading ? " is-loading" : ""}` },
11087
+ React.createElement("div", { className: "ps-summary__filters" },
11088
+ React.createElement(SummaryProviderPicker, { view, onChange: setProviders }),
11089
+ React.createElement("div", { className: "ps-budget-seg", role: "group", "aria-label": "Window" },
11090
+ SUMMARY_RANGES.map((days) => React.createElement("button", {
11091
+ key: days, type: "button",
11092
+ className: `ps-budget-seg__button${view.days === days ? " is-on" : ""}`,
11093
+ "aria-pressed": view.days === days,
11094
+ "aria-label": `Last ${days} days`,
11095
+ onClick: () => setRange(days),
11096
+ }, `${days}d`))),
11097
+ React.createElement("span", { className: "ps-summary__scope" },
11098
+ view.scope === "cluster" ? "Whole cluster, system sessions included" : "Your own turns")),
11099
+ view.error ? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
11100
+ React.createElement("div", null, "The summary could not be read. "),
11101
+ React.createElement("div", { className: "ps-budget-sub" }, view.error)) : null,
11102
+ React.createElement("div", { className: "ps-summary__kpis" },
11103
+ React.createElement(SummaryKpi, { label: "Today (UTC)", w: view.windows.day }),
11104
+ React.createElement(SummaryKpi, { label: "Last 7 days", w: view.windows.week }),
11105
+ React.createElement(SummaryKpi, { label: "Last 30 days", w: view.windows.month })),
11106
+ React.createElement("div", { className: "ps-summary__card" },
11107
+ React.createElement("div", { className: "ps-summary__card-head" },
11108
+ React.createElement("b", null, "Tokens per day"),
11109
+ React.createElement("span", { className: "ps-summary__legend" },
11110
+ 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"),
11111
+ React.createElement("span", { title: "The part of the input served from the cache" }, React.createElement("i", { className: "ps-summary-dot is-cache" }), "cache read"),
11112
+ 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"),
11113
+ React.createElement("span", null, React.createElement("i", { className: "ps-summary-dot is-out" }), "output")),
11114
+ React.createElement("span", { className: "ps-summary__muted" }, `${rangeLabel} · ${summaryTokens(view.windowTotal)} tokens`),
11115
+ // Named here, not painted over the bars: a spike is exactly
11116
+ // when the tallest bar reaches the top-right corner.
11117
+ summaryLogScale(view.series)
11118
+ ? 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")
11119
+ : null),
11120
+ view.windowTotal > 0 || view.loaded
11121
+ ? React.createElement(SummaryChart, { series: view.series })
11122
+ : null,
11123
+ classes.length > 1 ? React.createElement("div", { className: "ps-summary__muted" },
11124
+ "By charge: ", classes.map((c, i) => `${i ? " · " : ""}${c.chargeClass} ${summaryTokens(c.total)}`).join("")) : null),
11125
+ React.createElement("div", { className: "ps-summary__card" },
11126
+ React.createElement("div", { className: "ps-summary__card-head" },
11127
+ React.createElement("b", null, "By model"),
11128
+ React.createElement("span", { className: "ps-summary__muted" }, `${rangeLabel} · across providers, efforts and context tiers`)),
11129
+ view.models.length === 0
11130
+ ? React.createElement("div", { className: "ps-budget-empty" }, view.loaded ? "No token usage in this window." : (view.loading ? "Reading…" : ""))
11131
+ : React.createElement("div", { className: "ps-summary__table-wrap" },
11132
+ React.createElement("table", { className: "ps-summary__table" },
11133
+ React.createElement("thead", null, React.createElement("tr", null,
11134
+ [["Model"], ["Turns"], ["Providers", "Distinct provider names in the ledger for this window — including providers since deleted"],
11135
+ ["Input", "Prompt tokens, including what was served from and written to the cache"], ["Output"],
11136
+ ["Cache read", "The part of Input served from the cache"], ["Cache write", "The part of Input written to the cache"],
11137
+ ["Total", "Input + Output"], ["Share"], ["Trend"]]
11138
+ .map(([h, title]) => React.createElement("th", { key: h, title }, h)))),
11139
+ React.createElement("tbody", null, view.models.map((m) => React.createElement("tr", { key: m.model },
11140
+ React.createElement("td", { className: "is-model" }, m.model),
11141
+ React.createElement("td", null, m.turns),
11142
+ React.createElement("td", null, m.providers),
11143
+ React.createElement("td", null, summaryTokens(m.input)),
11144
+ React.createElement("td", null, summaryTokens(m.output)),
11145
+ React.createElement("td", null, summaryTokens(m.cacheRead)),
11146
+ React.createElement("td", null, summaryTokens(m.cacheWrite)),
11147
+ React.createElement("td", { className: "is-total" }, summaryTokens(m.total)),
11148
+ React.createElement("td", { className: "is-share" },
11149
+ React.createElement("i", { className: "ps-summary-share", style: { width: `${Math.max(1, Math.round(m.share * 100))}px` } }),
11150
+ `${(m.share * 100).toFixed(m.share < 0.1 ? 1 : 0)}%`),
11151
+ React.createElement("td", null, React.createElement(SummarySparkline, { values: m.sparkline })))))))));
11152
+ }
11153
+
10948
11154
  /**
10949
11155
  * The whole surface: a head, one table, and the selected provider's days.
10950
11156
  *
@@ -11146,24 +11352,17 @@ function ProviderBudgetView({ controller }) {
11146
11352
  React.createElement("span", { className: "ps-budget-coin", "aria-hidden": "true" }, "◎"),
11147
11353
  React.createElement("h2", { className: "ps-budget-h" }, "Providers & Budgets"),
11148
11354
  React.createElement("span", { className: "ps-budget-sub" }, "Usage and limits")),
11355
+ // Refresh only. Leaving (to the workspace, or to the admin console)
11356
+ // is the toolbar's Mode cluster; a ✕ and a gear here were second,
11357
+ // unlabelled copies of buttons already on screen. Esc still closes.
11149
11358
  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
11359
  React.createElement(IconButton, {
11157
11360
  icon: "↻",
11158
11361
  label: "Refresh providers and budgets",
11159
11362
  className: "ps-mini-button",
11160
11363
  disabled: view.loading || view.refreshing,
11161
11364
  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
- }, "✕")));
11365
+ })));
11167
11366
 
11168
11367
  // A read that FAILED is never dressed up as an empty one: "no providers"
11169
11368
  // and "we could not find out" are different facts, and only one of them is
@@ -11255,7 +11454,18 @@ function ProviderBudgetView({ controller }) {
11255
11454
 
11256
11455
  return React.createElement("div", { className: "ps-budget-surface" },
11257
11456
  head,
11258
- React.createElement("div", { className: "ps-budget" }, body),
11457
+ // Two tabs: the per-provider table (meters, people's turns, limits)
11458
+ // and the Cluster summary (the ledger, every turn, by model).
11459
+ React.createElement("div", { className: "ps-budget-tabs", role: "tablist" },
11460
+ [["providers", "Providers"], ["summary", "Cluster summary"]].map(([id, label]) => React.createElement("button", {
11461
+ key: id, type: "button", role: "tab",
11462
+ "aria-selected": (controller.getState().budget?.tab || "providers") === id,
11463
+ className: `ps-budget-tab${(controller.getState().budget?.tab || "providers") === id ? " is-on" : ""}`,
11464
+ onClick: () => controller.setBudgetTab(id),
11465
+ }, label))),
11466
+ (controller.getState().budget?.tab || "providers") === "summary"
11467
+ ? React.createElement("div", { className: "ps-budget" }, React.createElement(ClusterSummaryPane, { controller }))
11468
+ : React.createElement("div", { className: "ps-budget" }, body),
11259
11469
  sheetEl);
11260
11470
  }
11261
11471
 
@@ -12342,18 +12552,11 @@ function AdminConsolePanel({ controller, mobile = false }) {
12342
12552
  closeProviderSheet();
12343
12553
  }, [closeProviderSheet, controller, providerSheet]);
12344
12554
 
12345
- const principalLabel = formatAdminPrincipalLabel(view.principal);
12346
-
12555
+ // Title only. The signed-in person is already in the portal header, and
12556
+ // the way out is the toolbar's Mode cluster (Workspace) \u2014 a \u2715 here was a
12557
+ // second, unlabelled way to do the same thing.
12347
12558
  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"));
12559
+ React.createElement("h2", null, view.isAdmin ? "Admin Console" : "Settings"));
12357
12560
 
12358
12561
  const providerSection = React.createElement(AdminModelProvidersSection, {
12359
12562
  controller,
@@ -13279,6 +13482,12 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
13279
13482
  React.createElement(AdminProviderRows, {
13280
13483
  providers: providers.sharedProviders || [], isAdmin: true, personal: false, busy: pending,
13281
13484
  onSystemUse: () => {}, onDelete: deleteProvider,
13485
+ // The same handler as the personal rows: it reads
13486
+ // provider.class and opens the sheet in shared mode. 0.5.47
13487
+ // showed the button on shared rows without wiring this, so
13488
+ // the click threw "onUpdate is not a function" and nothing
13489
+ // opened.
13490
+ onUpdate: onUpdatePersonal,
13282
13491
  })),
13283
13492
  React.createElement("div", { className: "ps-admin-provider-block" },
13284
13493
  React.createElement(AdminDefaultSelect, {
@@ -13728,29 +13937,15 @@ export function PilotSwarmWebApp({ controller }) {
13728
13937
  defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
13729
13938
  controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
13730
13939
  );
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;
13940
+ // The default workspace is sessions + chat, nothing else. A
13941
+ // first visit on a wide desktop used to open the canvas as
13942
+ // well; that was withdrawn (2026-08-27) — the canvas and
13943
+ // diagnostics open when a person opens them (or an agent
13944
+ // draws, see canvas/flip), and a stored choice is honoured.
13749
13945
  const settings = materializeProfileSettings(
13750
13946
  profile?.profileSettings,
13751
13947
  defaultProfileSettingsRef.current,
13752
13948
  );
13753
- if (wantsDefaultCanvas) settings.desktopPanes = { canvasOpen: true, diagnosticsOpen: false };
13754
13949
  const settingsJson = JSON.stringify(settings);
13755
13950
  const currentSettingsBeforeApply = profileSettingsFromViewState(
13756
13951
  controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
@@ -14014,7 +14209,16 @@ export function PilotSwarmWebApp({ controller }) {
14014
14209
  // rail width is remembered for THIS session only.
14015
14210
  ? `${zenRailPx}px var(--ps-resizer-track, 16px) minmax(0, 1fr)`
14016
14211
  : (rightSideActive
14017
- ? `minmax(0, 1fr) var(--ps-resizer-track, 16px) min(${rightSidePx}px, 60%)`
14212
+ // The right block gets its pixels until CHAT is at its
14213
+ // floor — not "60% of the window". With canvas and
14214
+ // diagnostics both open, a 60% cap bound early (a
14215
+ // 640px canvas + 320px diagnostics is 60% of a
14216
+ // 1600px window), and past it a drag on the chat
14217
+ // seam could not widen the block: the canvas kept its
14218
+ // fixed pixels and the diagnostics column, the flex
14219
+ // one, gave them up — so the chat↔canvas seam moved
14220
+ // the canvas↔diagnostics seam instead.
14221
+ ? `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
14222
  : "minmax(0, 1fr)")),
14019
14223
  },
14020
14224
  },