pilotswarm 0.5.46 → 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 (85) hide show
  1. package/mcp/README.md +1 -0
  2. package/mcp/dist/src/tools/providers.d.ts.map +1 -1
  3. package/mcp/dist/src/tools/providers.js +27 -3
  4. package/mcp/dist/src/tools/providers.js.map +1 -1
  5. package/package.json +2 -2
  6. package/tui/src/app.js +7 -11
  7. package/tui/src/node-sdk-transport.js +21 -0
  8. package/ui/core/src/commands.js +1 -0
  9. package/ui/core/src/controller.js +331 -68
  10. package/ui/core/src/history.js +5 -0
  11. package/ui/core/src/index.js +1 -0
  12. package/ui/core/src/reducer.js +93 -4
  13. package/ui/core/src/selectors.js +175 -15
  14. package/ui/core/src/state.js +31 -0
  15. package/ui/core/src/table-layout.js +155 -0
  16. package/ui/react/src/components.js +1 -0
  17. package/ui/react/src/web-app.js +1035 -323
  18. package/web/dist/assets/{abnfDiagram-VRR7QNED-GXM5pEfT.js → abnfDiagram-VRR7QNED-6rYy4Wj8.js} +1 -1
  19. package/web/dist/assets/{arc-D52-xt6J.js → arc-CrRZ1Za7.js} +1 -1
  20. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Bxh49GyJ.js → architectureDiagram-ZJ3FMSHR-CPe2QuQL.js} +1 -1
  21. package/web/dist/assets/{blockDiagram-677ZJIJ3-D8LSE94K.js → blockDiagram-677ZJIJ3-DfT6vWOg.js} +1 -1
  22. package/web/dist/assets/{c4Diagram-LMCZKHZV-Dcs1NVIx.js → c4Diagram-LMCZKHZV-BO-VXBT8.js} +1 -1
  23. package/web/dist/assets/channel-HtOTPdHg.js +1 -0
  24. package/web/dist/assets/{chunk-2Q5K7J3B-Dy3zu7gI.js → chunk-2Q5K7J3B-nq8p0xwC.js} +1 -1
  25. package/web/dist/assets/{chunk-32BRIVSS-5v78VX3T.js → chunk-32BRIVSS-DstJjLIA.js} +1 -1
  26. package/web/dist/assets/{chunk-5VM5RSS4-DUJMCfdj.js → chunk-5VM5RSS4-Bq0MPH_Q.js} +1 -1
  27. package/web/dist/assets/{chunk-EX3LRPZG-DYyT1So9.js → chunk-EX3LRPZG-BPofXJWl.js} +1 -1
  28. package/web/dist/assets/{chunk-JWPE2WC7-Buzzx5R2.js → chunk-JWPE2WC7-BBr7v6mo.js} +1 -1
  29. package/web/dist/assets/{chunk-MOJQB5TN-B8fOWRD7.js → chunk-MOJQB5TN-DA7ca6Jr.js} +1 -1
  30. package/web/dist/assets/{chunk-RYQCIY6F-Bp04YO26.js → chunk-RYQCIY6F-fdXZe4nw.js} +1 -1
  31. package/web/dist/assets/{chunk-V7JOEXUC-Gw6h8gPE.js → chunk-V7JOEXUC-BSATEdk2.js} +1 -1
  32. package/web/dist/assets/{chunk-VR4S4FIN-ASf4dPE-.js → chunk-VR4S4FIN-RR27eQST.js} +1 -1
  33. package/web/dist/assets/{chunk-XXDRQBXY-C4B-MNNk.js → chunk-XXDRQBXY-Bh8VimxP.js} +1 -1
  34. package/web/dist/assets/classDiagram-OUVF2IWQ-C2P3dws0.js +1 -0
  35. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C2P3dws0.js +1 -0
  36. package/web/dist/assets/{cose-bilkent-JH36ORCC-C0S415ey.js → cose-bilkent-JH36ORCC-eP239MQJ.js} +1 -1
  37. package/web/dist/assets/{cynefin-VYW2F7L2-LWUlL1XB.js → cynefin-VYW2F7L2-Sb8l4Vm6.js} +1 -1
  38. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-ClnNDuqy.js → cynefinDiagram-TSTJHNR4-BrWMkhw9.js} +1 -1
  39. package/web/dist/assets/{dagre-VKFMJZFB-CRMTDGPv.js → dagre-VKFMJZFB-DT5kNKEx.js} +1 -1
  40. package/web/dist/assets/{diagram-FQU43EPY-Cjt1nFI8.js → diagram-FQU43EPY-Dy0Bxyod.js} +1 -1
  41. package/web/dist/assets/{diagram-G47NLZAW-DynrWO-8.js → diagram-G47NLZAW-R_yWa2-6.js} +1 -1
  42. package/web/dist/assets/{diagram-NH7WQ7WH-CkEgxg4T.js → diagram-NH7WQ7WH-BV2cGmgX.js} +1 -1
  43. package/web/dist/assets/{diagram-OA4YK3LP-DzB43wzH.js → diagram-OA4YK3LP-auayiDmq.js} +1 -1
  44. package/web/dist/assets/{diagram-WEI45ONY-DqxH3-sW.js → diagram-WEI45ONY-CE7bHIYE.js} +1 -1
  45. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-5NxDrqIa.js → ebnfDiagram-CCIWWBDH-C3SMk6Uu.js} +1 -1
  46. package/web/dist/assets/{erDiagram-Q63AITRT-OfiKzPj4.js → erDiagram-Q63AITRT-CnTs6X2D.js} +1 -1
  47. package/web/dist/assets/{flowDiagram-23GEKE2U-DsqfPM3s.js → flowDiagram-23GEKE2U-DobYm7mq.js} +1 -1
  48. package/web/dist/assets/{ganttDiagram-NO4QXBWP-N48fLign.js → ganttDiagram-NO4QXBWP-AkTQtAsR.js} +1 -1
  49. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-rn1cXRYh.js → gitGraphDiagram-IHSO6WYX-CpLWsqgx.js} +1 -1
  50. package/web/dist/assets/{index-BHp6FirC.js → index-BCzNnCl0.js} +6 -6
  51. package/web/dist/assets/index-BUp5hFdX.css +1 -0
  52. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CjJ3TLmy.js → infoDiagram-FWYZ7A6U-ByzTSgj_.js} +1 -1
  53. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DvCylaIQ.js → ishikawaDiagram-FXEZZL3T-CaL9Wi8i.js} +1 -1
  54. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B_Ethynx.js → journeyDiagram-5HDEW3XC-BillueZJ.js} +1 -1
  55. package/web/dist/assets/{kanban-definition-HUTT4EX6-BlQuhDqn.js → kanban-definition-HUTT4EX6-DI_IRVWQ.js} +1 -1
  56. package/web/dist/assets/{linear-DIwvxXrN.js → linear-Cabaq-RD.js} +1 -1
  57. package/web/dist/assets/{mermaid.core-CfAsl9Pc.js → mermaid.core-D8KnBjxP.js} +4 -4
  58. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BxJep4HE.js → mindmap-definition-LN4V7U3C-Bl7FpPwk.js} +1 -1
  59. package/web/dist/assets/{papaparse.min-BjO79nbw.js → papaparse.min-CcjjKk0h.js} +1 -1
  60. package/web/dist/assets/{pegDiagram-2B236MQR-yceXoFi0.js → pegDiagram-2B236MQR-CmsG2owD.js} +1 -1
  61. package/web/dist/assets/{pieDiagram-ENE6RG2P-BNBzpT5B.js → pieDiagram-ENE6RG2P-CLA_mcHl.js} +1 -1
  62. package/web/dist/assets/pilotswarm-DMCBXi5A.js +97 -0
  63. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-DCg1DTax.js → quadrantDiagram-ABIIQ3AL-hT9bnnUG.js} +1 -1
  64. package/web/dist/assets/{railroadDiagram-RFXS5EU6-DQdy8xST.js → railroadDiagram-RFXS5EU6-CNC0kFW6.js} +1 -1
  65. package/web/dist/assets/{requirementDiagram-TGXJPOKE-N8PYSqS8.js → requirementDiagram-TGXJPOKE-C-nSthhK.js} +1 -1
  66. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DK9WhfTA.js → sankeyDiagram-HTMAVEWB-C5JdV5bI.js} +1 -1
  67. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DEpALkWi.js → sequenceDiagram-DBY2YBRQ-BMKBxKk9.js} +1 -1
  68. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CKOEPe9x.js → sizeCapture-X5ZJPWSS-CbwNw-TR.js} +1 -1
  69. package/web/dist/assets/{stateDiagram-2N3HPSRC-Dwwyy0hH.js → stateDiagram-2N3HPSRC-ChvGrUa_.js} +1 -1
  70. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DLbEwVA2.js +1 -0
  71. package/web/dist/assets/{swimlanes-5IMT3BWC-hDoJpKdQ.js → swimlanes-5IMT3BWC-D9Uzkbi7.js} +2 -2
  72. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DMzhdO6y.js +8 -0
  73. package/web/dist/assets/{timeline-definition-FHXFAJF6-CPXnQ7LH.js → timeline-definition-FHXFAJF6-ctFEfgUp.js} +1 -1
  74. package/web/dist/assets/{vennDiagram-L72KCM5P-CYsOwGTT.js → vennDiagram-L72KCM5P-DGrJhlyF.js} +1 -1
  75. package/web/dist/assets/{wardleyDiagram-EHGQE667-DKh__oEb.js → wardleyDiagram-EHGQE667-BGqtZ8dQ.js} +1 -1
  76. package/web/dist/assets/{xychartDiagram-FW5EYKEG-Bws7l8k9.js → xychartDiagram-FW5EYKEG-DAXXs9xC.js} +1 -1
  77. package/web/dist/index.html +3 -3
  78. package/web/runtime.js +22 -0
  79. package/web/dist/assets/channel-DE0-pG_Z.js +0 -1
  80. package/web/dist/assets/classDiagram-OUVF2IWQ-B0o6by6m.js +0 -1
  81. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B0o6by6m.js +0 -1
  82. package/web/dist/assets/index-DEymVbi5.css +0 -1
  83. package/web/dist/assets/pilotswarm-DewDyJhu.js +0 -97
  84. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-CRKYt8KU.js +0 -1
  85. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DtnCdOQI.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
 
@@ -282,6 +285,31 @@ function normalizeProfileSettings(settings) {
282
285
  if (typeof candidate.themeId === "string" && candidate.themeId.trim()) {
283
286
  normalized.themeId = candidate.themeId.trim();
284
287
  }
288
+ if (typeof candidate.sessionDetailCollapsed === "boolean") {
289
+ normalized.sessionDetailCollapsed = candidate.sessionDetailCollapsed;
290
+ }
291
+ // The agent picker's per-person usage counts, which drive its DEFAULT
292
+ // "Most used" sort. This whitelist drops anything unlisted, and the store
293
+ // REPLACES the settings document rather than merging — so leaving this out
294
+ // meant the controller wrote a count and the portal's next save erased it,
295
+ // within a second, every time. The sort had nothing to sort by and nobody
296
+ // saw an error.
297
+ //
298
+ // Kept as a plain {agentName: count} map, bounded so a long-lived profile
299
+ // cannot grow without limit: counts must be finite positive numbers, and
300
+ // only the busiest names survive.
301
+ if (candidate.agentPickerUsage && typeof candidate.agentPickerUsage === "object"
302
+ && !Array.isArray(candidate.agentPickerUsage)) {
303
+ const usage = {};
304
+ for (const [name, count] of Object.entries(candidate.agentPickerUsage)) {
305
+ const n = Number(count);
306
+ if (typeof name === "string" && name && Number.isFinite(n) && n > 0) {
307
+ usage[name] = Math.min(Math.round(n), 1e6);
308
+ }
309
+ }
310
+ const kept = Object.entries(usage).sort((a, b) => b[1] - a[1]).slice(0, 200);
311
+ if (kept.length > 0) normalized.agentPickerUsage = Object.fromEntries(kept);
312
+ }
285
313
  if (typeof candidate.touchScale === "boolean") {
286
314
  normalized.touchScale = candidate.touchScale;
287
315
  }
@@ -397,6 +425,7 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
397
425
  return normalizeProfileSettings({
398
426
  themeId: state.themeId,
399
427
  [touchScaleKey()]: state.touchScale,
428
+ sessionDetailCollapsed: state.sessionDetailCollapsed,
400
429
  sessionOwnerFilter: state.ownerFilter,
401
430
  layoutAdjustments: {
402
431
  paneAdjust: state.paneAdjust,
@@ -440,6 +469,16 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
440
469
  ...(typeof preservedOtherTouchScale === "boolean"
441
470
  ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
442
471
  : {}),
472
+ // State-owned (ui.agentPickerUsage), so the save effect, the poll's
473
+ // before/after comparisons and the defaults all build the SAME shape.
474
+ // A preserved ref here, refreshed only by the 5s poll, both lost the
475
+ // count to the next save and made the poll's comparison JSON differ
476
+ // from the save baseline forever — after which remote settings never
477
+ // applied again on that device.
478
+ ...(state.agentPickerUsage && typeof state.agentPickerUsage === "object"
479
+ && Object.keys(state.agentPickerUsage).length > 0
480
+ ? { agentPickerUsage: state.agentPickerUsage }
481
+ : {}),
443
482
  });
444
483
  }
445
484
 
@@ -447,6 +486,7 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherTouchScale =
447
486
  return normalizeProfileSettings({
448
487
  themeId: state?.ui?.themeId,
449
488
  [touchScaleKey()]: state?.ui?.touchScale,
489
+ sessionDetailCollapsed: state?.ui?.sessionDetailCollapsed,
450
490
  // Derive the owner-filter default from the RESOLVED principal, never a
451
491
  // snapshot of state.sessions.ownerFilter — at mount that snapshot is
452
492
  // still {all:true} (principal not yet applied), and using it as the
@@ -541,6 +581,16 @@ function materializeProfileSettings(remoteSettings, defaults) {
541
581
  : (isNarrowViewport() && hasOwn(normalizedRemote, "touchScale")
542
582
  ? { touchScale: normalizedRemote.touchScale }
543
583
  : {})),
584
+ // Read back for the same reason as touchScale above: written but never
585
+ // merged means the fold state resets on every reload. No default here
586
+ // either — absent must stay absent so a poll cannot overwrite a toggle
587
+ // the person just made.
588
+ ...(hasOwn(normalizedRemote, "sessionDetailCollapsed")
589
+ ? { sessionDetailCollapsed: normalizedRemote.sessionDetailCollapsed }
590
+ : {}),
591
+ ...(hasOwn(normalizedRemote, "agentPickerUsage")
592
+ ? { agentPickerUsage: normalizedRemote.agentPickerUsage }
593
+ : {}),
544
594
  });
545
595
  }
546
596
 
@@ -596,31 +646,6 @@ const PORTAL_COLUMN_RESIZER_PX = 16;
596
646
  */
597
647
  const PORTAL_CANVAS_MIN_PX = 480;
598
648
 
599
- /**
600
- * Wide enough to OPEN the canvas by default on a first visit.
601
- *
602
- * Sessions and chat need roughly 224px + 500px to stay comfortable, and the
603
- * canvas wants PORTAL_CANVAS_MIN_PX beside them. 1440 clears that with room to
604
- * spare and is a real device width (a 14" MacBook, a 900p desktop), so the
605
- * common laptop gets a canvas and a small window does not get three cramped
606
- * columns.
607
- */
608
- const PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX = 1440;
609
-
610
- /**
611
- * Whether this profile has ever expressed a preference about the two columns.
612
- *
613
- * The width-based default must fire only in its absence: someone who CLOSED
614
- * the canvas has stored `{canvasOpen: false}`, which is a choice and reads
615
- * nothing like "unset". A legacy rightPaneMode counts too — it is the same
616
- * preference in the older shape.
617
- */
618
- function hasStoredDesktopPanes(settings) {
619
- if (!settings || typeof settings !== "object") return false;
620
- if (settings.desktopPanes && typeof settings.desktopPanes === "object") return true;
621
- return settings.rightPaneMode === "canvas" || settings.rightPaneMode === "panes";
622
- }
623
-
624
649
  /**
625
650
  * The widths the two optional columns OPEN at, in pixels. 2:1 — the canvas is
626
651
  * the thing being looked at; diagnostics is a readout beside it.
@@ -2207,139 +2232,6 @@ function renderInlineMarkdown(source, theme, keyPrefix = "md") {
2207
2232
  });
2208
2233
  }
2209
2234
 
2210
- function normalizeTableCellText(value = "") {
2211
- return String(value || "")
2212
- .replace(/\s+/g, " ")
2213
- .trim();
2214
- }
2215
-
2216
- const FIT_WIDTH_FLEX_HEADER_KEYWORDS = [
2217
- "description",
2218
- "mechanism",
2219
- "details",
2220
- "notes",
2221
- "summary",
2222
- "message",
2223
- "comment",
2224
- "reason",
2225
- "body",
2226
- "content",
2227
- "explanation",
2228
- "rationale",
2229
- "one-liner",
2230
- ];
2231
-
2232
- const FIT_WIDTH_FLEX_MIN_MAX_LEN = 24;
2233
- const FIT_WIDTH_RIGID_CHAR_CAP = 32;
2234
- const FIT_WIDTH_MIN_RIGID_CHARS = 6;
2235
- const FIT_WIDTH_FLEX_MIN_CHARS = 30;
2236
- const FIT_WIDTH_FLEX_MAX_CHARS = 56;
2237
- const FIT_WIDTH_FLEX_MIN_FRACTION = 0.4;
2238
- const FIT_WIDTH_FLEX_TO_RIGID_RATIO = 1.4;
2239
- const FIT_WIDTH_MIN_EXTRA_CHARS = 2;
2240
-
2241
- /**
2242
- * Compute per-column layout for a fit-width markdown / chat table.
2243
- *
2244
- * Strategy:
2245
- * 1. Measure max cell length and "wrappability" (cells with whitespace) per column.
2246
- * 2. Identify a single "flex" column — long, prose-like, ideally with a
2247
- * header keyword like Description / Mechanism / Notes. This column
2248
- * absorbs overflow by wrapping aggressively.
2249
- * 3. Give every other column a budget = clamp(maxLen + padding, MIN, RIGID_CAP),
2250
- * so rigid columns stay at their content-fit width even when one
2251
- * sibling column has hundreds of characters of prose.
2252
- * 4. Give the flex column a bounded share of the rigid-column budget. A
2253
- * very long cell should wrap; it should not steal so much percentage
2254
- * width that compact columns like Count / Status collapse on phones.
2255
- * 5. Return a minimum table width in ch so the wrapper can scroll
2256
- * horizontally rather than forcing all columns below readable size.
2257
- *
2258
- * Returns { widths: ["12.34%", ...], flexIndex: number, minWidth: "64ch" }
2259
- * when a flex column is identified — the table renderer then forces
2260
- * table-layout: fixed, adds an `is-flex-column` class to the chosen column,
2261
- * and gives the table a readable minimum width. Returns null when no flex
2262
- * column is found, in which case the renderer falls back to the browser's
2263
- * auto-table-layout (which is already good for short / uniform tables).
2264
- *
2265
- * Background: the previous behavior used the browser's auto-table-layout
2266
- * unconditionally. That works well when columns are uniform but is biased
2267
- * toward wide columns when one column has prose hundreds of characters
2268
- * long (e.g. a Mechanism / Description column) — auto-layout distributes
2269
- * width proportional to (max-content − min-content), which lets the prose
2270
- * column squeeze the rigid columns down to a few characters each.
2271
- */
2272
- function computeFitWidthColumnLayout(rows = []) {
2273
- const columnCount = Math.max(0, ...rows.map((row) => row.length));
2274
- if (columnCount <= 0) return null;
2275
-
2276
- const headerRow = rows[0] || [];
2277
- const dataRowCount = Math.max(1, rows.length - 1);
2278
-
2279
- const stats = Array.from({ length: columnCount }, () => ({
2280
- max: 0,
2281
- spaceCells: 0,
2282
- }));
2283
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
2284
- const row = rows[rowIndex];
2285
- for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
2286
- const text = normalizeTableCellText(row[columnIndex] || "");
2287
- if (text.length > stats[columnIndex].max) stats[columnIndex].max = text.length;
2288
- if (rowIndex > 0 && /\s/.test(text)) stats[columnIndex].spaceCells += 1;
2289
- }
2290
- }
2291
-
2292
- let flexIndex = -1;
2293
- let flexScore = 0;
2294
- for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
2295
- const stat = stats[columnIndex];
2296
- if (stat.max < FIT_WIDTH_FLEX_MIN_MAX_LEN) continue;
2297
- const spaceRatio = stat.spaceCells / dataRowCount;
2298
- if (spaceRatio < 0.4) continue;
2299
- const headerText = normalizeTableCellText(headerRow[columnIndex] || "").toLowerCase();
2300
- const headerBonus = FIT_WIDTH_FLEX_HEADER_KEYWORDS.some((keyword) => headerText.includes(keyword)) ? 60 : 0;
2301
- const score = stat.max + headerBonus;
2302
- if (score > flexScore) {
2303
- flexScore = score;
2304
- flexIndex = columnIndex;
2305
- }
2306
- }
2307
-
2308
- if (flexIndex < 0) return null;
2309
-
2310
- const rigidBudgets = stats.map((stat, columnIndex) => {
2311
- if (columnIndex === flexIndex) return 0;
2312
- return Math.max(FIT_WIDTH_MIN_RIGID_CHARS, Math.min(stat.max + 2, FIT_WIDTH_RIGID_CHAR_CAP));
2313
- });
2314
- const sumRigid = rigidBudgets.reduce((sum, value) => sum + value, 0);
2315
- const flexStat = stats[flexIndex];
2316
- const flexBudget = Math.max(
2317
- FIT_WIDTH_FLEX_MIN_CHARS,
2318
- Math.min(
2319
- flexStat.max * 0.45,
2320
- FIT_WIDTH_FLEX_MAX_CHARS,
2321
- Math.max(FIT_WIDTH_FLEX_MIN_CHARS, sumRigid * FIT_WIDTH_FLEX_TO_RIGID_RATIO),
2322
- ),
2323
- );
2324
- const budgets = rigidBudgets.map((value, columnIndex) => (
2325
- columnIndex === flexIndex ? flexBudget : value
2326
- ));
2327
-
2328
- const totalBudget = budgets.reduce((sum, value) => sum + value, 0);
2329
- if (totalBudget > 0 && budgets[flexIndex] / totalBudget < FIT_WIDTH_FLEX_MIN_FRACTION) {
2330
- const sumRigid = totalBudget - budgets[flexIndex];
2331
- budgets[flexIndex] = sumRigid * (FIT_WIDTH_FLEX_MIN_FRACTION / (1 - FIT_WIDTH_FLEX_MIN_FRACTION));
2332
- }
2333
-
2334
- const finalTotal = budgets.reduce((sum, value) => sum + value, 0);
2335
- if (!(finalTotal > 0)) return null;
2336
- return {
2337
- widths: budgets.map((value) => `${((value / finalTotal) * 100).toFixed(2)}%`),
2338
- flexIndex,
2339
- minWidth: `${Math.ceil(finalTotal + FIT_WIDTH_MIN_EXTRA_CHARS)}ch`,
2340
- };
2341
- }
2342
-
2343
2235
  function buildTableColumnLabels(headerRows = [], columnCount = 0) {
2344
2236
  return Array.from({ length: columnCount }, (_, columnIndex) => {
2345
2237
  const label = (Array.isArray(headerRows) ? headerRows : [])
@@ -4040,7 +3932,36 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
4040
3932
  */
4041
3933
  const SESSION_DETAIL_NONE = "—";
4042
3934
 
4043
- function SessionDetailBox({ session, childCount = 0, pause = null, controller = null }) {
3935
+ /**
3936
+ * The wait sentence the detail box should show, given the status it is ALREADY
3937
+ * showing. Pure, and exported so the agreement can be tested directly.
3938
+ *
3939
+ * `waitReason` is applied raw, but the status beside it is debounced — a change
3940
+ * is held for 5s by mergeSessionRowVisualStatus because the 4s catalog poll
3941
+ * (the CMS row) and the post-event detail sync (live orchestration) disagree
3942
+ * mid-turn. Reading one through the hold and the other around it made the
3943
+ * WAITING block blink in and out on every poll, and let the box read
3944
+ * "(running)" with a WAITING row underneath it at the same time.
3945
+ *
3946
+ * So the sentence is shown only while the status on screen agrees there is
3947
+ * something to wait for. A budget pause is not routed through here: it is
3948
+ * authoritative on its own and always shows.
3949
+ */
3950
+ export function visibleWaitReason(session, statusLabel) {
3951
+ const reason = typeof session?.waitReason === "string" && session.waitReason.trim()
3952
+ ? session.waitReason.trim()
3953
+ : null;
3954
+ if (!reason) return null;
3955
+ const status = String(statusLabel || "");
3956
+ // "waiting", and "waiting on 7" / "waiting on children" for a parent whose
3957
+ // descendants are still going.
3958
+ const waiting = status === "waiting"
3959
+ || status.startsWith("waiting on")
3960
+ || status === "input_required";
3961
+ return waiting ? reason : null;
3962
+ }
3963
+
3964
+ function SessionDetailBox({ session, childCount = 0, pause = null, controller = null, collapsed = false, onToggle = null }) {
4044
3965
  // EVERY field renders on EVERY selection, empty ones as an em dash. The box
4045
3966
  // is a fixed grid of rows, so moving through the list cannot change its
4046
3967
  // height — a box that grew and shrank would shove the list under the
@@ -4104,9 +4025,19 @@ function SessionDetailBox({ session, childCount = 0, pause = null, controller =
4104
4025
  // the "Paused now" band shows, with a way through to the provider that
4105
4026
  // caused it. `waitReason` covers every OTHER kind of wait (a scheduled
4106
4027
  // one, an agent asking to sleep), which is a different fact and says so.
4107
- const waitReason = typeof session?.waitReason === "string" && session.waitReason.trim()
4108
- ? session.waitReason.trim()
4109
- : null;
4028
+ //
4029
+ // The block is gated on the DEBOUNCED status, not on waitReason alone.
4030
+ // Without that gate the two halves of this box run on different clocks:
4031
+ // the Updated row holds a status change for 5s (mergeSessionRowVisualStatus)
4032
+ // while waitReason is applied raw, and mid-turn the two writers disagree —
4033
+ // the CMS row still carries the sentence after the live orchestration has
4034
+ // moved on, or the reverse. The result was a WAITING block blinking in and
4035
+ // out on every 4s poll, and a box that could read "(running)" with a
4036
+ // WAITING row under it at the same time.
4037
+ //
4038
+ // A budget pause is separate: it is authoritative on its own and is not
4039
+ // derived from the session's status, so it always shows.
4040
+ const waitReason = visibleWaitReason(session, statusSummary?.status);
4110
4041
  const pauseBlock = (!pause && !waitReason) ? null : React.createElement("div", {
4111
4042
  className: `ps-session-detail-pause${pause ? " is-paused" : ""}`,
4112
4043
  },
@@ -4128,7 +4059,60 @@ function SessionDetailBox({ session, childCount = 0, pause = null, controller =
4128
4059
  ).catch(() => {}),
4129
4060
  }, pause.provider ? `Open ${pause.provider}` : "Open providers and budgets") : null));
4130
4061
 
4062
+ // Collapsed, the box is one line: the state you check constantly (how full
4063
+ // the context is, when it last moved, whether it is stuck) without the ten
4064
+ // rows you look up once. Expanding is a click, and the choice is a
4065
+ // preference so it survives switching sessions and reloading.
4066
+ if (collapsed) {
4067
+ const marks = [];
4068
+ if (context) {
4069
+ marks.push(React.createElement("span", {
4070
+ key: "context",
4071
+ className: `ps-session-detail-mark${percent != null && percent >= 85 ? " is-hot" : percent != null && percent >= 70 ? " is-warm" : ""}`,
4072
+ }, percent != null ? `${percent}%` : context));
4073
+ }
4074
+ if (statusSummary) {
4075
+ marks.push(React.createElement("span", { key: "updated", className: "ps-session-detail-mark" },
4076
+ `${statusSummary.relative} (${statusSummary.status})`));
4077
+ }
4078
+ // A stopped session is the one thing you must not have to expand to
4079
+ // find out, so it keeps a marker in the collapsed line.
4080
+ if (pause || waitReason) {
4081
+ marks.push(React.createElement("span", {
4082
+ key: "halt",
4083
+ className: `ps-session-detail-mark${pause ? " is-paused" : ""}`,
4084
+ title: pause ? pause.reason : waitReason,
4085
+ }, pause ? "paused" : "waiting"));
4086
+ }
4087
+ return React.createElement("div", { className: "ps-session-detail-box is-collapsed" },
4088
+ React.createElement("button", {
4089
+ type: "button",
4090
+ className: "ps-session-detail-summary",
4091
+ "aria-expanded": false,
4092
+ title: "Show session details",
4093
+ onClick: onToggle,
4094
+ },
4095
+ React.createElement("span", { className: "ps-session-detail-twisty", "aria-hidden": "true" }, "▸"),
4096
+ React.createElement("span", { className: "ps-session-detail-summary-title" },
4097
+ session?.title || SESSION_DETAIL_NONE),
4098
+ React.createElement("span", { className: "ps-session-detail-marks" }, marks)),
4099
+ // A stopped session keeps its whole block, not just a marker. The
4100
+ // fold is for reference detail you look up once — id, owner, agent.
4101
+ // Why the session is stopped, and the way through to the surface
4102
+ // that can unstop it, is the one thing you need WITHOUT going
4103
+ // looking for it, and folding it away hid it behind a click at the
4104
+ // exact moment it mattered.
4105
+ pauseBlock);
4106
+ }
4107
+
4131
4108
  return React.createElement("div", { className: "ps-session-detail-box" },
4109
+ React.createElement("button", {
4110
+ type: "button",
4111
+ className: "ps-session-detail-collapse",
4112
+ "aria-expanded": true,
4113
+ title: "Hide session details",
4114
+ onClick: onToggle,
4115
+ }, React.createElement("span", { "aria-hidden": "true" }, "▾"), " Less"),
4132
4116
  // The row above ellipsizes to one line, so this is the only place the
4133
4117
  // whole name is legible. Two lines are RESERVED whether or not they are
4134
4118
  // used, so the box height still cannot move with the selection.
@@ -4435,76 +4419,226 @@ function useStableValue(value) {
4435
4419
  }
4436
4420
 
4437
4421
  /**
4438
- * Vertical-primary touch scrolling with an explicit horizontal opt-in.
4422
+ * One axis per gesture: a touch drag scrolls the session list up/down OR
4423
+ * left/right, never both.
4439
4424
  *
4440
4425
  * The session list has `width: max-content` rows, so it overflows sideways
4441
- * whenever a title is long. Declaring `touch-action: pan-x pan-y` told the
4442
- * browser both axes were equally on offer, and on a phone a swipe is never
4443
- * perfectly vertical — so scrolling down kept sliding the list sideways.
4426
+ * whenever a title is long. With both axes on offer a swipe is never perfectly
4427
+ * vertical, so scrolling down kept sliding the list sideways too.
4428
+ *
4429
+ * This used to hand the vertical case back to the browser (`touch-action:
4430
+ * pan-y`) and only drive horizontal itself. That relied on the browser to
4431
+ * refuse the sideways component, and it has two problems:
4444
4432
  *
4445
- * The fix is to give the browser ONE axis (`touch-action: pan-y`, set in CSS,
4446
- * which keeps native vertical momentum) and drive horizontal ourselves, only
4447
- * for a gesture that is unambiguously sideways. Once a gesture commits to an
4448
- * axis it keeps it until the finger lifts: to scroll across you stop and make
4449
- * a deliberate left/right swipe, which is exactly the intent.
4433
+ * - What counts as "vertical enough" for `pan-y` is the browser's call, not
4434
+ * ours, and engines disagree. The guarantee was only as good as whichever
4435
+ * one the reader happened to be using.
4436
+ * - A drag near 45° fell in the gap: we committed it to vertical and stepped
4437
+ * aside, and the browser then declined to pan it at all. The list simply
4438
+ * did not move, which reads as broken rather than as a locked axis.
4439
+ *
4440
+ * So both axes are driven here. Every gesture past the slop threshold commits
4441
+ * to exactly one axis and always moves it — no dead zone, and no engine gets a
4442
+ * vote on the diagonal. Vertical stays the default: sideways has to be asked
4443
+ * for, by a drag that beats vertical by PAN_HORIZONTAL_RATIO.
4444
+ *
4445
+ * Driving the scroll ourselves means we also own the part the browser was
4446
+ * doing for free, so a flick decays under `releaseFling` instead of stopping
4447
+ * dead under the finger. CSS keeps `touch-action: pan-y` as a floor: if these
4448
+ * listeners ever fail to attach, the list still scrolls vertically the native
4449
+ * way rather than becoming inert.
4450
4450
  */
4451
4451
  const PAN_COMMIT_PX = 10; // ignore the first few px — every swipe starts noisy
4452
4452
  const PAN_HORIZONTAL_RATIO = 1.6; // |dx| must beat |dy| by this much to go sideways
4453
+ const FLING_MIN_VELOCITY = 0.05; // px/ms below which a lift is a stop, not a flick
4454
+ const FLING_FRICTION = 0.95; // per frame; ~0.35s of glide from a brisk flick
4455
+ const FLING_MAX_VELOCITY = 4; // px/ms ceiling, so a fast flick stays catchable
4456
+
4457
+ /**
4458
+ * Which axis does this gesture own? Pure, and exported so the rule can be
4459
+ * tested without a browser — the bug it replaces was a gesture that matched
4460
+ * NEITHER branch and therefore scrolled nothing.
4461
+ *
4462
+ * Returns null while the drag is still inside the slop radius, then "x" or
4463
+ * "y" — never null again, and never both. Every gesture that gets past the
4464
+ * threshold moves something.
4465
+ */
4466
+ export function commitPanAxis(dx, dy) {
4467
+ if (Math.abs(dx) < PAN_COMMIT_PX && Math.abs(dy) < PAN_COMMIT_PX) return null;
4468
+ // Ties, and anything close to a tie, go to vertical. Vertical is the
4469
+ // primary axis; sideways has to be asked for.
4470
+ return Math.abs(dx) > Math.abs(dy) * PAN_HORIZONTAL_RATIO ? "x" : "y";
4471
+ }
4453
4472
 
4454
- function useHorizontalPanOptIn(ref) {
4473
+ function useAxisLockedPan(ref, enabled = true) {
4455
4474
  React.useEffect(() => {
4456
4475
  const el = ref.current;
4457
- if (!el) return undefined;
4476
+ // Coarse pointers only. On a touchscreen LAPTOP the pointer is fine,
4477
+ // so drag-to-reorder is armed on the same rows — and because this hook
4478
+ // prevents native scrolling, the browser never fires the pointercancel
4479
+ // that would otherwise end that drag. Both would then own one finger:
4480
+ // a ghost row appears, the list pans under it, and the drop resolves
4481
+ // against a list that moved. Mirrors the gate reorder uses the other
4482
+ // way round; a fine pointer scrolls natively under the pan-y CSS.
4483
+ if (!el || !enabled) return undefined;
4458
4484
 
4459
4485
  let startX = 0;
4460
4486
  let startY = 0;
4461
4487
  let lastX = 0;
4462
- let axis = null; // null = undecided, "x" | "y" = committed for this gesture
4488
+ let lastY = 0;
4489
+ let lastT = 0;
4490
+ let velocity = 0; // px/ms along the committed axis, sign follows the finger
4491
+ let axis = null; // null = undecided, "x" | "y" = committed, "done" = not ours
4492
+ let flingFrame = null;
4493
+ let swallowClick = false;
4494
+
4495
+ const stopFling = () => {
4496
+ if (flingFrame !== null) {
4497
+ cancelAnimationFrame(flingFrame);
4498
+ flingFrame = null;
4499
+ }
4500
+ };
4501
+
4502
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
4503
+ const canScroll = (which) => (which === "x"
4504
+ ? el.scrollWidth > el.clientWidth + 1
4505
+ : el.scrollHeight > el.clientHeight + 1);
4463
4506
 
4464
4507
  const onTouchStart = (event) => {
4465
- if (event.touches.length !== 1) { axis = "y"; return; }
4466
- const touch = event.touches[0];
4508
+ // A tap that lands while the list is still gliding is "stop", not
4509
+ // "open whatever is passing under my finger". Native momentum never
4510
+ // opened a row on the arresting tap; ours must not either.
4511
+ swallowClick = flingFrame !== null;
4512
+ stopFling();
4513
+ // targetTouches, not touches: a thumb resting elsewhere on the page
4514
+ // is not part of this gesture. Two fingers HERE is a pinch.
4515
+ if (event.targetTouches.length !== 1) { axis = "done"; return; }
4516
+ const touch = event.targetTouches[0];
4467
4517
  startX = lastX = touch.clientX;
4468
- startY = touch.clientY;
4518
+ startY = lastY = touch.clientY;
4519
+ lastT = now();
4520
+ velocity = 0;
4469
4521
  axis = null;
4470
4522
  };
4471
4523
 
4472
4524
  const onTouchMove = (event) => {
4473
- if (axis === "y" || event.touches.length !== 1) return;
4474
- const touch = event.touches[0];
4525
+ if (axis === "done" || event.targetTouches.length !== 1) return;
4526
+ const touch = event.targetTouches[0];
4475
4527
  const dx = touch.clientX - startX;
4476
4528
  const dy = touch.clientY - startY;
4477
4529
 
4478
4530
  if (axis === null) {
4479
- if (Math.abs(dx) < PAN_COMMIT_PX && Math.abs(dy) < PAN_COMMIT_PX) return;
4480
- // Ties, and anything close to a tie, go to vertical. Vertical is
4481
- // the primary axis; sideways has to be asked for.
4482
- axis = Math.abs(dx) > Math.abs(dy) * PAN_HORIZONTAL_RATIO ? "x" : "y";
4531
+ // Decide BEFORE preventing. Preventing a touchmove prevents
4532
+ // scrolling for every later move of that touch, so a hand-off
4533
+ // after the commit cannot hand anything back — the browser has
4534
+ // already been told no.
4535
+ //
4536
+ // Vertical is the browser's own axis here (touch-action:
4537
+ // pan-y), and it has to be claimed from the FIRST move or not
4538
+ // at all: the browser's slop is smaller than ours (5-8px
4539
+ // against 10), so an un-prevented move past it starts a native
4540
+ // pan-y scroll and marks the rest of the gesture
4541
+ // non-cancelable — our preventDefault then no-ops while
4542
+ // scrollTop -= step still runs, and vertical moved at double
4543
+ // speed with two flings on release. So a list with vertical
4544
+ // overflow is claimed now, inside the slop.
4545
+ //
4546
+ // A list WITHOUT vertical overflow leaves the early moves
4547
+ // alone. The browser only ever takes the vertical under pan-y,
4548
+ // so a sideways commit can still claim the gesture for the
4549
+ // list's horizontal overflow (a short list of long titles),
4550
+ // and a vertical commit hands the pane its scroll untouched.
4551
+ const ownsVertical = canScroll("y");
4552
+ if (!ownsVertical && !canScroll("x")) { axis = "done"; return; }
4553
+ if (ownsVertical && event.cancelable) event.preventDefault();
4554
+ axis = commitPanAxis(dx, dy);
4555
+ if (axis === null) return;
4556
+ // A list that cannot scroll on the committed axis is not ours
4557
+ // to pan; and if the browser has already begun its own scroll
4558
+ // (the event is no longer cancelable), the gesture is its.
4559
+ if (!canScroll(axis) || !event.cancelable) { axis = "done"; return; }
4560
+ // Measure from here, so the slop we ignored is not also applied.
4483
4561
  lastX = touch.clientX;
4484
- if (axis === "y") return;
4562
+ lastY = touch.clientY;
4563
+ lastT = now();
4485
4564
  }
4486
4565
 
4487
- // Committed horizontal: the browser is not panning this axis, so we
4488
- // do it. preventDefault stops the gesture also scrolling an
4489
- // ancestor sideways.
4490
- el.scrollLeft -= touch.clientX - lastX;
4491
- lastX = touch.clientX;
4566
+ // Ours: stop the browser doing it as well. A repeat of the
4567
+ // first-move prevent for a list that owns vertical; the first
4568
+ // prevent of the gesture for a sideways pan on a short list.
4492
4569
  if (event.cancelable) event.preventDefault();
4570
+
4571
+ const stepX = touch.clientX - lastX;
4572
+ const stepY = touch.clientY - lastY;
4573
+ const t = now();
4574
+ const elapsed = Math.max(1, t - lastT);
4575
+ const step = axis === "x" ? stepX : stepY;
4576
+
4577
+ if (axis === "x") el.scrollLeft -= stepX;
4578
+ else el.scrollTop -= stepY;
4579
+
4580
+ // Exponential smoothing: one jittery sample should not decide the
4581
+ // whole flick, and a finger that stops before lifting should decay
4582
+ // to zero rather than fling on a stale reading.
4583
+ const sample = Math.max(-FLING_MAX_VELOCITY, Math.min(FLING_MAX_VELOCITY, step / elapsed));
4584
+ velocity = velocity * 0.7 + sample * 0.3;
4585
+
4586
+ lastX = touch.clientX;
4587
+ lastY = touch.clientY;
4588
+ lastT = t;
4493
4589
  };
4494
4590
 
4495
- const onTouchEnd = () => { axis = null; };
4591
+ const releaseFling = (committed) => {
4592
+ if (Math.abs(velocity) < FLING_MIN_VELOCITY) return;
4593
+ let v = velocity;
4594
+ const tick = () => {
4595
+ v *= FLING_FRICTION;
4596
+ if (Math.abs(v) < FLING_MIN_VELOCITY) { flingFrame = null; return; }
4597
+ const before = committed === "x" ? el.scrollLeft : el.scrollTop;
4598
+ if (committed === "x") el.scrollLeft -= v * 16;
4599
+ else el.scrollTop -= v * 16;
4600
+ const after = committed === "x" ? el.scrollLeft : el.scrollTop;
4601
+ // Hit an end stop — no point animating against it.
4602
+ if (after === before) { flingFrame = null; return; }
4603
+ flingFrame = requestAnimationFrame(tick);
4604
+ };
4605
+ flingFrame = requestAnimationFrame(tick);
4606
+ };
4607
+
4608
+ const onTouchEnd = (event) => {
4609
+ // Lifting ONE finger of a pinch does not hand the survivor a pan:
4610
+ // its start point belongs to a different gesture, and the commit
4611
+ // would land on a large bogus delta. Stay out until all lift.
4612
+ if (event.targetTouches.length > 0) { axis = "done"; return; }
4613
+ const committed = axis;
4614
+ axis = null;
4615
+ if (committed === "x" || committed === "y") releaseFling(committed);
4616
+ velocity = 0;
4617
+ };
4618
+
4619
+ const onTouchCancel = () => { axis = null; velocity = 0; stopFling(); };
4620
+
4621
+ const onClickCapture = (event) => {
4622
+ if (!swallowClick) return;
4623
+ swallowClick = false;
4624
+ event.preventDefault();
4625
+ event.stopPropagation();
4626
+ };
4496
4627
 
4497
4628
  el.addEventListener("touchstart", onTouchStart, { passive: true });
4498
4629
  el.addEventListener("touchmove", onTouchMove, { passive: false });
4499
4630
  el.addEventListener("touchend", onTouchEnd, { passive: true });
4500
- el.addEventListener("touchcancel", onTouchEnd, { passive: true });
4631
+ el.addEventListener("touchcancel", onTouchCancel, { passive: true });
4632
+ el.addEventListener("click", onClickCapture, true);
4501
4633
  return () => {
4634
+ stopFling();
4502
4635
  el.removeEventListener("touchstart", onTouchStart);
4503
4636
  el.removeEventListener("touchmove", onTouchMove);
4504
4637
  el.removeEventListener("touchend", onTouchEnd);
4505
- el.removeEventListener("touchcancel", onTouchEnd);
4638
+ el.removeEventListener("touchcancel", onTouchCancel);
4639
+ el.removeEventListener("click", onClickCapture, true);
4506
4640
  };
4507
- }, [ref]);
4641
+ }, [ref, enabled]);
4508
4642
  }
4509
4643
 
4510
4644
  function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false, showDetailBox = null }) {
@@ -4515,13 +4649,21 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4515
4649
  const isMobilePane = String(panelClassName).includes("ps-mobile-session-pane");
4516
4650
  // Vertical is the primary scroll axis; sideways takes a deliberate swipe.
4517
4651
  const sessionListRef = React.useRef(null);
4518
- useHorizontalPanOptIn(sessionListRef);
4519
4652
  // Selection reverts to plain taps wherever the primary input is a finger:
4520
4653
  // the mobile pane, and the chat-focus overlay's list on a phone. Matches
4521
4654
  // the `(pointer: fine)` guard on touch-action in the stylesheet.
4522
- const touchInput = isMobilePane || useCoarsePointer();
4655
+ // Called unconditionally — `isMobilePane || useCoarsePointer()` skipped
4656
+ // the hook whenever the left side was true, which is a hooks-order bug
4657
+ // waiting for panelClassName to change under a mounted pane.
4658
+ const coarsePointer = useCoarsePointer();
4659
+ const touchInput = isMobilePane || coarsePointer;
4660
+ useAxisLockedPan(sessionListRef, touchInput);
4523
4661
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
4524
4662
  const theme = getTheme(themeId);
4663
+ const detailCollapsed = useControllerSelector(
4664
+ controller,
4665
+ (state) => Boolean(state.ui.sessionDetailCollapsed),
4666
+ );
4525
4667
  const sessionButtonRefs = React.useRef(new Map());
4526
4668
  const viewState = useControllerSelector(controller, (state) => ({
4527
4669
  branding: state.branding,
@@ -5318,6 +5460,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5318
5460
  // change it. The row already carries the joined pause record.
5319
5461
  pause: activeRow?.pause || null,
5320
5462
  controller,
5463
+ collapsed: detailCollapsed,
5464
+ onToggle: () => controller.dispatch({
5465
+ type: "ui/sessionDetailCollapsed",
5466
+ collapsed: !detailCollapsed,
5467
+ }),
5321
5468
  })
5322
5469
  : null),
5323
5470
  (manageOpen && activeSession && !activeSession.isGroup)
@@ -5917,7 +6064,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
5917
6064
  : value === "shared_read" ? "everyone here can view"
5918
6065
  : "everyone here can view and send"))),
5919
6066
  React.createElement("div", { className: "ps-share-section-label" }, "Special access"),
5920
- React.createElement("div", { className: "ps-share-section-sub" }, "Give specific people more than the general level. A person's grant wins over general access."),
6067
+ React.createElement("div", { className: "ps-share-section-sub" }, "Give specific people more than the general level. A grant can only raise someone's access above it, never lower it — to restrict everyone, lower the general level."),
5921
6068
  draftShares.length === 0
5922
6069
  ? React.createElement("div", { className: "ps-share-empty" }, "No individual grants — everyone has the general access above.")
5923
6070
  : draftShares.map((row) => React.createElement("div", { key: `${row.provider}/${row.subject}`, className: "ps-share-grant-row" },
@@ -7185,6 +7332,12 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
7185
7332
  * maximized / onToggleMaximized are the phone's full-screen state, owned by
7186
7333
  * MobileCanvasLayer. Passing no handler simply omits the button.
7187
7334
  */
7335
+ // Match the session-list settle: the canvas follows the selection, and the
7336
+ // selection is a keypress away from changing again. Slightly longer than
7337
+ // SESSION_NAV_SETTLE_MS (140) so the snapshot rides just behind the load that
7338
+ // invalidates it, rather than racing it.
7339
+ const CANVAS_SNAPSHOT_SETTLE_MS = 160;
7340
+
7188
7341
  function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote = false, maximized = false, onToggleMaximized = null }) {
7189
7342
  const view = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
7190
7343
  const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
@@ -7193,11 +7346,33 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
7193
7346
  }, [controller]);
7194
7347
 
7195
7348
  // Belt to the selection-burst braces: a pane mounted before the burst's
7196
- // snapshot resolves (deep link straight into canvas mode) fetches it here;
7197
- // the memo makes the duplicate call free.
7349
+ // snapshot resolves (deep link straight into canvas mode) fetches it here.
7350
+ //
7351
+ // Gated on `visible`, and that gate is load-bearing. This pane is MOUNTED
7352
+ // even when the canvas is closed — hidden with inert + an off-screen
7353
+ // transform rather than unmounted, so re-showing it does not reload the
7354
+ // frame. So this effect runs on every selection change whether or not
7355
+ // anyone can see the canvas, and the memo it leans on is only free for a
7356
+ // REPEAT of the same session: each new one is a real fetch.
7357
+ //
7358
+ // Arrowing down a session list is a selection change per keypress. That
7359
+ // turned a 30-row traversal into 30 `events-before` round trips against a
7360
+ // pane nobody had opened — free on a stub, the whole network latency on a
7361
+ // remote deployment, which is exactly what session-list-perf guards.
7362
+ // A hidden canvas needs no snapshot; becoming visible re-runs this.
7363
+ //
7364
+ // And it waits for the selection to SETTLE. loadSession already debounces
7365
+ // its own burst behind SESSION_NAV_SETTLE_MS for exactly this reason —
7366
+ // this effect keyed straight off the selection and so re-armed the cost
7367
+ // the debounce had just removed. Arrowing 30 rows fired 30 snapshot
7368
+ // fetches; the cleanup cancels every one but the row you stop on.
7198
7369
  React.useEffect(() => {
7199
- if (view.sessionId) controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
7200
- }, [controller, view.sessionId]);
7370
+ if (!visible || !view.sessionId) return undefined;
7371
+ const timer = setTimeout(() => {
7372
+ controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
7373
+ }, CANVAS_SNAPSHOT_SETTLE_MS);
7374
+ return () => clearTimeout(timer);
7375
+ }, [controller, view.sessionId, visible]);
7201
7376
 
7202
7377
  const zenOn = useControllerSelector(controller, (s) => s?.ui?.canvasZen === true);
7203
7378
  const [shareOpen, setShareOpen] = React.useState(false);
@@ -8688,6 +8863,19 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8688
8863
  setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
8689
8864
  }, [mobile]);
8690
8865
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
8866
+ // The same panel is Settings for a plain user — their own providers,
8867
+ // default and packages — and the Admin Console for an admin, who also
8868
+ // gets shared providers, workers and every user's packages. Calling it
8869
+ // "Admin console" for someone who then finds nothing administrative in
8870
+ // it reads as something missing or broken.
8871
+ // Two sources, because they arrive at different times: the auth context
8872
+ // carries the role from sign-in, before the console has ever opened; the
8873
+ // admin profile carries it once it has. Reading only the profile left an
8874
+ // admin's toolbar saying "Settings" until the first open.
8875
+ const isAdminUser = useControllerSelector(controller, (state) => Boolean(
8876
+ state.admin?.profile?.isAdmin || state.auth?.authorization?.role === "admin",
8877
+ ));
8878
+ const consoleName = isAdminUser ? "admin console" : "settings";
8691
8879
  const budgetOpen = useControllerSelector(controller, (state) => Boolean(state.ui?.budgetOpen));
8692
8880
  const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
8693
8881
  const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
@@ -8790,10 +8978,21 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8790
8978
  ...(mobile ? [] : [{
8791
8979
  key: "admin",
8792
8980
  icon: React.createElement(CogGlyph),
8793
- label: adminVisible ? "Close admin console" : "Admin console",
8981
+ label: adminVisible ? `Close ${consoleName}` : (isAdminUser ? "Admin console" : "Settings"),
8794
8982
  onClick: () => controller.handleCommand(adminVisible ? UI_COMMANDS.CLOSE_ADMIN_CONSOLE : UI_COMMANDS.OPEN_ADMIN_CONSOLE).catch(() => {}),
8795
8983
  active: adminVisible,
8796
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
+ }]),
8797
8996
  ];
8798
8997
 
8799
8998
  const renderButton = (def) => {
@@ -8863,17 +9062,27 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8863
9062
  );
8864
9063
  }
8865
9064
 
8866
- // Two groups. Budget, Diagnostics and the admin console are TOOLS — they
8867
- // act on the app rather than on the conversation — so they sit apart from
8868
- // the session controls, hard right. Everything else keeps its order.
8869
- 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"];
8870
9079
 
8871
9080
  // While the canvas is full screen, ANY other button first drops full
8872
9081
  // screen and then does its own job. Pressing Filter and watching nothing
8873
9082
  // happen behind a canvas — or watching a panel open where you cannot see
8874
9083
  // it — is the confusing half of every full-screen mode. The Canvas toggle
8875
- // is exempt: it already means "put the canvas away", and closing drops the
8876
- // flag in the reducer.
9084
+ // is exempt: it already means "put the canvas away", and closing drops
9085
+ // the flag in the reducer.
8877
9086
  const withRestore = (def) => (!canvasMaximized || def.key === "canvas" ? def : {
8878
9087
  ...def,
8879
9088
  onClick: (...args) => {
@@ -8881,21 +9090,35 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8881
9090
  return def.onClick?.(...args);
8882
9091
  },
8883
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
+ ];
8884
9109
 
8885
- const leftButtons = buttonDefs.filter((d) => !RIGHT_GROUP.has(d.key)).map(withRestore);
8886
- const rightButtons = buttonDefs.filter((d) => RIGHT_GROUP.has(d.key)).map(withRestore);
8887
9110
  const toolbar = React.createElement("div", { className: "ps-toolbar" },
8888
9111
  // Three columns: left rail | main controls | right rail. Full-screen
8889
9112
  // canvas uses the otherwise-empty left rail for the normal actions so
8890
9113
  // they do not crowd the canvas metadata and controls across the top.
8891
9114
  React.createElement("div", { className: "ps-toolbar-side is-left" },
8892
- canvasMaximized ? leftButtons.map(renderButton) : null),
9115
+ canvasMaximized ? leftCluster : null),
8893
9116
  React.createElement("div", { className: "ps-toolbar-actions" },
8894
- canvasMaximized ? null : leftButtons.map(renderButton)),
9117
+ canvasMaximized ? null : leftCluster),
8895
9118
  React.createElement("div", { className: "ps-toolbar-side is-right" },
8896
9119
  // The portal header parks its version/status meta here, LEFT of
8897
9120
  // the tool buttons, and its sign-out glyph in the slot after
8898
- // them — one right-aligned cluster: meta · bug · settings · out.
9121
+ // them — one right-aligned cluster: meta · mode · theme · out.
8899
9122
  React.createElement("span", { className: "ps-toolbar-meta-slot", id: "ps-toolbar-meta-slot" }),
8900
9123
  // While the canvas is full screen its header controls (rev, zoom,
8901
9124
  // zen, restore) portal INTO this slot from CanvasPane — zoom state
@@ -8907,9 +9130,7 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8907
9130
  canvasMaximized
8908
9131
  ? React.createElement("span", { className: "ps-toolbar-divider", "aria-hidden": "true" })
8909
9132
  : null,
8910
- rightButtons.length
8911
- ? React.createElement("div", { className: "ps-toolbar-actions is-tools" }, rightButtons.map(renderButton))
8912
- : null,
9133
+ React.createElement("div", { className: "ps-toolbar-actions is-tools" }, rightCluster),
8913
9134
  React.createElement("span", { className: "ps-toolbar-signout-slot", id: "ps-toolbar-signout-slot" })),
8914
9135
  );
8915
9136
 
@@ -9380,7 +9601,12 @@ function ChatRightResizeHandle({ controller, target, adjust = 0 }) {
9380
9601
  const st = dragStateRef.current;
9381
9602
  if (!st) return;
9382
9603
  // Pointer right = chat grows = the neighbour gives up the pixels.
9383
- 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) });
9384
9610
  };
9385
9611
  const onUp = () => {
9386
9612
  const st = dragStateRef.current;
@@ -9408,7 +9634,18 @@ function ChatRightResizeHandle({ controller, target, adjust = 0 }) {
9408
9634
  if (event.button !== 0) return;
9409
9635
  event.preventDefault();
9410
9636
  capturePointerForDrag(event);
9411
- 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
+ };
9412
9649
  setDragging(true);
9413
9650
  document.body.classList.add("is-resizing-pane-x");
9414
9651
  },
@@ -9445,7 +9682,7 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
9445
9682
  // real space between the two panes. Its bounds are asymmetric:
9446
9683
  // -column/2 makes Inspector 0px; column/2-divider makes Activity
9447
9684
  // 0px. Using one symmetric half-range left an 8-9px pane remnant.
9448
- 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);
9449
9686
  controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: next });
9450
9687
  };
9451
9688
  const onUp = () => {
@@ -9478,7 +9715,7 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
9478
9715
  const { minAdjust, maxAdjust } = getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight);
9479
9716
  dragStateRef.current = {
9480
9717
  startY: event.clientY,
9481
- start: clamp(Number(splitAdjust) || 0, minAdjust, maxAdjust),
9718
+ start: clampNumber(Number(splitAdjust) || 0, minAdjust, maxAdjust),
9482
9719
  minAdjust,
9483
9720
  maxAdjust,
9484
9721
  };
@@ -9495,7 +9732,7 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
9495
9732
  const { minAdjust, maxAdjust } = getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight);
9496
9733
  controller.dispatch({
9497
9734
  type: "ui/diagnosticsSplitAdjust",
9498
- diagnosticsSplitAdjust: clamp(
9735
+ diagnosticsSplitAdjust: clampNumber(
9499
9736
  (Number(splitAdjust) || 0) + (event.key === "ArrowDown" ? dividerHeight : -dividerHeight),
9500
9737
  minAdjust,
9501
9738
  maxAdjust,
@@ -9788,7 +10025,25 @@ function budgetCellEl(cell) {
9788
10025
  }, `/ ${cell.quotaLabel}`));
9789
10026
  }
9790
10027
 
9791
- /** 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. */
9792
10047
  function budgetCellTitle(row, column, cell) {
9793
10048
  const who = row.kind === "model" ? `${row.providerName} · ${row.label}` : row.providerName;
9794
10049
  const cap = cell.uncapped
@@ -9799,7 +10054,8 @@ function budgetCellTitle(row, column, cell) {
9799
10054
  const blocked = cell.providerExhausted
9800
10055
  ? " The provider's own limit for this period is spent, so nothing runs against it whatever your share says."
9801
10056
  : "";
9802
- 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}`;
9803
10059
  }
9804
10060
 
9805
10061
  /**
@@ -9904,6 +10160,19 @@ function ProviderTable({ controller, view, defaults = null }) {
9904
10160
  title: budgetCellTitle(row, column, row.cells[column.id]),
9905
10161
  }, budgetCellEl(row.cells[column.id]))));
9906
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
+
9907
10176
  return React.createElement(React.Fragment, null,
9908
10177
  React.createElement("div", { className: "ps-budget-table-head" },
9909
10178
  React.createElement("span", { className: "ps-budget-label" },
@@ -9936,7 +10205,8 @@ function ProviderTable({ controller, view, defaults = null }) {
9936
10205
  view.columns.map((column) => React.createElement("th", {
9937
10206
  key: column.id, className: "ps-budget-grid-h is-num", scope: "col",
9938
10207
  }, column.label)))),
9939
- 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)])))));
9940
10210
  }
9941
10211
 
9942
10212
  /**
@@ -10440,9 +10710,10 @@ function EditProviderSheet({
10440
10710
  * credential is the key itself, never a pointer to one.
10441
10711
  */
10442
10712
  function CreateProviderSheet({
10443
- types, existingNames, isAdmin, busy, error, onCancel, onConfirm,
10444
- initialName = "", initialShared = false, initialType = "",
10713
+ types, existingNames, isAdmin, busy, error, onDirty, onCancel, onConfirm,
10714
+ initialName = "", initialShared = false, initialType = "", updateProvider = null,
10445
10715
  }) {
10716
+ const updating = Boolean(updateProvider);
10446
10717
  const [name, setName] = React.useState(initialName);
10447
10718
  const [type, setType] = React.useState(initialType || types[0]?.id || "");
10448
10719
  const [credential, setCredential] = React.useState("");
@@ -10455,7 +10726,7 @@ function CreateProviderSheet({
10455
10726
  const trimmed = name.trim();
10456
10727
  // A name is unique across the cluster and immutable, so a clash is
10457
10728
  // permanent. The database refuses it too; refusing here saves the trip.
10458
- const taken = existingNames.some((existing) => existing.toLowerCase() === trimmed.toLowerCase());
10729
+ const taken = !updating && existingNames.some((existing) => existing.toLowerCase() === trimmed.toLowerCase());
10459
10730
  // MAJOR 16: the database's own check constraint allows only these, and it
10460
10731
  // reports every violation with one fixed sentence — so a name refused for
10461
10732
  // its LENGTH was told to use letters and numbers, which it already did.
@@ -10481,52 +10752,70 @@ function CreateProviderSheet({
10481
10752
  };
10482
10753
 
10483
10754
  return React.createElement(BudgetSheet, {
10484
- title: isGithub ? "Add GitHub Copilot provider" : "Add model provider",
10755
+ title: updating ? `Update key for ${trimmed}` : (isGithub ? "Add GitHub Copilot provider" : "Add model provider"),
10485
10756
  subtitle: shared ? "Shared" : "Personal",
10486
- confirmLabel: "Add provider", busy, disabled: !valid, error, onCancel: cancel, onConfirm: submit,
10487
- footNote: "",
10757
+ confirmLabel: updating ? "Update key" : "Add provider", busy, disabled: !valid, error, onCancel: cancel, onConfirm: submit,
10758
+ // The whole point of an update is what does NOT change. Saying so is
10759
+ // the difference between "replace the key" and "did I just make a
10760
+ // second provider?" — the Delete sheet below spells out its
10761
+ // consequences the same way.
10762
+ footNote: updating
10763
+ ? "Only the key changes. The name, type, base URL, defaults, system-session routing and usage history all stay as they are."
10764
+ : "",
10488
10765
  },
10489
10766
  React.createElement(SheetField, {
10490
10767
  label: "Name",
10491
10768
  hint: taken ? "A provider with that name already exists, and names are unique across the cluster."
10492
10769
  : tooLong ? `Too long: ${trimmed.length} characters, and a name may be at most 64.`
10493
10770
  : badChars ? "Letters, numbers, dot, dash and underscore only — no colon, no spaces, no accents: a session refers to it as \"<provider>:<model>\"."
10494
- : "Permanent. Used in provider:model.",
10771
+ : updating ? "Not changing. Sessions keep referring to it as provider:model."
10772
+ : "Permanent. Used in provider:model.",
10495
10773
  },
10496
10774
  React.createElement("input", {
10497
- ref, className: `ps-budget-input${(taken || badChars || tooLong) && trimmed ? " is-invalid" : ""}`,
10775
+ ref, className: `ps-budget-input${updating ? " is-static" : ""}${(taken || badChars || tooLong) && trimmed ? " is-invalid" : ""}`,
10498
10776
  "aria-label": "Provider name",
10499
10777
  value: name, placeholder: "azure-prod",
10500
- onChange: (event) => setName(event.target.value),
10778
+ disabled: updating,
10779
+ onChange: (event) => { onDirty?.(); setName(event.target.value); },
10501
10780
  onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
10502
10781
  })),
10503
10782
  React.createElement(SheetField, {
10504
10783
  label: "Type",
10505
- hint: "Backend and model catalog.",
10784
+ hint: updating ? "Not changing." : "Backend and model catalog.",
10506
10785
  },
10507
10786
  React.createElement("select", {
10508
- className: "ps-budget-input", "aria-label": "Provider type",
10509
- value: type, onChange: (event) => setType(event.target.value),
10787
+ className: `ps-budget-input${updating ? " is-static" : ""}`, "aria-label": "Provider type",
10788
+ value: type, disabled: updating, onChange: (event) => { onDirty?.(); setType(event.target.value); },
10510
10789
  }, types.length === 0
10511
10790
  ? React.createElement("option", { value: "" }, "No provider types are listed on this deployment")
10512
10791
  : types.map((option) => React.createElement("option", { key: option.id, value: option.id }, option.id)))),
10513
10792
  React.createElement(SheetField, {
10514
- label: isGithub ? "GitHub Copilot token" : "API key",
10515
- hint: "Paste the token value.",
10793
+ label: updating
10794
+ ? (isGithub ? "New GitHub Copilot token" : "New API key")
10795
+ : (isGithub ? "GitHub Copilot token" : "API key"),
10796
+ hint: updating
10797
+ ? "Replaces the current one. The existing key is never shown."
10798
+ : "Paste the token value.",
10516
10799
  },
10517
10800
  React.createElement("input", {
10518
10801
  className: "ps-budget-input", type: "password", autoComplete: "off",
10519
- "aria-label": isGithub ? "GitHub Copilot token" : "API key",
10802
+ "aria-label": updating
10803
+ ? (isGithub ? "New GitHub Copilot token" : "New API key")
10804
+ : (isGithub ? "GitHub Copilot token" : "API key"),
10520
10805
  value: credential, placeholder: "Paste token",
10521
- onChange: (event) => setCredential(event.target.value),
10806
+ onChange: (event) => { onDirty?.(); setCredential(event.target.value); },
10807
+ // While updating this is the only editable field, and the Name input
10808
+ // that carries the other Enter handler is disabled — so without this
10809
+ // the sheet cannot be submitted from the keyboard at all.
10810
+ onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
10522
10811
  })),
10523
- React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
10812
+ !updating ? React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
10524
10813
  React.createElement("input", {
10525
10814
  className: "ps-budget-input", value: baseUrl, placeholder: "https://…",
10526
10815
  "aria-label": "Base URL",
10527
10816
  onChange: (event) => setBaseUrl(event.target.value),
10528
- })),
10529
- isAdmin ? React.createElement(SheetField, { label: "Access" },
10817
+ })) : null,
10818
+ isAdmin && !updating ? React.createElement(SheetField, { label: "Access" },
10530
10819
  React.createElement(Segmented, {
10531
10820
  value: shared ? "shared" : "mine", onChange: (value) => setShared(value === "shared"),
10532
10821
  label: "Access",
@@ -10579,10 +10868,289 @@ function budgetViewDeps(state) {
10579
10868
  Boolean(state.ui?.budgetOpen), budget.loading, budget.refreshing, budget.loaded,
10580
10869
  budget.error, budget.grid, budget.paused, budget.overall, budget.selectedProvider,
10581
10870
  budget.selectedScope, budget.missingProvider, budget.series,
10871
+ budget.tab, budget.summary,
10582
10872
  state.auth?.authorization?.role, state.admin?.profile,
10583
10873
  ];
10584
10874
  }
10585
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
+
10586
11154
  /**
10587
11155
  * The whole surface: a head, one table, and the selected provider's days.
10588
11156
  *
@@ -10784,24 +11352,17 @@ function ProviderBudgetView({ controller }) {
10784
11352
  React.createElement("span", { className: "ps-budget-coin", "aria-hidden": "true" }, "◎"),
10785
11353
  React.createElement("h2", { className: "ps-budget-h" }, "Providers & Budgets"),
10786
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.
10787
11358
  React.createElement("div", { className: "ps-budget-head-actions" },
10788
- React.createElement(IconButton, {
10789
- icon: React.createElement(CogGlyph),
10790
- label: "Model providers",
10791
- className: "ps-mini-button",
10792
- onClick: () => { controller.closeBudget(); void controller.openAdminConsole(); },
10793
- }),
10794
11359
  React.createElement(IconButton, {
10795
11360
  icon: "↻",
10796
11361
  label: "Refresh providers and budgets",
10797
11362
  className: "ps-mini-button",
10798
11363
  disabled: view.loading || view.refreshing,
10799
11364
  onClick: () => controller.refreshProviderTable().catch(() => {}),
10800
- }),
10801
- React.createElement("button", {
10802
- type: "button", className: "ps-budget-close", onClick: () => controller.closeBudget(),
10803
- "aria-label": "Close providers and budgets", title: "Close (Esc)",
10804
- }, "✕")));
11365
+ })));
10805
11366
 
10806
11367
  // A read that FAILED is never dressed up as an empty one: "no providers"
10807
11368
  // and "we could not find out" are different facts, and only one of them is
@@ -10893,7 +11454,18 @@ function ProviderBudgetView({ controller }) {
10893
11454
 
10894
11455
  return React.createElement("div", { className: "ps-budget-surface" },
10895
11456
  head,
10896
- 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),
10897
11469
  sheetEl);
10898
11470
  }
10899
11471
 
@@ -11018,7 +11590,7 @@ function ModalLayer({ controller }) {
11018
11590
  }),
11019
11591
  React.createElement("span", null, "Mobile"));
11020
11592
 
11021
- const renderListModal = (presentation, confirmLabel = "Apply", footerExtras = null) => {
11593
+ const renderListModal = (presentation, confirmLabel = "Apply", footerExtras = null, headerExtras = null) => {
11022
11594
  const rows = Array.isArray(presentation.rows) ? presentation.rows : [];
11023
11595
  const rowItemIndexes = Array.isArray(presentation.rowItemIndexes) ? presentation.rowItemIndexes : null;
11024
11596
  const usesHangingIndent = modal.type === "modelPicker" || modal.type === "reasoningEffortPicker" || modal.type === "contextTierPicker" || modal.type === "sessionAgentPicker";
@@ -11039,13 +11611,8 @@ function ModalLayer({ controller }) {
11039
11611
  key: item?.id || `row:${rowIndex}`,
11040
11612
  type: "button",
11041
11613
  className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
11042
- // A section header is a disclosure control: one click opens
11043
- // or closes it. Selecting it and making the user hit the
11044
- // footer button would be a two-step for something that
11045
- // reads as a twisty.
11046
11614
  onClick: () => {
11047
11615
  controller.dispatch({ type: "ui/modalSelection", index: itemIndex });
11048
- if (item?.kind === "section") controller.toggleAgentPickerSection(item.sectionKey);
11049
11616
  },
11050
11617
  },
11051
11618
  React.createElement("div", { className: "ps-line ps-modal-list-line" },
@@ -11088,6 +11655,7 @@ function ModalLayer({ controller }) {
11088
11655
  React.createElement("div", { className: "ps-modal-title" }, presentation.title),
11089
11656
  React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
11090
11657
  ),
11658
+ headerExtras,
11091
11659
  React.createElement("div", { className: "ps-modal-grid" },
11092
11660
  React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
11093
11661
  renderedList,
@@ -11178,14 +11746,43 @@ function ModalLayer({ controller }) {
11178
11746
  return renderListModal(modalState.contextTierPicker, pickerConfirmLabel);
11179
11747
  }
11180
11748
  if (modal.type === "sessionAgentPicker" && modalState.sessionAgentPicker) {
11181
- // The primary button follows the selection: on a section it is the
11182
- // disclosure, not a create, and labelling it "Create Session" would
11183
- // promise something Enter does not do there.
11184
- const picked = modal.items?.[modal.selectedIndex || 0];
11185
- const confirmLabel = picked?.kind === "section"
11186
- ? (picked.collapsed ? "Open" : "Close")
11187
- : "Create Session";
11188
- return renderListModal(modalState.sessionAgentPicker, confirmLabel);
11749
+ // Every row is an agent now, so Enter always creates.
11750
+ const SORTS = [
11751
+ ["used", "Most used", "Ordered by how often YOU have started each agent"],
11752
+ ["name", "Name", "Alphabetical by agent name"],
11753
+ ["package", "Package", "Grouped by package name, then agent name"],
11754
+ ];
11755
+ const toolbar = React.createElement("div", { className: "ps-agent-picker-toolbar" },
11756
+ React.createElement("input", {
11757
+ type: "search",
11758
+ className: "ps-agent-picker-search",
11759
+ // The keyboard handler routes Escape/Enter/arrows from a
11760
+ // marked search box to the modal; see useKeyboardShortcuts.
11761
+ "data-modal-search": "true",
11762
+ placeholder: "Search agents, packages, tools…",
11763
+ value: modal.query || "",
11764
+ autoFocus: true,
11765
+ "aria-label": "Search agents",
11766
+ onChange: (event) => controller.setAgentPickerQuery(event.target.value),
11767
+ // The list's own keys must still work from inside the box, but
11768
+ // every OTHER key has to stay here — the modal binds j and k to
11769
+ // move the selection, and without this you cannot type "kusto".
11770
+ onKeyDown: (event) => {
11771
+ const passes = ["ArrowUp", "ArrowDown", "Enter", "Escape", "Tab"];
11772
+ if (!passes.includes(event.key)) event.stopPropagation();
11773
+ },
11774
+ }),
11775
+ React.createElement("div", { className: "ps-agent-picker-sorts", role: "group", "aria-label": "Sort agents" },
11776
+ SORTS.map(([value, label, title]) => React.createElement("button", {
11777
+ key: value,
11778
+ type: "button",
11779
+ className: `ps-agent-picker-sort${(modal.sort || "used") === value ? " is-on" : ""}`,
11780
+ title,
11781
+ "aria-pressed": (modal.sort || "used") === value ? "true" : "false",
11782
+ onClick: () => controller.setAgentPickerSort(value),
11783
+ }, label))),
11784
+ );
11785
+ return renderListModal(modalState.sessionAgentPicker, "Create Session", null, toolbar);
11189
11786
  }
11190
11787
  if (modal.type === "sessionGroupPicker" && modalState.sessionGroupPicker) {
11191
11788
  return renderListModal(modalState.sessionGroupPicker, "Move");
@@ -11220,6 +11817,7 @@ function ModalLayer({ controller }) {
11220
11817
  ),
11221
11818
  React.createElement("input", {
11222
11819
  className: "ps-modal-input ps-modal-search",
11820
+ "data-modal-search": "true",
11223
11821
  value: ownerFilterQuery,
11224
11822
  placeholder: "Search people…",
11225
11823
  autoFocus: true,
@@ -11573,26 +12171,56 @@ function useKeyboardShortcuts(controller, mobile) {
11573
12171
  controller.handleCommand(UI_COMMANDS.MODAL_PANE_NEXT).catch(() => {});
11574
12172
  return;
11575
12173
  }
11576
- // Disclosure keys for the agent picker. Scoped to that modal:
11577
- // every other list modal is flat, and left/right there would
11578
- // either do nothing or fight the Tab pane-cycling. Without
11579
- // this the picker's own hint ("Enter or → to open") named a
11580
- // key nothing listened for.
11581
- if (modal.type === "sessionAgentPicker" && (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
12174
+ if (event.key === "ArrowUp" || event.key === "k") {
11582
12175
  event.preventDefault();
11583
- controller.handleCommand(
11584
- event.key === "ArrowRight" ? UI_COMMANDS.MODAL_PANE_NEXT : UI_COMMANDS.MODAL_PANE_PREV,
11585
- ).catch(() => {});
12176
+ controller.handleCommand(UI_COMMANDS.MODAL_PREV).catch(() => {});
11586
12177
  return;
11587
12178
  }
11588
- if (event.key === "ArrowUp" || event.key === "k") {
12179
+ if (event.key === "ArrowDown" || event.key === "j") {
12180
+ event.preventDefault();
12181
+ controller.handleCommand(UI_COMMANDS.MODAL_NEXT).catch(() => {});
12182
+ }
12183
+ return;
12184
+ }
12185
+
12186
+ // An INPUT inside a list modal — the agent picker's search box, the
12187
+ // filter dialog's people search — must still answer the keys that
12188
+ // drive the modal. The block above is gated on !editable so that
12189
+ // typing "j" filters instead of moving the selection, which is
12190
+ // right; but it also swallowed Escape, Enter and the arrows, so
12191
+ // with the box focused the dialog could not be closed, confirmed
12192
+ // or navigated at all (its own hint said "Esc close" while Escape
12193
+ // did nothing). Only those four keys, and only for a box marked
12194
+ // as a modal's SEARCH field. Not every input in a modal: the
12195
+ // rename and group-name boxes confirm on Enter themselves (a
12196
+ // second confirm from here is a no-op only because the first
12197
+ // closes the modal synchronously — not a contract worth leaning
12198
+ // on), and in a text box the arrow keys mean the caret, not the
12199
+ // list. A textarea keeps Enter for newlines.
12200
+ // `editable` is a boolean, not the element — test the target.
12201
+ if (modal && editable && target instanceof HTMLElement && target.dataset.modalSearch === "true") {
12202
+ if (event.key === "Escape") {
12203
+ // An input with text clears itself first (its own handler
12204
+ // does that and stops propagation), so reaching here means
12205
+ // the box is empty and Escape means close.
12206
+ event.preventDefault();
12207
+ controller.handleCommand(UI_COMMANDS.CLOSE_MODAL).catch(() => {});
12208
+ return;
12209
+ }
12210
+ if (event.key === "Enter") {
12211
+ event.preventDefault();
12212
+ controller.handleCommand(UI_COMMANDS.MODAL_CONFIRM).catch(() => {});
12213
+ return;
12214
+ }
12215
+ if (event.key === "ArrowUp") {
11589
12216
  event.preventDefault();
11590
12217
  controller.handleCommand(UI_COMMANDS.MODAL_PREV).catch(() => {});
11591
12218
  return;
11592
12219
  }
11593
- if (event.key === "ArrowDown" || event.key === "j") {
12220
+ if (event.key === "ArrowDown") {
11594
12221
  event.preventDefault();
11595
12222
  controller.handleCommand(UI_COMMANDS.MODAL_NEXT).catch(() => {});
12223
+ return;
11596
12224
  }
11597
12225
  return;
11598
12226
  }
@@ -11902,37 +12530,51 @@ function AdminConsolePanel({ controller, mobile = false }) {
11902
12530
  setProviderSheet(null);
11903
12531
  setProviderSheetBusy(false);
11904
12532
  setProviderSheetError(null);
11905
- }, []);
12533
+ // The sheet's own error is local state and goes with it. The PANE
12534
+ // banner is not: a failed mutation writes it into the store, and
12535
+ // nothing cleared it until the next mutation succeeded — so Cancel
12536
+ // left a red band describing a change that was abandoned.
12537
+ controller.dismissAdminModelProviderMutationError();
12538
+ }, [controller]);
11906
12539
  const submitProvider = React.useCallback(async (input) => {
11907
12540
  setProviderSheetBusy(true);
11908
12541
  setProviderSheetError(null);
11909
- const outcome = await controller.createAdminProvider(input);
12542
+ const outcome = providerSheet?.update
12543
+ ? await controller.updateAdminProviderCredential({
12544
+ name: input.name, credentials: input.credentials, shared: input.shared === true,
12545
+ })
12546
+ : await controller.createAdminProvider(input);
11910
12547
  setProviderSheetBusy(false);
11911
12548
  if (!outcome?.ok) {
11912
- setProviderSheetError(outcome?.error || "The provider could not be created.");
12549
+ setProviderSheetError(outcome?.error || `The provider key could not be ${providerSheet?.update ? "updated" : "created"}.`);
11913
12550
  return;
11914
12551
  }
11915
12552
  closeProviderSheet();
11916
- }, [closeProviderSheet, controller]);
11917
-
11918
- const principalLabel = formatAdminPrincipalLabel(view.principal);
12553
+ }, [closeProviderSheet, controller, providerSheet]);
11919
12554
 
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.
11920
12558
  const header = React.createElement("header", { className: "ps-admin-console__header" },
11921
- React.createElement("h2", null, "Admin Console"),
11922
- React.createElement("span", { className: "ps-admin-console__who" }, principalLabel),
11923
- React.createElement("button", {
11924
- type: "button",
11925
- className: "ps-mini-button is-icon",
11926
- onClick: onClose,
11927
- title: "Close the admin console",
11928
- "aria-label": "Close the admin console",
11929
- }, "\u2715"));
12559
+ React.createElement("h2", null, view.isAdmin ? "Admin Console" : "Settings"));
11930
12560
 
11931
12561
  const providerSection = React.createElement(AdminModelProvidersSection, {
11932
12562
  controller,
11933
12563
  view,
11934
- onAddPersonal: () => setProviderSheet({ shared: false, github: true }),
11935
- onAddShared: () => setProviderSheet({ shared: true, github: false }),
12564
+ // One failure should say itself once. While a sheet is open it shows
12565
+ // the message itself, and the pane printed the SAME text behind it.
12566
+ sheetOpen: Boolean(providerSheet),
12567
+ // Open clean: a banner from a previous, abandoned attempt must not
12568
+ // greet the next one.
12569
+ onAddPersonal: () => { controller.dismissAdminModelProviderMutationError(); setProviderSheet({ shared: false, github: true }); },
12570
+ onAddShared: () => { controller.dismissAdminModelProviderMutationError(); setProviderSheet({ shared: true, github: false }); },
12571
+ // Carry the scope: a shared provider rotates through the admin-only op,
12572
+ // a personal one through the owner's. Without this the sheet defaults
12573
+ // to personal and a shared rotation would 404 on its own name.
12574
+ onUpdatePersonal: (provider) => {
12575
+ controller.dismissAdminModelProviderMutationError();
12576
+ setProviderSheet({ update: provider, shared: provider.class === "shared" });
12577
+ },
11936
12578
  });
11937
12579
 
11938
12580
  const tree = React.createElement(AdminSettingsTree, { controller, view });
@@ -11952,11 +12594,17 @@ function AdminConsolePanel({ controller, mobile = false }) {
11952
12594
  ].map((provider) => provider.name),
11953
12595
  isAdmin: view.isAdmin,
11954
12596
  initialShared: providerSheet.shared,
12597
+ updateProvider: providerSheet.update || null,
11955
12598
  initialType: providerSheet.github
11956
12599
  ? (view.modelProviders?.providerTypes || []).find((type) => /github/i.test(type.id))?.id || ""
11957
- : "",
12600
+ : (providerSheet.update?.typeId || ""),
12601
+ initialName: providerSheet.update?.name || "",
11958
12602
  busy: providerSheetBusy,
11959
12603
  error: providerSheetError,
12604
+ // Editing a field is the person answering the error. Leaving the
12605
+ // old failure sitting under the input while they retype makes it
12606
+ // look like the new value is already rejected.
12607
+ onDirty: () => setProviderSheetError(null),
11960
12608
  onCancel: closeProviderSheet,
11961
12609
  onConfirm: submitProvider,
11962
12610
  })
@@ -12158,6 +12806,19 @@ function AdminPackageDetailPane({ controller, view }) {
12158
12806
  };
12159
12807
  const act = (kind, arg) => () => controller.runAdminPackageAction(kind, arg);
12160
12808
  const pending = detail.actionPending;
12809
+ // Download is a pure read that streams to the browser, so it does not go
12810
+ // through runAdminPackageAction (which mutates and refreshes the detail).
12811
+ const downloadPackage = async () => {
12812
+ try {
12813
+ await controller.transport.saveAgentPackageDownload(
12814
+ detail.name,
12815
+ detail.activeSemver ?? null,
12816
+ { scope: detail.scope, owner: detail.owner ?? null },
12817
+ );
12818
+ } catch (err) {
12819
+ window.alert(`Could not download ${detail.name}: ${err?.message || err}`);
12820
+ }
12821
+ };
12161
12822
  return React.createElement("div", { className: "ps-admin-detail" },
12162
12823
  detail.error
12163
12824
  ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" },
@@ -12252,6 +12913,20 @@ function AdminPackageDetailPane({ controller, view }) {
12252
12913
  title: "Copy the active version into your user scope as a private test bench",
12253
12914
  }, pending === "republish" ? "Copying…" : "Copy to my user scope")
12254
12915
  : null,
12916
+ // Getting a published package back OUT. The Web API has always
12917
+ // served it; without this the only way was the CLI, which is a
12918
+ // strange gap for a console that can publish, promote and
12919
+ // delete. Feature-detected: a host with nowhere to put a file
12920
+ // does not offer it.
12921
+ typeof controller.transport.saveAgentPackageDownload === "function"
12922
+ ? React.createElement("button", {
12923
+ type: "button",
12924
+ className: "ps-mini-button",
12925
+ onClick: () => downloadPackage(),
12926
+ disabled: Boolean(pending) || !detail.activeSemver,
12927
+ title: `Download ${detail.name}@${detail.activeSemver ?? "?"} as the .tgz the CLI would push`,
12928
+ }, pending === "download" ? "Downloading…" : "Download")
12929
+ : null,
12255
12930
  React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.enabled ? "disable" : "enable"), disabled: Boolean(pending) },
12256
12931
  detail.enabled ? "Disable" : "Enable"),
12257
12932
  detail.canManage
@@ -12677,7 +13352,7 @@ function AdminDefaultSelect({ label, hint, value, choices, disabled, emptyLabel,
12677
13352
  hint ? React.createElement("span", { className: "ps-admin-console__hint" }, hint) : null);
12678
13353
  }
12679
13354
 
12680
- function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, onDelete }) {
13355
+ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, onUpdate, onDelete }) {
12681
13356
  if (!providers.length) {
12682
13357
  return React.createElement("p", { className: "ps-admin-console__hint" },
12683
13358
  personal ? "No personal providers yet." : "No shared providers are configured.");
@@ -12688,7 +13363,9 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12688
13363
  className: "ps-admin-provider-row",
12689
13364
  },
12690
13365
  React.createElement("div", { className: "ps-admin-provider-row__identity" },
12691
- React.createElement("strong", null, provider.name),
13366
+ // Truncates when the row is tight (see the CSS), so the full name
13367
+ // has to be recoverable on hover.
13368
+ React.createElement("strong", { title: provider.name }, provider.name),
12692
13369
  React.createElement("span", { className: `ps-scope-badge is-${provider.class === "shared" ? "shared" : "user"}` }, provider.class),
12693
13370
  provider.isMyDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "my default") : null,
12694
13371
  provider.isClusterDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "cluster default") : null,
@@ -12702,6 +13379,23 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12702
13379
  onChange: (event) => onSystemUse(provider.name, event.target.checked),
12703
13380
  }),
12704
13381
  "Allow system sessions") : null,
13382
+ // Personal: the owner rotates their own key. Shared: an admin
13383
+ // rotates the cluster's. A shared provider had no way to rotate at
13384
+ // all, so an expired cluster key meant delete-and-recreate — which
13385
+ // drops the cluster-default flag, the allowance, any hold, the
13386
+ // system-use routing and the usage history, i.e. everything this
13387
+ // feature exists to preserve.
13388
+ (personal || isAdmin) ? React.createElement("button", {
13389
+ type: "button",
13390
+ className: "ps-mini-button",
13391
+ // Several providers means several identical "Update Key"
13392
+ // buttons to a screen reader. The trash below already names
13393
+ // its row.
13394
+ "aria-label": `Update key for ${provider.name}`,
13395
+ title: `Update key for ${provider.name}`,
13396
+ disabled: busy,
13397
+ onClick: () => onUpdate(provider),
13398
+ }, "Update key") : null,
12705
13399
  React.createElement(IconButton, {
12706
13400
  icon: React.createElement(TrashGlyph),
12707
13401
  label: `Delete ${provider.name}`,
@@ -12711,7 +13405,7 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12711
13405
  })))));
12712
13406
  }
12713
13407
 
12714
- function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShared }) {
13408
+ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShared, onUpdatePersonal, sheetOpen = false }) {
12715
13409
  const providers = view.modelProviders || {};
12716
13410
  const pending = Boolean(providers.mutation?.pending);
12717
13411
  const systemConfigured = adminDefaultModel(providers.systemSessionDefault);
@@ -12763,6 +13457,7 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
12763
13457
  React.createElement(AdminProviderRows, {
12764
13458
  providers: providers.myProviders || [], isAdmin: view.isAdmin, personal: true, busy: pending,
12765
13459
  onSystemUse: (name, enabled) => controller.setAdminProviderSystemUse(name, enabled),
13460
+ onUpdate: onUpdatePersonal,
12766
13461
  onDelete: deleteProvider,
12767
13462
  })),
12768
13463
  React.createElement("div", { className: "ps-admin-provider-block" },
@@ -12787,6 +13482,12 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
12787
13482
  React.createElement(AdminProviderRows, {
12788
13483
  providers: providers.sharedProviders || [], isAdmin: true, personal: false, busy: pending,
12789
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,
12790
13491
  })),
12791
13492
  React.createElement("div", { className: "ps-admin-provider-block" },
12792
13493
  React.createElement(AdminDefaultSelect, {
@@ -12859,8 +13560,12 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
12859
13560
 
12860
13561
  return React.createElement("section", { className: "ps-admin-detail ps-admin-model-providers" },
12861
13562
  pageHeader,
12862
- providers.error || providers.mutation?.error
12863
- ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, providers.mutation?.error || providers.error)
13563
+ // A mutation error belongs to the sheet that raised it while that sheet
13564
+ // is up. `providers.error` is different — it is the LOAD failing, which
13565
+ // is about the pane itself, so it still shows.
13566
+ providers.error || (providers.mutation?.error && !sheetOpen)
13567
+ ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" },
13568
+ (!sheetOpen && providers.mutation?.error) || providers.error)
12864
13569
  : null,
12865
13570
  sharedPage ? sharedProvidersPage : myPage);
12866
13571
  }
@@ -13022,6 +13727,8 @@ export function PilotSwarmWebApp({ controller }) {
13022
13727
  const state = useControllerSelector(controller, (rootState) => ({
13023
13728
  themeId: rootState.ui.themeId,
13024
13729
  touchScale: Boolean(rootState.ui.touchScale),
13730
+ sessionDetailCollapsed: Boolean(rootState.ui.sessionDetailCollapsed),
13731
+ agentPickerUsage: rootState.ui.agentPickerUsage,
13025
13732
  ownerFilter: rootState.sessions.ownerFilter,
13026
13733
  pinnedIds: rootState.sessions.pinnedIds,
13027
13734
  // Without this the profile-save effect reads undefined and a reorder
@@ -13201,6 +13908,16 @@ export function PilotSwarmWebApp({ controller }) {
13201
13908
  try {
13202
13909
  const profile = await transport.getCurrentUserProfile();
13203
13910
  if (!active) return;
13911
+ // This is the same profile the Admin Console fetches, and it
13912
+ // carries isAdmin in every auth mode — including the no-auth
13913
+ // stub, where getAuthContext() reports nothing. Seed it into
13914
+ // state once, so the toolbar can name itself (Settings vs
13915
+ // Admin console) from the first poll instead of after the
13916
+ // console has been opened. The console's own refresh still
13917
+ // runs and replaces this with the fuller load.
13918
+ if (profile && typeof profile.isAdmin === "boolean" && !controller.getState().admin?.profile) {
13919
+ controller.dispatch({ type: "admin/profile/loaded", profile });
13920
+ }
13204
13921
  // Recompute the fallback defaults from CURRENT state each poll:
13205
13922
  // the principal is resolved by now (it was likely null at mount),
13206
13923
  // so the "no persisted filter" fallback becomes the correct
@@ -13220,29 +13937,15 @@ export function PilotSwarmWebApp({ controller }) {
13220
13937
  defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
13221
13938
  controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
13222
13939
  );
13223
- // First visit on a desktop wide enough to hold it: open the
13224
- // canvas. A workspace that opens as chat alone hides the thing
13225
- // most sessions are FOR, and the canvas needs real width to be
13226
- // worth showing — below the breakpoint chat alone is better.
13227
- //
13228
- // Folded into the settings rather than dispatched separately.
13229
- // A separate dispatch raced the re-baseline at the end of this
13230
- // same poll: the new value was recorded as "already saved",
13231
- // never written, and the next poll put it back. Going through
13232
- // the settings means the normal apply-and-save path carries it.
13233
- //
13234
- // Only when NOTHING is stored. An explicit choice, including
13235
- // closing the canvas, is a preference from then on. Not
13236
- // live-responsive either: narrowing the window never closes a
13237
- // canvas the user opened on purpose.
13238
- const wantsDefaultCanvas = !hasStoredDesktopPanes(remoteNormalized)
13239
- && !isNarrowViewport()
13240
- && 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.
13241
13945
  const settings = materializeProfileSettings(
13242
13946
  profile?.profileSettings,
13243
13947
  defaultProfileSettingsRef.current,
13244
13948
  );
13245
- if (wantsDefaultCanvas) settings.desktopPanes = { canvasOpen: true, diagnosticsOpen: false };
13246
13949
  const settingsJson = JSON.stringify(settings);
13247
13950
  const currentSettingsBeforeApply = profileSettingsFromViewState(
13248
13951
  controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
@@ -13339,7 +14042,7 @@ export function PilotSwarmWebApp({ controller }) {
13339
14042
  });
13340
14043
  }, 400);
13341
14044
  return undefined;
13342
- }, [controller, state.activeSessionId, state.activityPaneAdjust, state.canvasPrefs, state.collapsedSessionIds, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.rightPaneMode, state.sessionPaneAdjust, state.themeId, state.touchScale]);
14045
+ }, [controller, state.activeSessionId, state.activityPaneAdjust, state.agentPickerUsage, state.canvasOpen, state.canvasPaneAdjust, state.canvasPrefs, state.canvasZen, state.collapsedSessionIds, state.diagnosticsOpen, state.diagnosticsPaneAdjust, state.diagnosticsSplitAdjust, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.rightPaneMode, state.sessionDetailCollapsed, state.sessionPaneAdjust, state.themeId, state.touchScale]);
13343
14046
 
13344
14047
  React.useEffect(() => {
13345
14048
  applyDocumentTheme(state.themeId);
@@ -13506,7 +14209,16 @@ export function PilotSwarmWebApp({ controller }) {
13506
14209
  // rail width is remembered for THIS session only.
13507
14210
  ? `${zenRailPx}px var(--ps-resizer-track, 16px) minmax(0, 1fr)`
13508
14211
  : (rightSideActive
13509
- ? `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))`
13510
14222
  : "minmax(0, 1fr)")),
13511
14223
  },
13512
14224
  },