pilotswarm 0.5.44 → 0.5.47

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 (98) hide show
  1. package/mcp/README.md +12 -0
  2. package/mcp/dist/src/context.d.ts.map +1 -1
  3. package/mcp/dist/src/context.js +9 -1
  4. package/mcp/dist/src/context.js.map +1 -1
  5. package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
  6. package/mcp/dist/src/tools/agent-packages.js +31 -0
  7. package/mcp/dist/src/tools/agent-packages.js.map +1 -1
  8. package/mcp/dist/src/tools/providers.d.ts.map +1 -1
  9. package/mcp/dist/src/tools/providers.js +15 -3
  10. package/mcp/dist/src/tools/providers.js.map +1 -1
  11. package/mcp/dist/src/util/respond.d.ts.map +1 -1
  12. package/mcp/dist/src/util/respond.js +6 -0
  13. package/mcp/dist/src/util/respond.js.map +1 -1
  14. package/package.json +2 -2
  15. package/tui/src/agents-cli.js +35 -0
  16. package/tui/src/app.js +7 -11
  17. package/tui/src/node-sdk-transport.js +70 -0
  18. package/ui/core/src/controller.js +313 -74
  19. package/ui/core/src/history.js +65 -10
  20. package/ui/core/src/reducer.js +51 -4
  21. package/ui/core/src/selectors.js +101 -20
  22. package/ui/core/src/state.js +11 -0
  23. package/ui/react/src/components.js +1 -0
  24. package/ui/react/src/web-app.js +938 -127
  25. package/web/api/canvas-plane.js +18 -6
  26. package/web/api/ws.js +22 -2
  27. package/web/auth/index.js +8 -1
  28. package/web/auth/normalize/entra.js +5 -1
  29. package/web/dist/assets/{abnfDiagram-VRR7QNED-67Q0WWge.js → abnfDiagram-VRR7QNED-VPL8m_9W.js} +1 -1
  30. package/web/dist/assets/{arc-BvcYFngS.js → arc-T5I3-c1B.js} +1 -1
  31. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CPBN0rk8.js → architectureDiagram-ZJ3FMSHR-DEP70bg6.js} +1 -1
  32. package/web/dist/assets/{blockDiagram-677ZJIJ3-D_hp1Oah.js → blockDiagram-677ZJIJ3-CvBu1MbO.js} +1 -1
  33. package/web/dist/assets/{c4Diagram-LMCZKHZV-DAOVGMIW.js → c4Diagram-LMCZKHZV-B5qwF4gm.js} +1 -1
  34. package/web/dist/assets/channel-D5LojeMw.js +1 -0
  35. package/web/dist/assets/{chunk-2Q5K7J3B-DajZuaot.js → chunk-2Q5K7J3B-zVW8dS7X.js} +1 -1
  36. package/web/dist/assets/{chunk-32BRIVSS-C6pXBsgt.js → chunk-32BRIVSS-DWctxZZS.js} +1 -1
  37. package/web/dist/assets/{chunk-5VM5RSS4-780edrmi.js → chunk-5VM5RSS4-Bq7_Vmf9.js} +1 -1
  38. package/web/dist/assets/{chunk-EX3LRPZG-C0e4NVuz.js → chunk-EX3LRPZG-B8CFB3v-.js} +1 -1
  39. package/web/dist/assets/{chunk-JWPE2WC7-uRe83I6C.js → chunk-JWPE2WC7-B7gLPjgZ.js} +1 -1
  40. package/web/dist/assets/{chunk-MOJQB5TN-B0WPoPjh.js → chunk-MOJQB5TN-B2x6q12r.js} +1 -1
  41. package/web/dist/assets/{chunk-RYQCIY6F-eRFuHHyG.js → chunk-RYQCIY6F-XorU9Stx.js} +1 -1
  42. package/web/dist/assets/{chunk-V7JOEXUC-C8FDXprk.js → chunk-V7JOEXUC-CsLa6pw5.js} +1 -1
  43. package/web/dist/assets/{chunk-VR4S4FIN-DPFpLQdB.js → chunk-VR4S4FIN-BBv9mwmj.js} +1 -1
  44. package/web/dist/assets/{chunk-XXDRQBXY-CoGQOPKW.js → chunk-XXDRQBXY-Cjm4mm6l.js} +1 -1
  45. package/web/dist/assets/classDiagram-OUVF2IWQ-C5edc5Or.js +1 -0
  46. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C5edc5Or.js +1 -0
  47. package/web/dist/assets/{cose-bilkent-JH36ORCC-BTBSfO5v.js → cose-bilkent-JH36ORCC-Zw9ZQbVl.js} +1 -1
  48. package/web/dist/assets/{cynefin-VYW2F7L2-DgTkQEoK.js → cynefin-VYW2F7L2-BYnR9YLM.js} +1 -1
  49. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DiBMlKoP.js → cynefinDiagram-TSTJHNR4-Cx8L0dOv.js} +1 -1
  50. package/web/dist/assets/{dagre-VKFMJZFB-Qv-4dcbk.js → dagre-VKFMJZFB-7GaqVcqz.js} +1 -1
  51. package/web/dist/assets/{diagram-FQU43EPY-CgyAb7yx.js → diagram-FQU43EPY-BWSRPJvU.js} +1 -1
  52. package/web/dist/assets/{diagram-G47NLZAW-BxN-KcGp.js → diagram-G47NLZAW-BELuAE1e.js} +1 -1
  53. package/web/dist/assets/{diagram-NH7WQ7WH-BiellpWz.js → diagram-NH7WQ7WH-DGXhPpri.js} +1 -1
  54. package/web/dist/assets/{diagram-OA4YK3LP-C8Ktgrwv.js → diagram-OA4YK3LP-DV8HqN0T.js} +1 -1
  55. package/web/dist/assets/{diagram-WEI45ONY-Bj5K1X91.js → diagram-WEI45ONY-Clv1X3ZL.js} +1 -1
  56. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DNQI2XT9.js → ebnfDiagram-CCIWWBDH-TpQx36oi.js} +1 -1
  57. package/web/dist/assets/{erDiagram-Q63AITRT-D5Xkllu6.js → erDiagram-Q63AITRT-DVVWd00c.js} +1 -1
  58. package/web/dist/assets/{flowDiagram-23GEKE2U-DJpTtMuj.js → flowDiagram-23GEKE2U-Cdz_8_Dw.js} +1 -1
  59. package/web/dist/assets/{ganttDiagram-NO4QXBWP-C-EQlCJf.js → ganttDiagram-NO4QXBWP-zlco-o7u.js} +1 -1
  60. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-C8vpSbvW.js → gitGraphDiagram-IHSO6WYX-CfAZW1En.js} +1 -1
  61. package/web/dist/assets/index-CMtzFJre.css +1 -0
  62. package/web/dist/assets/index-tQXCQojP.js +24 -0
  63. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CKNzsR3r.js → infoDiagram-FWYZ7A6U-0R-8efwI.js} +1 -1
  64. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CjyQq3lf.js → ishikawaDiagram-FXEZZL3T-lPpoXGlp.js} +1 -1
  65. package/web/dist/assets/{journeyDiagram-5HDEW3XC-CK3EEkJt.js → journeyDiagram-5HDEW3XC-D05vSVqu.js} +1 -1
  66. package/web/dist/assets/{kanban-definition-HUTT4EX6-CHQ8YkPx.js → kanban-definition-HUTT4EX6-ClaDdESv.js} +1 -1
  67. package/web/dist/assets/{linear-CUYirs1h.js → linear-CUnhHMvY.js} +1 -1
  68. package/web/dist/assets/{mermaid.core-Di8sQC4g.js → mermaid.core-DaYS-X8Y.js} +4 -4
  69. package/web/dist/assets/{mindmap-definition-LN4V7U3C-D38dmHP5.js → mindmap-definition-LN4V7U3C-N480VHds.js} +1 -1
  70. package/web/dist/assets/{papaparse.min-DWBU_pXS.js → papaparse.min-CNTcNgd8.js} +1 -1
  71. package/web/dist/assets/{pegDiagram-2B236MQR-BU759QCo.js → pegDiagram-2B236MQR-CVWyJ5ck.js} +1 -1
  72. package/web/dist/assets/{pieDiagram-ENE6RG2P-BbpfHbsj.js → pieDiagram-ENE6RG2P-UR9uohPf.js} +1 -1
  73. package/web/dist/assets/pilotswarm-BG6hk0oE.js +97 -0
  74. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-BYZ3XHZn.js → quadrantDiagram-ABIIQ3AL-D2SIOetn.js} +1 -1
  75. package/web/dist/assets/{railroadDiagram-RFXS5EU6-D18gkZBJ.js → railroadDiagram-RFXS5EU6-OgN6qgOU.js} +1 -1
  76. package/web/dist/assets/{requirementDiagram-TGXJPOKE-D6vo26Ib.js → requirementDiagram-TGXJPOKE-BSUURBBz.js} +1 -1
  77. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CxQwXnRE.js → sankeyDiagram-HTMAVEWB-BBZhmSBZ.js} +1 -1
  78. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CfGisw4A.js → sequenceDiagram-DBY2YBRQ-C10IDNdW.js} +1 -1
  79. package/web/dist/assets/{sizeCapture-X5ZJPWSS-BDK7-yA-.js → sizeCapture-X5ZJPWSS-B9787MHa.js} +1 -1
  80. package/web/dist/assets/{stateDiagram-2N3HPSRC-8IyidYSV.js → stateDiagram-2N3HPSRC-BmSBo7es.js} +1 -1
  81. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BqpsfoJI.js +1 -0
  82. package/web/dist/assets/{swimlanes-5IMT3BWC-CgNqXCvM.js → swimlanes-5IMT3BWC-DOXpv_KQ.js} +2 -2
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-k8WG_bWt.js +8 -0
  84. package/web/dist/assets/{timeline-definition-FHXFAJF6-DAJsGj6d.js → timeline-definition-FHXFAJF6-SjhGfPdI.js} +1 -1
  85. package/web/dist/assets/{vennDiagram-L72KCM5P-DDVdvG5k.js → vennDiagram-L72KCM5P-BiujYen6.js} +1 -1
  86. package/web/dist/assets/{wardleyDiagram-EHGQE667-CiAs_m72.js → wardleyDiagram-EHGQE667-6nLXBvwc.js} +1 -1
  87. package/web/dist/assets/{xychartDiagram-FW5EYKEG-D6OLJtsD.js → xychartDiagram-FW5EYKEG-Di7ioTvW.js} +1 -1
  88. package/web/dist/index.html +3 -3
  89. package/web/runtime.js +192 -6
  90. package/web/server.js +22 -0
  91. package/web/dist/assets/channel-DiW7lUT8.js +0 -1
  92. package/web/dist/assets/classDiagram-OUVF2IWQ-CpAhBd5x.js +0 -1
  93. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CpAhBd5x.js +0 -1
  94. package/web/dist/assets/index-C2ioXNJJ.js +0 -24
  95. package/web/dist/assets/index-CVYm02UI.css +0 -1
  96. package/web/dist/assets/pilotswarm-DrM3pXOV.js +0 -97
  97. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-F7qYkvpr.js +0 -1
  98. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CUKrHLkY.js +0 -8
@@ -282,6 +282,31 @@ function normalizeProfileSettings(settings) {
282
282
  if (typeof candidate.themeId === "string" && candidate.themeId.trim()) {
283
283
  normalized.themeId = candidate.themeId.trim();
284
284
  }
285
+ if (typeof candidate.sessionDetailCollapsed === "boolean") {
286
+ normalized.sessionDetailCollapsed = candidate.sessionDetailCollapsed;
287
+ }
288
+ // The agent picker's per-person usage counts, which drive its DEFAULT
289
+ // "Most used" sort. This whitelist drops anything unlisted, and the store
290
+ // REPLACES the settings document rather than merging — so leaving this out
291
+ // meant the controller wrote a count and the portal's next save erased it,
292
+ // within a second, every time. The sort had nothing to sort by and nobody
293
+ // saw an error.
294
+ //
295
+ // Kept as a plain {agentName: count} map, bounded so a long-lived profile
296
+ // cannot grow without limit: counts must be finite positive numbers, and
297
+ // only the busiest names survive.
298
+ if (candidate.agentPickerUsage && typeof candidate.agentPickerUsage === "object"
299
+ && !Array.isArray(candidate.agentPickerUsage)) {
300
+ const usage = {};
301
+ for (const [name, count] of Object.entries(candidate.agentPickerUsage)) {
302
+ const n = Number(count);
303
+ if (typeof name === "string" && name && Number.isFinite(n) && n > 0) {
304
+ usage[name] = Math.min(Math.round(n), 1e6);
305
+ }
306
+ }
307
+ const kept = Object.entries(usage).sort((a, b) => b[1] - a[1]).slice(0, 200);
308
+ if (kept.length > 0) normalized.agentPickerUsage = Object.fromEntries(kept);
309
+ }
285
310
  if (typeof candidate.touchScale === "boolean") {
286
311
  normalized.touchScale = candidate.touchScale;
287
312
  }
@@ -397,6 +422,7 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
397
422
  return normalizeProfileSettings({
398
423
  themeId: state.themeId,
399
424
  [touchScaleKey()]: state.touchScale,
425
+ sessionDetailCollapsed: state.sessionDetailCollapsed,
400
426
  sessionOwnerFilter: state.ownerFilter,
401
427
  layoutAdjustments: {
402
428
  paneAdjust: state.paneAdjust,
@@ -440,6 +466,16 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
440
466
  ...(typeof preservedOtherTouchScale === "boolean"
441
467
  ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
442
468
  : {}),
469
+ // State-owned (ui.agentPickerUsage), so the save effect, the poll's
470
+ // before/after comparisons and the defaults all build the SAME shape.
471
+ // A preserved ref here, refreshed only by the 5s poll, both lost the
472
+ // count to the next save and made the poll's comparison JSON differ
473
+ // from the save baseline forever — after which remote settings never
474
+ // applied again on that device.
475
+ ...(state.agentPickerUsage && typeof state.agentPickerUsage === "object"
476
+ && Object.keys(state.agentPickerUsage).length > 0
477
+ ? { agentPickerUsage: state.agentPickerUsage }
478
+ : {}),
443
479
  });
444
480
  }
445
481
 
@@ -447,6 +483,7 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherTouchScale =
447
483
  return normalizeProfileSettings({
448
484
  themeId: state?.ui?.themeId,
449
485
  [touchScaleKey()]: state?.ui?.touchScale,
486
+ sessionDetailCollapsed: state?.ui?.sessionDetailCollapsed,
450
487
  // Derive the owner-filter default from the RESOLVED principal, never a
451
488
  // snapshot of state.sessions.ownerFilter — at mount that snapshot is
452
489
  // still {all:true} (principal not yet applied), and using it as the
@@ -541,6 +578,16 @@ function materializeProfileSettings(remoteSettings, defaults) {
541
578
  : (isNarrowViewport() && hasOwn(normalizedRemote, "touchScale")
542
579
  ? { touchScale: normalizedRemote.touchScale }
543
580
  : {})),
581
+ // Read back for the same reason as touchScale above: written but never
582
+ // merged means the fold state resets on every reload. No default here
583
+ // either — absent must stay absent so a poll cannot overwrite a toggle
584
+ // the person just made.
585
+ ...(hasOwn(normalizedRemote, "sessionDetailCollapsed")
586
+ ? { sessionDetailCollapsed: normalizedRemote.sessionDetailCollapsed }
587
+ : {}),
588
+ ...(hasOwn(normalizedRemote, "agentPickerUsage")
589
+ ? { agentPickerUsage: normalizedRemote.agentPickerUsage }
590
+ : {}),
544
591
  });
545
592
  }
546
593
 
@@ -1257,6 +1304,33 @@ function SystemNoticeLine({ line, theme }) {
1257
1304
  : null);
1258
1305
  }
1259
1306
 
1307
+ /**
1308
+ * A canvas action is a button press inside the drawn page, not something the
1309
+ * viewer typed. Printing the raw `[canvas-action] {…}` JSON as a chat message
1310
+ * buries the conversation in plumbing, so it renders as one collapsed row —
1311
+ * the same affordance as a system notice — that opens to show the exact
1312
+ * payload the page sent.
1313
+ */
1314
+ function CanvasActionLine({ line, theme }) {
1315
+ const payload = String(line?.canvasActionPayload || "").trim();
1316
+ const name = line?.canvasActionName || "action";
1317
+ const detail = String(line?.canvasActionDetail || "").trim();
1318
+ const time = String(line?.canvasActionTime || "").trim();
1319
+ return React.createElement("details", { className: "ps-system-notice ps-canvas-action" },
1320
+ React.createElement("summary", {
1321
+ className: "ps-system-notice-summary ps-canvas-action-summary",
1322
+ style: { color: resolveColor(theme, "gray") || "var(--ps-muted)" },
1323
+ },
1324
+ React.createElement("span", { className: "ps-canvas-action-tag" }, "canvas"),
1325
+ React.createElement("span", { className: "ps-system-notice-summary-text" },
1326
+ `${name}${detail ? ` — ${detail}` : ""}`),
1327
+ time ? React.createElement("span", { className: "ps-canvas-action-time" }, time) : null),
1328
+ payload
1329
+ ? React.createElement("div", { className: "ps-system-notice-body" },
1330
+ React.createElement("pre", { className: "ps-canvas-action-payload" }, payload))
1331
+ : null);
1332
+ }
1333
+
1260
1334
  /**
1261
1335
  * Coalesce high-frequency dispatches to one per animation frame.
1262
1336
  *
@@ -1303,6 +1377,11 @@ const Line = React.memo(function Line({ line, theme, className = "" }) {
1303
1377
  if (line.kind === "systemNotice") {
1304
1378
  return React.createElement(SystemNoticeLine, { line, theme });
1305
1379
  }
1380
+ // Checked before `kind`: the canvas-action line carries runs for the TUI
1381
+ // but the portal renders it as its own collapsed row, not as those runs.
1382
+ if (line.canvasAction) {
1383
+ return React.createElement(CanvasActionLine, { line, theme });
1384
+ }
1306
1385
  if (line.kind === "runs") {
1307
1386
  return React.createElement("div", { className: lineClassName },
1308
1387
  React.createElement(Runs, { runs: line.runs, theme }));
@@ -3448,8 +3527,12 @@ function StructuredChatBlocks({ lines, theme, controller = null }) {
3448
3527
  // for a host that cannot render the canvas). In the browser the canvas
3449
3528
  // pane updating IS the signal, so the chat skips them outright — desktop
3450
3529
  // and mobile share this renderer, so one filter covers both.
3530
+ //
3531
+ // canvasAction lines are NOT skipped: pressing a button in the drawn page
3532
+ // is a real thing the viewer did, and dropping it leaves an unexplained
3533
+ // gap before the agent's reply. Line renders it as one collapsed row.
3451
3534
  const blocks = React.useMemo(
3452
- () => parseStructuredChatBlocks((lines || []).filter((line) => !line?.canvasUpdate && !line?.canvasAction)),
3535
+ () => parseStructuredChatBlocks((lines || []).filter((line) => !line?.canvasUpdate)),
3453
3536
  [lines],
3454
3537
  );
3455
3538
  return React.createElement(StructuredBlockList, { blocks, theme, controller });
@@ -4004,7 +4087,36 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
4004
4087
  */
4005
4088
  const SESSION_DETAIL_NONE = "—";
4006
4089
 
4007
- function SessionDetailBox({ session, childCount = 0, pause = null, controller = null }) {
4090
+ /**
4091
+ * The wait sentence the detail box should show, given the status it is ALREADY
4092
+ * showing. Pure, and exported so the agreement can be tested directly.
4093
+ *
4094
+ * `waitReason` is applied raw, but the status beside it is debounced — a change
4095
+ * is held for 5s by mergeSessionRowVisualStatus because the 4s catalog poll
4096
+ * (the CMS row) and the post-event detail sync (live orchestration) disagree
4097
+ * mid-turn. Reading one through the hold and the other around it made the
4098
+ * WAITING block blink in and out on every poll, and let the box read
4099
+ * "(running)" with a WAITING row underneath it at the same time.
4100
+ *
4101
+ * So the sentence is shown only while the status on screen agrees there is
4102
+ * something to wait for. A budget pause is not routed through here: it is
4103
+ * authoritative on its own and always shows.
4104
+ */
4105
+ export function visibleWaitReason(session, statusLabel) {
4106
+ const reason = typeof session?.waitReason === "string" && session.waitReason.trim()
4107
+ ? session.waitReason.trim()
4108
+ : null;
4109
+ if (!reason) return null;
4110
+ const status = String(statusLabel || "");
4111
+ // "waiting", and "waiting on 7" / "waiting on children" for a parent whose
4112
+ // descendants are still going.
4113
+ const waiting = status === "waiting"
4114
+ || status.startsWith("waiting on")
4115
+ || status === "input_required";
4116
+ return waiting ? reason : null;
4117
+ }
4118
+
4119
+ function SessionDetailBox({ session, childCount = 0, pause = null, controller = null, collapsed = false, onToggle = null }) {
4008
4120
  // EVERY field renders on EVERY selection, empty ones as an em dash. The box
4009
4121
  // is a fixed grid of rows, so moving through the list cannot change its
4010
4122
  // height — a box that grew and shrank would shove the list under the
@@ -4068,9 +4180,19 @@ function SessionDetailBox({ session, childCount = 0, pause = null, controller =
4068
4180
  // the "Paused now" band shows, with a way through to the provider that
4069
4181
  // caused it. `waitReason` covers every OTHER kind of wait (a scheduled
4070
4182
  // one, an agent asking to sleep), which is a different fact and says so.
4071
- const waitReason = typeof session?.waitReason === "string" && session.waitReason.trim()
4072
- ? session.waitReason.trim()
4073
- : null;
4183
+ //
4184
+ // The block is gated on the DEBOUNCED status, not on waitReason alone.
4185
+ // Without that gate the two halves of this box run on different clocks:
4186
+ // the Updated row holds a status change for 5s (mergeSessionRowVisualStatus)
4187
+ // while waitReason is applied raw, and mid-turn the two writers disagree —
4188
+ // the CMS row still carries the sentence after the live orchestration has
4189
+ // moved on, or the reverse. The result was a WAITING block blinking in and
4190
+ // out on every 4s poll, and a box that could read "(running)" with a
4191
+ // WAITING row under it at the same time.
4192
+ //
4193
+ // A budget pause is separate: it is authoritative on its own and is not
4194
+ // derived from the session's status, so it always shows.
4195
+ const waitReason = visibleWaitReason(session, statusSummary?.status);
4074
4196
  const pauseBlock = (!pause && !waitReason) ? null : React.createElement("div", {
4075
4197
  className: `ps-session-detail-pause${pause ? " is-paused" : ""}`,
4076
4198
  },
@@ -4092,7 +4214,60 @@ function SessionDetailBox({ session, childCount = 0, pause = null, controller =
4092
4214
  ).catch(() => {}),
4093
4215
  }, pause.provider ? `Open ${pause.provider}` : "Open providers and budgets") : null));
4094
4216
 
4217
+ // Collapsed, the box is one line: the state you check constantly (how full
4218
+ // the context is, when it last moved, whether it is stuck) without the ten
4219
+ // rows you look up once. Expanding is a click, and the choice is a
4220
+ // preference so it survives switching sessions and reloading.
4221
+ if (collapsed) {
4222
+ const marks = [];
4223
+ if (context) {
4224
+ marks.push(React.createElement("span", {
4225
+ key: "context",
4226
+ className: `ps-session-detail-mark${percent != null && percent >= 85 ? " is-hot" : percent != null && percent >= 70 ? " is-warm" : ""}`,
4227
+ }, percent != null ? `${percent}%` : context));
4228
+ }
4229
+ if (statusSummary) {
4230
+ marks.push(React.createElement("span", { key: "updated", className: "ps-session-detail-mark" },
4231
+ `${statusSummary.relative} (${statusSummary.status})`));
4232
+ }
4233
+ // A stopped session is the one thing you must not have to expand to
4234
+ // find out, so it keeps a marker in the collapsed line.
4235
+ if (pause || waitReason) {
4236
+ marks.push(React.createElement("span", {
4237
+ key: "halt",
4238
+ className: `ps-session-detail-mark${pause ? " is-paused" : ""}`,
4239
+ title: pause ? pause.reason : waitReason,
4240
+ }, pause ? "paused" : "waiting"));
4241
+ }
4242
+ return React.createElement("div", { className: "ps-session-detail-box is-collapsed" },
4243
+ React.createElement("button", {
4244
+ type: "button",
4245
+ className: "ps-session-detail-summary",
4246
+ "aria-expanded": false,
4247
+ title: "Show session details",
4248
+ onClick: onToggle,
4249
+ },
4250
+ React.createElement("span", { className: "ps-session-detail-twisty", "aria-hidden": "true" }, "▸"),
4251
+ React.createElement("span", { className: "ps-session-detail-summary-title" },
4252
+ session?.title || SESSION_DETAIL_NONE),
4253
+ React.createElement("span", { className: "ps-session-detail-marks" }, marks)),
4254
+ // A stopped session keeps its whole block, not just a marker. The
4255
+ // fold is for reference detail you look up once — id, owner, agent.
4256
+ // Why the session is stopped, and the way through to the surface
4257
+ // that can unstop it, is the one thing you need WITHOUT going
4258
+ // looking for it, and folding it away hid it behind a click at the
4259
+ // exact moment it mattered.
4260
+ pauseBlock);
4261
+ }
4262
+
4095
4263
  return React.createElement("div", { className: "ps-session-detail-box" },
4264
+ React.createElement("button", {
4265
+ type: "button",
4266
+ className: "ps-session-detail-collapse",
4267
+ "aria-expanded": true,
4268
+ title: "Hide session details",
4269
+ onClick: onToggle,
4270
+ }, React.createElement("span", { "aria-hidden": "true" }, "▾"), " Less"),
4096
4271
  // The row above ellipsizes to one line, so this is the only place the
4097
4272
  // whole name is legible. Two lines are RESERVED whether or not they are
4098
4273
  // used, so the box height still cannot move with the selection.
@@ -4399,76 +4574,226 @@ function useStableValue(value) {
4399
4574
  }
4400
4575
 
4401
4576
  /**
4402
- * Vertical-primary touch scrolling with an explicit horizontal opt-in.
4577
+ * One axis per gesture: a touch drag scrolls the session list up/down OR
4578
+ * left/right, never both.
4403
4579
  *
4404
4580
  * The session list has `width: max-content` rows, so it overflows sideways
4405
- * whenever a title is long. Declaring `touch-action: pan-x pan-y` told the
4406
- * browser both axes were equally on offer, and on a phone a swipe is never
4407
- * perfectly vertical — so scrolling down kept sliding the list sideways.
4581
+ * whenever a title is long. With both axes on offer a swipe is never perfectly
4582
+ * vertical, so scrolling down kept sliding the list sideways too.
4583
+ *
4584
+ * This used to hand the vertical case back to the browser (`touch-action:
4585
+ * pan-y`) and only drive horizontal itself. That relied on the browser to
4586
+ * refuse the sideways component, and it has two problems:
4587
+ *
4588
+ * - What counts as "vertical enough" for `pan-y` is the browser's call, not
4589
+ * ours, and engines disagree. The guarantee was only as good as whichever
4590
+ * one the reader happened to be using.
4591
+ * - A drag near 45° fell in the gap: we committed it to vertical and stepped
4592
+ * aside, and the browser then declined to pan it at all. The list simply
4593
+ * did not move, which reads as broken rather than as a locked axis.
4408
4594
  *
4409
- * The fix is to give the browser ONE axis (`touch-action: pan-y`, set in CSS,
4410
- * which keeps native vertical momentum) and drive horizontal ourselves, only
4411
- * for a gesture that is unambiguously sideways. Once a gesture commits to an
4412
- * axis it keeps it until the finger lifts: to scroll across you stop and make
4413
- * a deliberate left/right swipe, which is exactly the intent.
4595
+ * So both axes are driven here. Every gesture past the slop threshold commits
4596
+ * to exactly one axis and always moves it — no dead zone, and no engine gets a
4597
+ * vote on the diagonal. Vertical stays the default: sideways has to be asked
4598
+ * for, by a drag that beats vertical by PAN_HORIZONTAL_RATIO.
4599
+ *
4600
+ * Driving the scroll ourselves means we also own the part the browser was
4601
+ * doing for free, so a flick decays under `releaseFling` instead of stopping
4602
+ * dead under the finger. CSS keeps `touch-action: pan-y` as a floor: if these
4603
+ * listeners ever fail to attach, the list still scrolls vertically the native
4604
+ * way rather than becoming inert.
4414
4605
  */
4415
4606
  const PAN_COMMIT_PX = 10; // ignore the first few px — every swipe starts noisy
4416
4607
  const PAN_HORIZONTAL_RATIO = 1.6; // |dx| must beat |dy| by this much to go sideways
4608
+ const FLING_MIN_VELOCITY = 0.05; // px/ms below which a lift is a stop, not a flick
4609
+ const FLING_FRICTION = 0.95; // per frame; ~0.35s of glide from a brisk flick
4610
+ const FLING_MAX_VELOCITY = 4; // px/ms ceiling, so a fast flick stays catchable
4611
+
4612
+ /**
4613
+ * Which axis does this gesture own? Pure, and exported so the rule can be
4614
+ * tested without a browser — the bug it replaces was a gesture that matched
4615
+ * NEITHER branch and therefore scrolled nothing.
4616
+ *
4617
+ * Returns null while the drag is still inside the slop radius, then "x" or
4618
+ * "y" — never null again, and never both. Every gesture that gets past the
4619
+ * threshold moves something.
4620
+ */
4621
+ export function commitPanAxis(dx, dy) {
4622
+ if (Math.abs(dx) < PAN_COMMIT_PX && Math.abs(dy) < PAN_COMMIT_PX) return null;
4623
+ // Ties, and anything close to a tie, go to vertical. Vertical is the
4624
+ // primary axis; sideways has to be asked for.
4625
+ return Math.abs(dx) > Math.abs(dy) * PAN_HORIZONTAL_RATIO ? "x" : "y";
4626
+ }
4417
4627
 
4418
- function useHorizontalPanOptIn(ref) {
4628
+ function useAxisLockedPan(ref, enabled = true) {
4419
4629
  React.useEffect(() => {
4420
4630
  const el = ref.current;
4421
- if (!el) return undefined;
4631
+ // Coarse pointers only. On a touchscreen LAPTOP the pointer is fine,
4632
+ // so drag-to-reorder is armed on the same rows — and because this hook
4633
+ // prevents native scrolling, the browser never fires the pointercancel
4634
+ // that would otherwise end that drag. Both would then own one finger:
4635
+ // a ghost row appears, the list pans under it, and the drop resolves
4636
+ // against a list that moved. Mirrors the gate reorder uses the other
4637
+ // way round; a fine pointer scrolls natively under the pan-y CSS.
4638
+ if (!el || !enabled) return undefined;
4422
4639
 
4423
4640
  let startX = 0;
4424
4641
  let startY = 0;
4425
4642
  let lastX = 0;
4426
- let axis = null; // null = undecided, "x" | "y" = committed for this gesture
4643
+ let lastY = 0;
4644
+ let lastT = 0;
4645
+ let velocity = 0; // px/ms along the committed axis, sign follows the finger
4646
+ let axis = null; // null = undecided, "x" | "y" = committed, "done" = not ours
4647
+ let flingFrame = null;
4648
+ let swallowClick = false;
4649
+
4650
+ const stopFling = () => {
4651
+ if (flingFrame !== null) {
4652
+ cancelAnimationFrame(flingFrame);
4653
+ flingFrame = null;
4654
+ }
4655
+ };
4656
+
4657
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
4658
+ const canScroll = (which) => (which === "x"
4659
+ ? el.scrollWidth > el.clientWidth + 1
4660
+ : el.scrollHeight > el.clientHeight + 1);
4427
4661
 
4428
4662
  const onTouchStart = (event) => {
4429
- if (event.touches.length !== 1) { axis = "y"; return; }
4430
- const touch = event.touches[0];
4663
+ // A tap that lands while the list is still gliding is "stop", not
4664
+ // "open whatever is passing under my finger". Native momentum never
4665
+ // opened a row on the arresting tap; ours must not either.
4666
+ swallowClick = flingFrame !== null;
4667
+ stopFling();
4668
+ // targetTouches, not touches: a thumb resting elsewhere on the page
4669
+ // is not part of this gesture. Two fingers HERE is a pinch.
4670
+ if (event.targetTouches.length !== 1) { axis = "done"; return; }
4671
+ const touch = event.targetTouches[0];
4431
4672
  startX = lastX = touch.clientX;
4432
- startY = touch.clientY;
4673
+ startY = lastY = touch.clientY;
4674
+ lastT = now();
4675
+ velocity = 0;
4433
4676
  axis = null;
4434
4677
  };
4435
4678
 
4436
4679
  const onTouchMove = (event) => {
4437
- if (axis === "y" || event.touches.length !== 1) return;
4438
- const touch = event.touches[0];
4680
+ if (axis === "done" || event.targetTouches.length !== 1) return;
4681
+ const touch = event.targetTouches[0];
4439
4682
  const dx = touch.clientX - startX;
4440
4683
  const dy = touch.clientY - startY;
4441
4684
 
4442
4685
  if (axis === null) {
4443
- if (Math.abs(dx) < PAN_COMMIT_PX && Math.abs(dy) < PAN_COMMIT_PX) return;
4444
- // Ties, and anything close to a tie, go to vertical. Vertical is
4445
- // the primary axis; sideways has to be asked for.
4446
- axis = Math.abs(dx) > Math.abs(dy) * PAN_HORIZONTAL_RATIO ? "x" : "y";
4686
+ // Decide BEFORE preventing. Preventing a touchmove prevents
4687
+ // scrolling for every later move of that touch, so a hand-off
4688
+ // after the commit cannot hand anything back — the browser has
4689
+ // already been told no.
4690
+ //
4691
+ // Vertical is the browser's own axis here (touch-action:
4692
+ // pan-y), and it has to be claimed from the FIRST move or not
4693
+ // at all: the browser's slop is smaller than ours (5-8px
4694
+ // against 10), so an un-prevented move past it starts a native
4695
+ // pan-y scroll and marks the rest of the gesture
4696
+ // non-cancelable — our preventDefault then no-ops while
4697
+ // scrollTop -= step still runs, and vertical moved at double
4698
+ // speed with two flings on release. So a list with vertical
4699
+ // overflow is claimed now, inside the slop.
4700
+ //
4701
+ // A list WITHOUT vertical overflow leaves the early moves
4702
+ // alone. The browser only ever takes the vertical under pan-y,
4703
+ // so a sideways commit can still claim the gesture for the
4704
+ // list's horizontal overflow (a short list of long titles),
4705
+ // and a vertical commit hands the pane its scroll untouched.
4706
+ const ownsVertical = canScroll("y");
4707
+ if (!ownsVertical && !canScroll("x")) { axis = "done"; return; }
4708
+ if (ownsVertical && event.cancelable) event.preventDefault();
4709
+ axis = commitPanAxis(dx, dy);
4710
+ if (axis === null) return;
4711
+ // A list that cannot scroll on the committed axis is not ours
4712
+ // to pan; and if the browser has already begun its own scroll
4713
+ // (the event is no longer cancelable), the gesture is its.
4714
+ if (!canScroll(axis) || !event.cancelable) { axis = "done"; return; }
4715
+ // Measure from here, so the slop we ignored is not also applied.
4447
4716
  lastX = touch.clientX;
4448
- if (axis === "y") return;
4717
+ lastY = touch.clientY;
4718
+ lastT = now();
4449
4719
  }
4450
4720
 
4451
- // Committed horizontal: the browser is not panning this axis, so we
4452
- // do it. preventDefault stops the gesture also scrolling an
4453
- // ancestor sideways.
4454
- el.scrollLeft -= touch.clientX - lastX;
4455
- lastX = touch.clientX;
4721
+ // Ours: stop the browser doing it as well. A repeat of the
4722
+ // first-move prevent for a list that owns vertical; the first
4723
+ // prevent of the gesture for a sideways pan on a short list.
4456
4724
  if (event.cancelable) event.preventDefault();
4725
+
4726
+ const stepX = touch.clientX - lastX;
4727
+ const stepY = touch.clientY - lastY;
4728
+ const t = now();
4729
+ const elapsed = Math.max(1, t - lastT);
4730
+ const step = axis === "x" ? stepX : stepY;
4731
+
4732
+ if (axis === "x") el.scrollLeft -= stepX;
4733
+ else el.scrollTop -= stepY;
4734
+
4735
+ // Exponential smoothing: one jittery sample should not decide the
4736
+ // whole flick, and a finger that stops before lifting should decay
4737
+ // to zero rather than fling on a stale reading.
4738
+ const sample = Math.max(-FLING_MAX_VELOCITY, Math.min(FLING_MAX_VELOCITY, step / elapsed));
4739
+ velocity = velocity * 0.7 + sample * 0.3;
4740
+
4741
+ lastX = touch.clientX;
4742
+ lastY = touch.clientY;
4743
+ lastT = t;
4457
4744
  };
4458
4745
 
4459
- const onTouchEnd = () => { axis = null; };
4746
+ const releaseFling = (committed) => {
4747
+ if (Math.abs(velocity) < FLING_MIN_VELOCITY) return;
4748
+ let v = velocity;
4749
+ const tick = () => {
4750
+ v *= FLING_FRICTION;
4751
+ if (Math.abs(v) < FLING_MIN_VELOCITY) { flingFrame = null; return; }
4752
+ const before = committed === "x" ? el.scrollLeft : el.scrollTop;
4753
+ if (committed === "x") el.scrollLeft -= v * 16;
4754
+ else el.scrollTop -= v * 16;
4755
+ const after = committed === "x" ? el.scrollLeft : el.scrollTop;
4756
+ // Hit an end stop — no point animating against it.
4757
+ if (after === before) { flingFrame = null; return; }
4758
+ flingFrame = requestAnimationFrame(tick);
4759
+ };
4760
+ flingFrame = requestAnimationFrame(tick);
4761
+ };
4762
+
4763
+ const onTouchEnd = (event) => {
4764
+ // Lifting ONE finger of a pinch does not hand the survivor a pan:
4765
+ // its start point belongs to a different gesture, and the commit
4766
+ // would land on a large bogus delta. Stay out until all lift.
4767
+ if (event.targetTouches.length > 0) { axis = "done"; return; }
4768
+ const committed = axis;
4769
+ axis = null;
4770
+ if (committed === "x" || committed === "y") releaseFling(committed);
4771
+ velocity = 0;
4772
+ };
4773
+
4774
+ const onTouchCancel = () => { axis = null; velocity = 0; stopFling(); };
4775
+
4776
+ const onClickCapture = (event) => {
4777
+ if (!swallowClick) return;
4778
+ swallowClick = false;
4779
+ event.preventDefault();
4780
+ event.stopPropagation();
4781
+ };
4460
4782
 
4461
4783
  el.addEventListener("touchstart", onTouchStart, { passive: true });
4462
4784
  el.addEventListener("touchmove", onTouchMove, { passive: false });
4463
4785
  el.addEventListener("touchend", onTouchEnd, { passive: true });
4464
- el.addEventListener("touchcancel", onTouchEnd, { passive: true });
4786
+ el.addEventListener("touchcancel", onTouchCancel, { passive: true });
4787
+ el.addEventListener("click", onClickCapture, true);
4465
4788
  return () => {
4789
+ stopFling();
4466
4790
  el.removeEventListener("touchstart", onTouchStart);
4467
4791
  el.removeEventListener("touchmove", onTouchMove);
4468
4792
  el.removeEventListener("touchend", onTouchEnd);
4469
- el.removeEventListener("touchcancel", onTouchEnd);
4793
+ el.removeEventListener("touchcancel", onTouchCancel);
4794
+ el.removeEventListener("click", onClickCapture, true);
4470
4795
  };
4471
- }, [ref]);
4796
+ }, [ref, enabled]);
4472
4797
  }
4473
4798
 
4474
4799
  function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false, showDetailBox = null }) {
@@ -4479,13 +4804,21 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4479
4804
  const isMobilePane = String(panelClassName).includes("ps-mobile-session-pane");
4480
4805
  // Vertical is the primary scroll axis; sideways takes a deliberate swipe.
4481
4806
  const sessionListRef = React.useRef(null);
4482
- useHorizontalPanOptIn(sessionListRef);
4483
4807
  // Selection reverts to plain taps wherever the primary input is a finger:
4484
4808
  // the mobile pane, and the chat-focus overlay's list on a phone. Matches
4485
4809
  // the `(pointer: fine)` guard on touch-action in the stylesheet.
4486
- const touchInput = isMobilePane || useCoarsePointer();
4810
+ // Called unconditionally — `isMobilePane || useCoarsePointer()` skipped
4811
+ // the hook whenever the left side was true, which is a hooks-order bug
4812
+ // waiting for panelClassName to change under a mounted pane.
4813
+ const coarsePointer = useCoarsePointer();
4814
+ const touchInput = isMobilePane || coarsePointer;
4815
+ useAxisLockedPan(sessionListRef, touchInput);
4487
4816
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
4488
4817
  const theme = getTheme(themeId);
4818
+ const detailCollapsed = useControllerSelector(
4819
+ controller,
4820
+ (state) => Boolean(state.ui.sessionDetailCollapsed),
4821
+ );
4489
4822
  const sessionButtonRefs = React.useRef(new Map());
4490
4823
  const viewState = useControllerSelector(controller, (state) => ({
4491
4824
  branding: state.branding,
@@ -5282,6 +5615,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5282
5615
  // change it. The row already carries the joined pause record.
5283
5616
  pause: activeRow?.pause || null,
5284
5617
  controller,
5618
+ collapsed: detailCollapsed,
5619
+ onToggle: () => controller.dispatch({
5620
+ type: "ui/sessionDetailCollapsed",
5621
+ collapsed: !detailCollapsed,
5622
+ }),
5285
5623
  })
5286
5624
  : null),
5287
5625
  (manageOpen && activeSession && !activeSession.isGroup)
@@ -5881,7 +6219,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
5881
6219
  : value === "shared_read" ? "everyone here can view"
5882
6220
  : "everyone here can view and send"))),
5883
6221
  React.createElement("div", { className: "ps-share-section-label" }, "Special access"),
5884
- React.createElement("div", { className: "ps-share-section-sub" }, "Give specific people more than the general level. A person's grant wins over general access."),
6222
+ 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."),
5885
6223
  draftShares.length === 0
5886
6224
  ? React.createElement("div", { className: "ps-share-empty" }, "No individual grants — everyone has the general access above.")
5887
6225
  : draftShares.map((row) => React.createElement("div", { key: `${row.provider}/${row.subject}`, className: "ps-share-grant-row" },
@@ -6576,6 +6914,13 @@ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
6576
6914
  // making, and stacking them put a destructive Reset next to an innocuous
6577
6915
  // deep link. Mirrors the Manage-session dialog's General/Access tabs.
6578
6916
  const [tab, setTab] = React.useState("session");
6917
+ // The canvas KV write policy (interactive-canvas-apps Part D): who may
6918
+ // write the app's shared state. null = not loaded / not supported.
6919
+ const [kvPolicy, setKvPolicy] = React.useState(null);
6920
+ // Who the VIEWER is on this canvas. Only an owner or an admin may change
6921
+ // the policy, so the control is disabled for everyone else rather than
6922
+ // letting them pick a value and then showing a refusal.
6923
+ const [kvMe, setKvMe] = React.useState(null);
6579
6924
  const transport = controller.transport;
6580
6925
  const sessionLink = `${window.location.origin}/?session=${encodeURIComponent(sessionId)}&view=canvas&slot=${slot}&max=1`;
6581
6926
  // The token variants all resolve the same hashed row: one token, N doors,
@@ -6588,9 +6933,24 @@ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
6588
6933
  transport.getCanvasShareLink?.(sessionId, slot)
6589
6934
  .then((state) => setInfo(state || { exists: false }))
6590
6935
  .catch((err) => { setInfo({ exists: false }); setError(err?.message || String(err)); });
6936
+ if (typeof transport.readCanvasKv === "function") {
6937
+ transport.readCanvasKv(sessionId, slot, { limit: 1 })
6938
+ .then((read) => { setKvPolicy(read?.policy || "owner"); setKvMe(read?.me || null); })
6939
+ .catch(() => { setKvPolicy(null); setKvMe(null); });
6940
+ }
6591
6941
  }, [transport, sessionId, slot]);
6592
6942
  React.useEffect(() => { refresh(); }, [refresh]);
6593
6943
 
6944
+ const canSetKvPolicy = kvMe?.relation === "owner" || kvMe?.relation === "admin";
6945
+
6946
+ const setPolicy = (value) => {
6947
+ setBusy(true); setError("");
6948
+ transport.setCanvasKvAccess(sessionId, slot, value)
6949
+ .then(() => setKvPolicy(value))
6950
+ .catch((err) => setError(err?.message || String(err)))
6951
+ .finally(() => setBusy(false));
6952
+ };
6953
+
6594
6954
  const copy = (label, text) => {
6595
6955
  navigator.clipboard?.writeText(text)
6596
6956
  .then(() => { setCopied(label); window.setTimeout(() => setCopied(""), 1500); })
@@ -6648,7 +7008,34 @@ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
6648
7008
  role: "tabpanel", "aria-hidden": tab === "session" ? undefined : "true",
6649
7009
  },
6650
7010
  React.createElement("div", { className: "ps-canvas-share-sub" }, "Opens the portal signed in, canvas full screen. Session visibility rules apply."),
6651
- linkRows(sessionLink, "session")),
7011
+ linkRows(sessionLink, "session"),
7012
+ // Who may WRITE the app's shared state. Stated in words:
7013
+ // "readers" reaches everyone the session is read-shared
7014
+ // with, which a dialog that only says "link" hides.
7015
+ kvPolicy !== null
7016
+ ? React.createElement("div", { className: "ps-canvas-share-policy" },
7017
+ React.createElement("label", { className: "ps-canvas-share-sub", htmlFor: "ps-canvas-kv-policy" }, "Who can edit inside this app"),
7018
+ React.createElement("select", {
7019
+ id: "ps-canvas-kv-policy",
7020
+ className: "ps-canvas-slot-select",
7021
+ value: kvPolicy,
7022
+ disabled: busy || !canSetKvPolicy,
7023
+ onChange: (event) => setPolicy(event.target.value),
7024
+ },
7025
+ React.createElement("option", { value: "owner" }, "Only people who can edit the session"),
7026
+ React.createElement("option", { value: "readers" }, "Anyone who can open the session"),
7027
+ React.createElement("option", { value: "link" }, "Anyone who can open the session, plus link viewers")),
7028
+ React.createElement("div", { className: "ps-canvas-share-sub" },
7029
+ kvPolicy === "owner"
7030
+ ? "You, admins, and anyone you shared the session with as “write”. People with view-only access can watch the app but not change anything in it."
7031
+ : kvPolicy === "link"
7032
+ ? "Your view-only people can edit inside the app. Anyone holding the share link can watch it — link holders can never edit."
7033
+ : "Adds your view-only people: they can edit inside the app. It does not let them message the agent."),
7034
+ canSetKvPolicy
7035
+ ? null
7036
+ : React.createElement("div", { className: "ps-canvas-share-sub" },
7037
+ "Only the session owner or an admin can change this."))
7038
+ : null),
6652
7039
  React.createElement("div", {
6653
7040
  className: `ps-canvas-share-panel${tab === "public" ? " is-active" : ""}`,
6654
7041
  role: "tabpanel", "aria-hidden": tab === "public" ? undefined : "true",
@@ -6753,6 +7140,9 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
6753
7140
  const autoRetryRef = React.useRef({ rev: 0, used: false });
6754
7141
  const hostRef = React.useRef(null);
6755
7142
  const liveIframeElRef = React.useRef(null);
7143
+ // The staging frame's element, so the KV bridge can answer a page's
7144
+ // `ready` posted at script time (before promote). See the bridge below.
7145
+ const stagingIframeElRef = React.useRef(null);
6756
7146
  const hostBox = useElementBox(hostRef);
6757
7147
  // The frame geometry each revision LOADED at. A sandboxed page cannot be
6758
7148
  // poked after the fact (no reaching into an opaque origin), and pages
@@ -6779,18 +7169,99 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
6779
7169
  if (!controller || !sessionId) return undefined;
6780
7170
  const onMessage = (event) => {
6781
7171
  const frameEl = liveIframeElRef.current;
6782
- if (!frameEl || !event.source || event.source !== frameEl.contentWindow) return;
6783
7172
  const payload = event.data;
6784
- if (!payload || typeof payload !== "object" || payload.type !== "canvas-action") return;
6785
- controller.submitCanvasAction(sessionId, payload).then((result) => {
6786
- if (result && result.ok === false && typeof console !== "undefined") {
6787
- console.warn(`canvas action rejected: ${result.reason}`);
7173
+ if (!payload || typeof payload !== "object" || !event.source) return;
7174
+ const fromLive = Boolean(frameEl && event.source === frameEl.contentWindow);
7175
+ // A document runs its scripts in the STAGING frame first and is
7176
+ // promoted on load — so a page that calls CanvasKV() at script
7177
+ // time posts `ready` before this frame is live. KV READS are
7178
+ // safe to answer from the staging frame of THIS canvas (it holds
7179
+ // the revision about to go live; the reply goes back to the
7180
+ // window that asked). Writes and actions stay live-frame only.
7181
+ const fromStaging = Boolean(stagingIframeElRef.current && event.source === stagingIframeElRef.current.contentWindow);
7182
+ if (!fromLive && !fromStaging) return;
7183
+ if (payload.type === "canvas-action") {
7184
+ if (!fromLive) return;
7185
+ controller.submitCanvasAction(sessionId, payload).then((result) => {
7186
+ if (result && result.ok === false && typeof console !== "undefined") {
7187
+ console.warn(`canvas action rejected: ${result.reason}`);
7188
+ }
7189
+ }).catch(() => {});
7190
+ return;
7191
+ }
7192
+ // The KV bridge (door 1). The page never names the session or the
7193
+ // slot — both come from THIS frame — and never sends `by`: the
7194
+ // server stamps it from the signed-in principal.
7195
+ if (payload.type === "canvas-kv") {
7196
+ const id = payload.id;
7197
+ const transport = controller.transport;
7198
+ // Reply to the window that ASKED (live or staging), never to
7199
+ // whichever frame is live by the time the request lands.
7200
+ const source = event.source;
7201
+ const post = (message) => {
7202
+ try { source.postMessage(message, "*"); } catch { /* frame mid-teardown */ }
7203
+ };
7204
+ const fail = (error, code = "ERROR") => post({ type: "canvas-kv-result", id, ok: false, error: String(error?.message || error || "failed"), code: error?.code || code });
7205
+ if (typeof transport?.readCanvasKv !== "function") { fail("the canvas KV store is not available on this deployment", "UNAVAILABLE"); return; }
7206
+ const op = String(payload.op || "");
7207
+ if (!fromLive && (op === "put" || op === "delete")) {
7208
+ fail("the canvas is still loading; retry the write in a moment", "NOT_READY");
7209
+ return;
7210
+ }
7211
+ if (op === "ready" || op === "list") {
7212
+ transport.readCanvasKv(sessionId, slot, { prefix: payload.prefix ?? null, limit: payload.limit ?? null, after: payload.after ?? null })
7213
+ .then((read) => post({
7214
+ type: op === "ready" ? "canvas-kv-ready" : "canvas-kv-result",
7215
+ id, ok: true,
7216
+ entries: read?.entries ?? [], nextAfter: read?.nextAfter ?? null,
7217
+ me: read?.me ?? null, canWrite: Boolean(read?.me?.canWrite), policy: read?.policy ?? "owner",
7218
+ }))
7219
+ .catch(fail);
7220
+ return;
7221
+ }
7222
+ if (op === "get") {
7223
+ transport.readCanvasKv(sessionId, slot, { key: payload.key })
7224
+ .then((read) => post({ type: "canvas-kv-result", id, ok: true, entry: read?.entries?.[0] ?? null }))
7225
+ .catch(fail);
7226
+ return;
6788
7227
  }
6789
- }).catch(() => {});
7228
+ if (op === "put" || op === "delete") {
7229
+ const one = op === "put"
7230
+ ? { op: "put", key: payload.key, value: payload.value, ...(payload.ifMatch != null ? { ifMatch: payload.ifMatch } : {}) }
7231
+ : { op: "delete", key: payload.key, ...(payload.ifMatch != null ? { ifMatch: payload.ifMatch } : {}) };
7232
+ transport.writeCanvasKv(sessionId, slot, [one])
7233
+ .then((written) => {
7234
+ const r = written?.results?.[0] ?? { ok: false, error: "no result" };
7235
+ post({ type: "canvas-kv-result", id, ok: Boolean(r.ok), key: r.key, rev: r.rev, capped: r.capped, error: r.error, code: r.code });
7236
+ })
7237
+ .catch(fail);
7238
+ return;
7239
+ }
7240
+ fail(`unknown canvas-kv op ${op}`, "INVALID_REQUEST");
7241
+ }
6790
7242
  };
6791
7243
  window.addEventListener("message", onMessage);
6792
7244
  return () => window.removeEventListener("message", onMessage);
6793
- }, [controller, sessionId]);
7245
+ }, [controller, sessionId, slot]);
7246
+
7247
+ // Live KV changes for this slot → the page, as `canvas-kv-change`. A
7248
+ // pointer-only ping (value too big for the notify) fetches that one key.
7249
+ React.useEffect(() => {
7250
+ if (!controller || !sessionId || typeof controller.subscribeCanvasKv !== "function") return undefined;
7251
+ return controller.subscribeCanvasKv(sessionId, (change) => {
7252
+ if (!change || Number(change.slot) !== Number(slot)) return;
7253
+ const frameWindow = liveIframeElRef.current?.contentWindow;
7254
+ if (!frameWindow) return;
7255
+ const deliver = (entry) => {
7256
+ try { frameWindow.postMessage({ type: "canvas-kv-change", key: change.key, rev: change.rev, op: change.op, ...(entry ? { v: entry.v, by: entry.by, at: entry.at } : {}) }, "*"); } catch { /* frame mid-teardown */ }
7257
+ };
7258
+ if (change.op === "delete") { deliver(null); return; }
7259
+ if (change.value && typeof change.value === "object") { deliver({ v: change.value.v, by: change.value.by, at: change.value.at }); return; }
7260
+ controller.transport?.readCanvasKv?.(sessionId, slot, { key: change.key })
7261
+ .then((read) => deliver(read?.entries?.[0] ?? null))
7262
+ .catch(() => { /* the page's next list catches up */ });
7263
+ });
7264
+ }, [controller, sessionId, slot]);
6794
7265
 
6795
7266
  React.useEffect(() => {
6796
7267
  if (!controller || !sessionId || !latestRev) return undefined;
@@ -6971,7 +7442,9 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
6971
7442
  return React.createElement("div", { ref: hostRef, className: "ps-html-preview-host is-fitting" },
6972
7443
  [live, staging].filter(Boolean).map((frame) => React.createElement("iframe", {
6973
7444
  key: `canvas:${frame.rev}:${frame.url}`,
6974
- ref: frame === live ? ((el) => { liveIframeElRef.current = el; }) : undefined,
7445
+ ref: frame === live
7446
+ ? ((el) => { liveIframeElRef.current = el; })
7447
+ : ((el) => { stagingIframeElRef.current = el; }),
6975
7448
  className: `ps-html-preview-frame${frame === live ? "" : " is-staging"}`,
6976
7449
  style: zoomStyle,
6977
7450
  src: frame.url,
@@ -7014,6 +7487,12 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
7014
7487
  * maximized / onToggleMaximized are the phone's full-screen state, owned by
7015
7488
  * MobileCanvasLayer. Passing no handler simply omits the button.
7016
7489
  */
7490
+ // Match the session-list settle: the canvas follows the selection, and the
7491
+ // selection is a keypress away from changing again. Slightly longer than
7492
+ // SESSION_NAV_SETTLE_MS (140) so the snapshot rides just behind the load that
7493
+ // invalidates it, rather than racing it.
7494
+ const CANVAS_SNAPSHOT_SETTLE_MS = 160;
7495
+
7017
7496
  function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote = false, maximized = false, onToggleMaximized = null }) {
7018
7497
  const view = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
7019
7498
  const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
@@ -7022,11 +7501,33 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
7022
7501
  }, [controller]);
7023
7502
 
7024
7503
  // Belt to the selection-burst braces: a pane mounted before the burst's
7025
- // snapshot resolves (deep link straight into canvas mode) fetches it here;
7026
- // the memo makes the duplicate call free.
7504
+ // snapshot resolves (deep link straight into canvas mode) fetches it here.
7505
+ //
7506
+ // Gated on `visible`, and that gate is load-bearing. This pane is MOUNTED
7507
+ // even when the canvas is closed — hidden with inert + an off-screen
7508
+ // transform rather than unmounted, so re-showing it does not reload the
7509
+ // frame. So this effect runs on every selection change whether or not
7510
+ // anyone can see the canvas, and the memo it leans on is only free for a
7511
+ // REPEAT of the same session: each new one is a real fetch.
7512
+ //
7513
+ // Arrowing down a session list is a selection change per keypress. That
7514
+ // turned a 30-row traversal into 30 `events-before` round trips against a
7515
+ // pane nobody had opened — free on a stub, the whole network latency on a
7516
+ // remote deployment, which is exactly what session-list-perf guards.
7517
+ // A hidden canvas needs no snapshot; becoming visible re-runs this.
7518
+ //
7519
+ // And it waits for the selection to SETTLE. loadSession already debounces
7520
+ // its own burst behind SESSION_NAV_SETTLE_MS for exactly this reason —
7521
+ // this effect keyed straight off the selection and so re-armed the cost
7522
+ // the debounce had just removed. Arrowing 30 rows fired 30 snapshot
7523
+ // fetches; the cleanup cancels every one but the row you stop on.
7027
7524
  React.useEffect(() => {
7028
- if (view.sessionId) controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
7029
- }, [controller, view.sessionId]);
7525
+ if (!visible || !view.sessionId) return undefined;
7526
+ const timer = setTimeout(() => {
7527
+ controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
7528
+ }, CANVAS_SNAPSHOT_SETTLE_MS);
7529
+ return () => clearTimeout(timer);
7530
+ }, [controller, view.sessionId, visible]);
7030
7531
 
7031
7532
  const zenOn = useControllerSelector(controller, (s) => s?.ui?.canvasZen === true);
7032
7533
  const [shareOpen, setShareOpen] = React.useState(false);
@@ -8517,6 +9018,19 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8517
9018
  setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
8518
9019
  }, [mobile]);
8519
9020
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
9021
+ // The same panel is Settings for a plain user — their own providers,
9022
+ // default and packages — and the Admin Console for an admin, who also
9023
+ // gets shared providers, workers and every user's packages. Calling it
9024
+ // "Admin console" for someone who then finds nothing administrative in
9025
+ // it reads as something missing or broken.
9026
+ // Two sources, because they arrive at different times: the auth context
9027
+ // carries the role from sign-in, before the console has ever opened; the
9028
+ // admin profile carries it once it has. Reading only the profile left an
9029
+ // admin's toolbar saying "Settings" until the first open.
9030
+ const isAdminUser = useControllerSelector(controller, (state) => Boolean(
9031
+ state.admin?.profile?.isAdmin || state.auth?.authorization?.role === "admin",
9032
+ ));
9033
+ const consoleName = isAdminUser ? "admin console" : "settings";
8520
9034
  const budgetOpen = useControllerSelector(controller, (state) => Boolean(state.ui?.budgetOpen));
8521
9035
  const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
8522
9036
  const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
@@ -8619,7 +9133,7 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8619
9133
  ...(mobile ? [] : [{
8620
9134
  key: "admin",
8621
9135
  icon: React.createElement(CogGlyph),
8622
- label: adminVisible ? "Close admin console" : "Admin console",
9136
+ label: adminVisible ? `Close ${consoleName}` : (isAdminUser ? "Admin console" : "Settings"),
8623
9137
  onClick: () => controller.handleCommand(adminVisible ? UI_COMMANDS.CLOSE_ADMIN_CONSOLE : UI_COMMANDS.OPEN_ADMIN_CONSOLE).catch(() => {}),
8624
9138
  active: adminVisible,
8625
9139
  }]),
@@ -9938,14 +10452,42 @@ function ProviderSystemSpend({ view }) {
9938
10452
  ? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" }, spend.error)
9939
10453
  : null,
9940
10454
  spend.loaded ? React.createElement(React.Fragment, null,
9941
- React.createElement("div", { className: "ps-budget-system-total" },
9942
- React.createElement("strong", null, `${spend.tokensLabel} tokens`),
9943
- React.createElement("span", { className: "ps-budget-sub" }, ` · ${budgetPlural(spend.turns, "turn", "turns")}`)),
10455
+ // The headline: total tokens and turns as two stat tiles, then a
10456
+ // per-model table with a share bar so the mix reads at a glance
10457
+ // (77.9M of 94.7M on one model is the fact that matters).
10458
+ React.createElement("div", { className: "ps-budget-system-stats" },
10459
+ React.createElement("div", { className: "ps-budget-system-stat" },
10460
+ React.createElement("strong", null, spend.tokensLabel),
10461
+ React.createElement("span", null, "tokens")),
10462
+ React.createElement("div", { className: "ps-budget-system-stat" },
10463
+ React.createElement("strong", null, formatCompactNumber(spend.turns)),
10464
+ React.createElement("span", null, spend.turns === 1 ? "turn" : "turns")),
10465
+ spend.turns > 0
10466
+ ? React.createElement("div", { className: "ps-budget-system-stat" },
10467
+ React.createElement("strong", null, formatCompactNumber(Math.round(spend.tokens / spend.turns))),
10468
+ React.createElement("span", null, "tokens / turn"))
10469
+ : null),
9944
10470
  spend.models.length
9945
- ? React.createElement("div", { className: "ps-budget-system-models" },
9946
- spend.models.map((model) => React.createElement("div", { key: model.key, className: "ps-budget-system-model" },
9947
- React.createElement("span", null, modelLabel(model.label)),
9948
- React.createElement("strong", null, `${model.tokensLabel} · ${budgetPlural(model.turns, "turn", "turns")}`))))
10471
+ ? React.createElement("table", { className: "ps-budget-system-models" },
10472
+ React.createElement("thead", null,
10473
+ React.createElement("tr", null,
10474
+ React.createElement("th", { scope: "col" }, "Model"),
10475
+ React.createElement("th", { scope: "col", className: "is-num" }, "Tokens"),
10476
+ React.createElement("th", { scope: "col", className: "is-share" }, "Share"),
10477
+ React.createElement("th", { scope: "col", className: "is-num" }, "Turns"))),
10478
+ React.createElement("tbody", null,
10479
+ spend.models.map((model) => {
10480
+ const share = spend.tokens > 0 ? model.tokens / spend.tokens : 0;
10481
+ const pct = Math.round(share * 100);
10482
+ return React.createElement("tr", { key: model.key },
10483
+ React.createElement("td", { className: "ps-budget-system-model-name" }, modelLabel(model.label)),
10484
+ React.createElement("td", { className: "is-num" }, model.tokensLabel),
10485
+ React.createElement("td", { className: "is-share" },
10486
+ React.createElement("span", { className: "ps-budget-system-bar", role: "img", "aria-label": `${pct}% of system spend` },
10487
+ React.createElement("span", { className: "ps-budget-system-bar-fill", style: { width: `${Math.max(share > 0 ? 1 : 0, share * 100)}%` } })),
10488
+ React.createElement("span", { className: "ps-budget-system-pct" }, `${pct}%`)),
10489
+ React.createElement("td", { className: "is-num" }, formatCompactNumber(model.turns)));
10490
+ })))
9949
10491
  : React.createElement("div", { className: "ps-budget-sub" }, "No system spend in this range.")) : null);
9950
10492
  }
9951
10493
 
@@ -10241,9 +10783,10 @@ function EditProviderSheet({
10241
10783
  * credential is the key itself, never a pointer to one.
10242
10784
  */
10243
10785
  function CreateProviderSheet({
10244
- types, existingNames, isAdmin, busy, error, onCancel, onConfirm,
10245
- initialName = "", initialShared = false, initialType = "",
10786
+ types, existingNames, isAdmin, busy, error, onDirty, onCancel, onConfirm,
10787
+ initialName = "", initialShared = false, initialType = "", updateProvider = null,
10246
10788
  }) {
10789
+ const updating = Boolean(updateProvider);
10247
10790
  const [name, setName] = React.useState(initialName);
10248
10791
  const [type, setType] = React.useState(initialType || types[0]?.id || "");
10249
10792
  const [credential, setCredential] = React.useState("");
@@ -10256,7 +10799,7 @@ function CreateProviderSheet({
10256
10799
  const trimmed = name.trim();
10257
10800
  // A name is unique across the cluster and immutable, so a clash is
10258
10801
  // permanent. The database refuses it too; refusing here saves the trip.
10259
- const taken = existingNames.some((existing) => existing.toLowerCase() === trimmed.toLowerCase());
10802
+ const taken = !updating && existingNames.some((existing) => existing.toLowerCase() === trimmed.toLowerCase());
10260
10803
  // MAJOR 16: the database's own check constraint allows only these, and it
10261
10804
  // reports every violation with one fixed sentence — so a name refused for
10262
10805
  // its LENGTH was told to use letters and numbers, which it already did.
@@ -10282,52 +10825,70 @@ function CreateProviderSheet({
10282
10825
  };
10283
10826
 
10284
10827
  return React.createElement(BudgetSheet, {
10285
- title: isGithub ? "Add GitHub Copilot provider" : "Add model provider",
10828
+ title: updating ? `Update key for ${trimmed}` : (isGithub ? "Add GitHub Copilot provider" : "Add model provider"),
10286
10829
  subtitle: shared ? "Shared" : "Personal",
10287
- confirmLabel: "Add provider", busy, disabled: !valid, error, onCancel: cancel, onConfirm: submit,
10288
- footNote: "",
10830
+ confirmLabel: updating ? "Update key" : "Add provider", busy, disabled: !valid, error, onCancel: cancel, onConfirm: submit,
10831
+ // The whole point of an update is what does NOT change. Saying so is
10832
+ // the difference between "replace the key" and "did I just make a
10833
+ // second provider?" — the Delete sheet below spells out its
10834
+ // consequences the same way.
10835
+ footNote: updating
10836
+ ? "Only the key changes. The name, type, base URL, defaults, system-session routing and usage history all stay as they are."
10837
+ : "",
10289
10838
  },
10290
10839
  React.createElement(SheetField, {
10291
10840
  label: "Name",
10292
10841
  hint: taken ? "A provider with that name already exists, and names are unique across the cluster."
10293
10842
  : tooLong ? `Too long: ${trimmed.length} characters, and a name may be at most 64.`
10294
10843
  : badChars ? "Letters, numbers, dot, dash and underscore only — no colon, no spaces, no accents: a session refers to it as \"<provider>:<model>\"."
10295
- : "Permanent. Used in provider:model.",
10844
+ : updating ? "Not changing. Sessions keep referring to it as provider:model."
10845
+ : "Permanent. Used in provider:model.",
10296
10846
  },
10297
10847
  React.createElement("input", {
10298
- ref, className: `ps-budget-input${(taken || badChars || tooLong) && trimmed ? " is-invalid" : ""}`,
10848
+ ref, className: `ps-budget-input${updating ? " is-static" : ""}${(taken || badChars || tooLong) && trimmed ? " is-invalid" : ""}`,
10299
10849
  "aria-label": "Provider name",
10300
10850
  value: name, placeholder: "azure-prod",
10301
- onChange: (event) => setName(event.target.value),
10851
+ disabled: updating,
10852
+ onChange: (event) => { onDirty?.(); setName(event.target.value); },
10302
10853
  onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
10303
10854
  })),
10304
10855
  React.createElement(SheetField, {
10305
10856
  label: "Type",
10306
- hint: "Backend and model catalog.",
10857
+ hint: updating ? "Not changing." : "Backend and model catalog.",
10307
10858
  },
10308
10859
  React.createElement("select", {
10309
- className: "ps-budget-input", "aria-label": "Provider type",
10310
- value: type, onChange: (event) => setType(event.target.value),
10860
+ className: `ps-budget-input${updating ? " is-static" : ""}`, "aria-label": "Provider type",
10861
+ value: type, disabled: updating, onChange: (event) => { onDirty?.(); setType(event.target.value); },
10311
10862
  }, types.length === 0
10312
10863
  ? React.createElement("option", { value: "" }, "No provider types are listed on this deployment")
10313
10864
  : types.map((option) => React.createElement("option", { key: option.id, value: option.id }, option.id)))),
10314
10865
  React.createElement(SheetField, {
10315
- label: isGithub ? "GitHub Copilot token" : "API key",
10316
- hint: "Paste the token value.",
10866
+ label: updating
10867
+ ? (isGithub ? "New GitHub Copilot token" : "New API key")
10868
+ : (isGithub ? "GitHub Copilot token" : "API key"),
10869
+ hint: updating
10870
+ ? "Replaces the current one. The existing key is never shown."
10871
+ : "Paste the token value.",
10317
10872
  },
10318
10873
  React.createElement("input", {
10319
10874
  className: "ps-budget-input", type: "password", autoComplete: "off",
10320
- "aria-label": isGithub ? "GitHub Copilot token" : "API key",
10875
+ "aria-label": updating
10876
+ ? (isGithub ? "New GitHub Copilot token" : "New API key")
10877
+ : (isGithub ? "GitHub Copilot token" : "API key"),
10321
10878
  value: credential, placeholder: "Paste token",
10322
- onChange: (event) => setCredential(event.target.value),
10879
+ onChange: (event) => { onDirty?.(); setCredential(event.target.value); },
10880
+ // While updating this is the only editable field, and the Name input
10881
+ // that carries the other Enter handler is disabled — so without this
10882
+ // the sheet cannot be submitted from the keyboard at all.
10883
+ onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
10323
10884
  })),
10324
- React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
10885
+ !updating ? React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
10325
10886
  React.createElement("input", {
10326
10887
  className: "ps-budget-input", value: baseUrl, placeholder: "https://…",
10327
10888
  "aria-label": "Base URL",
10328
10889
  onChange: (event) => setBaseUrl(event.target.value),
10329
- })),
10330
- isAdmin ? React.createElement(SheetField, { label: "Access" },
10890
+ })) : null,
10891
+ isAdmin && !updating ? React.createElement(SheetField, { label: "Access" },
10331
10892
  React.createElement(Segmented, {
10332
10893
  value: shared ? "shared" : "mine", onChange: (value) => setShared(value === "shared"),
10333
10894
  label: "Access",
@@ -10819,7 +11380,7 @@ function ModalLayer({ controller }) {
10819
11380
  }),
10820
11381
  React.createElement("span", null, "Mobile"));
10821
11382
 
10822
- const renderListModal = (presentation, confirmLabel = "Apply", footerExtras = null) => {
11383
+ const renderListModal = (presentation, confirmLabel = "Apply", footerExtras = null, headerExtras = null) => {
10823
11384
  const rows = Array.isArray(presentation.rows) ? presentation.rows : [];
10824
11385
  const rowItemIndexes = Array.isArray(presentation.rowItemIndexes) ? presentation.rowItemIndexes : null;
10825
11386
  const usesHangingIndent = modal.type === "modelPicker" || modal.type === "reasoningEffortPicker" || modal.type === "contextTierPicker" || modal.type === "sessionAgentPicker";
@@ -10840,13 +11401,8 @@ function ModalLayer({ controller }) {
10840
11401
  key: item?.id || `row:${rowIndex}`,
10841
11402
  type: "button",
10842
11403
  className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
10843
- // A section header is a disclosure control: one click opens
10844
- // or closes it. Selecting it and making the user hit the
10845
- // footer button would be a two-step for something that
10846
- // reads as a twisty.
10847
11404
  onClick: () => {
10848
11405
  controller.dispatch({ type: "ui/modalSelection", index: itemIndex });
10849
- if (item?.kind === "section") controller.toggleAgentPickerSection(item.sectionKey);
10850
11406
  },
10851
11407
  },
10852
11408
  React.createElement("div", { className: "ps-line ps-modal-list-line" },
@@ -10889,6 +11445,7 @@ function ModalLayer({ controller }) {
10889
11445
  React.createElement("div", { className: "ps-modal-title" }, presentation.title),
10890
11446
  React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
10891
11447
  ),
11448
+ headerExtras,
10892
11449
  React.createElement("div", { className: "ps-modal-grid" },
10893
11450
  React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
10894
11451
  renderedList,
@@ -10979,14 +11536,43 @@ function ModalLayer({ controller }) {
10979
11536
  return renderListModal(modalState.contextTierPicker, pickerConfirmLabel);
10980
11537
  }
10981
11538
  if (modal.type === "sessionAgentPicker" && modalState.sessionAgentPicker) {
10982
- // The primary button follows the selection: on a section it is the
10983
- // disclosure, not a create, and labelling it "Create Session" would
10984
- // promise something Enter does not do there.
10985
- const picked = modal.items?.[modal.selectedIndex || 0];
10986
- const confirmLabel = picked?.kind === "section"
10987
- ? (picked.collapsed ? "Open" : "Close")
10988
- : "Create Session";
10989
- return renderListModal(modalState.sessionAgentPicker, confirmLabel);
11539
+ // Every row is an agent now, so Enter always creates.
11540
+ const SORTS = [
11541
+ ["used", "Most used", "Ordered by how often YOU have started each agent"],
11542
+ ["name", "Name", "Alphabetical by agent name"],
11543
+ ["package", "Package", "Grouped by package name, then agent name"],
11544
+ ];
11545
+ const toolbar = React.createElement("div", { className: "ps-agent-picker-toolbar" },
11546
+ React.createElement("input", {
11547
+ type: "search",
11548
+ className: "ps-agent-picker-search",
11549
+ // The keyboard handler routes Escape/Enter/arrows from a
11550
+ // marked search box to the modal; see useKeyboardShortcuts.
11551
+ "data-modal-search": "true",
11552
+ placeholder: "Search agents, packages, tools…",
11553
+ value: modal.query || "",
11554
+ autoFocus: true,
11555
+ "aria-label": "Search agents",
11556
+ onChange: (event) => controller.setAgentPickerQuery(event.target.value),
11557
+ // The list's own keys must still work from inside the box, but
11558
+ // every OTHER key has to stay here — the modal binds j and k to
11559
+ // move the selection, and without this you cannot type "kusto".
11560
+ onKeyDown: (event) => {
11561
+ const passes = ["ArrowUp", "ArrowDown", "Enter", "Escape", "Tab"];
11562
+ if (!passes.includes(event.key)) event.stopPropagation();
11563
+ },
11564
+ }),
11565
+ React.createElement("div", { className: "ps-agent-picker-sorts", role: "group", "aria-label": "Sort agents" },
11566
+ SORTS.map(([value, label, title]) => React.createElement("button", {
11567
+ key: value,
11568
+ type: "button",
11569
+ className: `ps-agent-picker-sort${(modal.sort || "used") === value ? " is-on" : ""}`,
11570
+ title,
11571
+ "aria-pressed": (modal.sort || "used") === value ? "true" : "false",
11572
+ onClick: () => controller.setAgentPickerSort(value),
11573
+ }, label))),
11574
+ );
11575
+ return renderListModal(modalState.sessionAgentPicker, "Create Session", null, toolbar);
10990
11576
  }
10991
11577
  if (modal.type === "sessionGroupPicker" && modalState.sessionGroupPicker) {
10992
11578
  return renderListModal(modalState.sessionGroupPicker, "Move");
@@ -11021,6 +11607,7 @@ function ModalLayer({ controller }) {
11021
11607
  ),
11022
11608
  React.createElement("input", {
11023
11609
  className: "ps-modal-input ps-modal-search",
11610
+ "data-modal-search": "true",
11024
11611
  value: ownerFilterQuery,
11025
11612
  placeholder: "Search people…",
11026
11613
  autoFocus: true,
@@ -11374,26 +11961,56 @@ function useKeyboardShortcuts(controller, mobile) {
11374
11961
  controller.handleCommand(UI_COMMANDS.MODAL_PANE_NEXT).catch(() => {});
11375
11962
  return;
11376
11963
  }
11377
- // Disclosure keys for the agent picker. Scoped to that modal:
11378
- // every other list modal is flat, and left/right there would
11379
- // either do nothing or fight the Tab pane-cycling. Without
11380
- // this the picker's own hint ("Enter or → to open") named a
11381
- // key nothing listened for.
11382
- if (modal.type === "sessionAgentPicker" && (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
11964
+ if (event.key === "ArrowUp" || event.key === "k") {
11383
11965
  event.preventDefault();
11384
- controller.handleCommand(
11385
- event.key === "ArrowRight" ? UI_COMMANDS.MODAL_PANE_NEXT : UI_COMMANDS.MODAL_PANE_PREV,
11386
- ).catch(() => {});
11966
+ controller.handleCommand(UI_COMMANDS.MODAL_PREV).catch(() => {});
11387
11967
  return;
11388
11968
  }
11389
- if (event.key === "ArrowUp" || event.key === "k") {
11969
+ if (event.key === "ArrowDown" || event.key === "j") {
11970
+ event.preventDefault();
11971
+ controller.handleCommand(UI_COMMANDS.MODAL_NEXT).catch(() => {});
11972
+ }
11973
+ return;
11974
+ }
11975
+
11976
+ // An INPUT inside a list modal — the agent picker's search box, the
11977
+ // filter dialog's people search — must still answer the keys that
11978
+ // drive the modal. The block above is gated on !editable so that
11979
+ // typing "j" filters instead of moving the selection, which is
11980
+ // right; but it also swallowed Escape, Enter and the arrows, so
11981
+ // with the box focused the dialog could not be closed, confirmed
11982
+ // or navigated at all (its own hint said "Esc close" while Escape
11983
+ // did nothing). Only those four keys, and only for a box marked
11984
+ // as a modal's SEARCH field. Not every input in a modal: the
11985
+ // rename and group-name boxes confirm on Enter themselves (a
11986
+ // second confirm from here is a no-op only because the first
11987
+ // closes the modal synchronously — not a contract worth leaning
11988
+ // on), and in a text box the arrow keys mean the caret, not the
11989
+ // list. A textarea keeps Enter for newlines.
11990
+ // `editable` is a boolean, not the element — test the target.
11991
+ if (modal && editable && target instanceof HTMLElement && target.dataset.modalSearch === "true") {
11992
+ if (event.key === "Escape") {
11993
+ // An input with text clears itself first (its own handler
11994
+ // does that and stops propagation), so reaching here means
11995
+ // the box is empty and Escape means close.
11996
+ event.preventDefault();
11997
+ controller.handleCommand(UI_COMMANDS.CLOSE_MODAL).catch(() => {});
11998
+ return;
11999
+ }
12000
+ if (event.key === "Enter") {
12001
+ event.preventDefault();
12002
+ controller.handleCommand(UI_COMMANDS.MODAL_CONFIRM).catch(() => {});
12003
+ return;
12004
+ }
12005
+ if (event.key === "ArrowUp") {
11390
12006
  event.preventDefault();
11391
12007
  controller.handleCommand(UI_COMMANDS.MODAL_PREV).catch(() => {});
11392
12008
  return;
11393
12009
  }
11394
- if (event.key === "ArrowDown" || event.key === "j") {
12010
+ if (event.key === "ArrowDown") {
11395
12011
  event.preventDefault();
11396
12012
  controller.handleCommand(UI_COMMANDS.MODAL_NEXT).catch(() => {});
12013
+ return;
11397
12014
  }
11398
12015
  return;
11399
12016
  }
@@ -11703,37 +12320,58 @@ function AdminConsolePanel({ controller, mobile = false }) {
11703
12320
  setProviderSheet(null);
11704
12321
  setProviderSheetBusy(false);
11705
12322
  setProviderSheetError(null);
11706
- }, []);
12323
+ // The sheet's own error is local state and goes with it. The PANE
12324
+ // banner is not: a failed mutation writes it into the store, and
12325
+ // nothing cleared it until the next mutation succeeded — so Cancel
12326
+ // left a red band describing a change that was abandoned.
12327
+ controller.dismissAdminModelProviderMutationError();
12328
+ }, [controller]);
11707
12329
  const submitProvider = React.useCallback(async (input) => {
11708
12330
  setProviderSheetBusy(true);
11709
12331
  setProviderSheetError(null);
11710
- const outcome = await controller.createAdminProvider(input);
12332
+ const outcome = providerSheet?.update
12333
+ ? await controller.updateAdminProviderCredential({
12334
+ name: input.name, credentials: input.credentials, shared: input.shared === true,
12335
+ })
12336
+ : await controller.createAdminProvider(input);
11711
12337
  setProviderSheetBusy(false);
11712
12338
  if (!outcome?.ok) {
11713
- setProviderSheetError(outcome?.error || "The provider could not be created.");
12339
+ setProviderSheetError(outcome?.error || `The provider key could not be ${providerSheet?.update ? "updated" : "created"}.`);
11714
12340
  return;
11715
12341
  }
11716
12342
  closeProviderSheet();
11717
- }, [closeProviderSheet, controller]);
12343
+ }, [closeProviderSheet, controller, providerSheet]);
11718
12344
 
11719
12345
  const principalLabel = formatAdminPrincipalLabel(view.principal);
11720
12346
 
11721
12347
  const header = React.createElement("header", { className: "ps-admin-console__header" },
11722
- React.createElement("h2", null, "Admin Console"),
12348
+ React.createElement("h2", null, view.isAdmin ? "Admin Console" : "Settings"),
11723
12349
  React.createElement("span", { className: "ps-admin-console__who" }, principalLabel),
11724
12350
  React.createElement("button", {
11725
12351
  type: "button",
11726
12352
  className: "ps-mini-button is-icon",
11727
12353
  onClick: onClose,
11728
- title: "Close the admin console",
11729
- "aria-label": "Close the admin console",
12354
+ title: view.isAdmin ? "Close the admin console" : "Close settings",
12355
+ "aria-label": view.isAdmin ? "Close the admin console" : "Close settings",
11730
12356
  }, "\u2715"));
11731
12357
 
11732
12358
  const providerSection = React.createElement(AdminModelProvidersSection, {
11733
12359
  controller,
11734
12360
  view,
11735
- onAddPersonal: () => setProviderSheet({ shared: false, github: true }),
11736
- onAddShared: () => setProviderSheet({ shared: true, github: false }),
12361
+ // One failure should say itself once. While a sheet is open it shows
12362
+ // the message itself, and the pane printed the SAME text behind it.
12363
+ sheetOpen: Boolean(providerSheet),
12364
+ // Open clean: a banner from a previous, abandoned attempt must not
12365
+ // greet the next one.
12366
+ onAddPersonal: () => { controller.dismissAdminModelProviderMutationError(); setProviderSheet({ shared: false, github: true }); },
12367
+ onAddShared: () => { controller.dismissAdminModelProviderMutationError(); setProviderSheet({ shared: true, github: false }); },
12368
+ // Carry the scope: a shared provider rotates through the admin-only op,
12369
+ // a personal one through the owner's. Without this the sheet defaults
12370
+ // to personal and a shared rotation would 404 on its own name.
12371
+ onUpdatePersonal: (provider) => {
12372
+ controller.dismissAdminModelProviderMutationError();
12373
+ setProviderSheet({ update: provider, shared: provider.class === "shared" });
12374
+ },
11737
12375
  });
11738
12376
 
11739
12377
  const tree = React.createElement(AdminSettingsTree, { controller, view });
@@ -11753,11 +12391,17 @@ function AdminConsolePanel({ controller, mobile = false }) {
11753
12391
  ].map((provider) => provider.name),
11754
12392
  isAdmin: view.isAdmin,
11755
12393
  initialShared: providerSheet.shared,
12394
+ updateProvider: providerSheet.update || null,
11756
12395
  initialType: providerSheet.github
11757
12396
  ? (view.modelProviders?.providerTypes || []).find((type) => /github/i.test(type.id))?.id || ""
11758
- : "",
12397
+ : (providerSheet.update?.typeId || ""),
12398
+ initialName: providerSheet.update?.name || "",
11759
12399
  busy: providerSheetBusy,
11760
12400
  error: providerSheetError,
12401
+ // Editing a field is the person answering the error. Leaving the
12402
+ // old failure sitting under the input while they retype makes it
12403
+ // look like the new value is already rejected.
12404
+ onDirty: () => setProviderSheetError(null),
11761
12405
  onCancel: closeProviderSheet,
11762
12406
  onConfirm: submitProvider,
11763
12407
  })
@@ -11959,6 +12603,19 @@ function AdminPackageDetailPane({ controller, view }) {
11959
12603
  };
11960
12604
  const act = (kind, arg) => () => controller.runAdminPackageAction(kind, arg);
11961
12605
  const pending = detail.actionPending;
12606
+ // Download is a pure read that streams to the browser, so it does not go
12607
+ // through runAdminPackageAction (which mutates and refreshes the detail).
12608
+ const downloadPackage = async () => {
12609
+ try {
12610
+ await controller.transport.saveAgentPackageDownload(
12611
+ detail.name,
12612
+ detail.activeSemver ?? null,
12613
+ { scope: detail.scope, owner: detail.owner ?? null },
12614
+ );
12615
+ } catch (err) {
12616
+ window.alert(`Could not download ${detail.name}: ${err?.message || err}`);
12617
+ }
12618
+ };
11962
12619
  return React.createElement("div", { className: "ps-admin-detail" },
11963
12620
  detail.error
11964
12621
  ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" },
@@ -12003,13 +12660,18 @@ function AdminPackageDetailPane({ controller, view }) {
12003
12660
  React.createElement("span", null, version.dateText),
12004
12661
  version.active
12005
12662
  ? React.createElement("span", { className: "is-ok" }, "● active")
12006
- : (detail.canManage
12663
+ : (detail.canEdit
12007
12664
  ? React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act("pin", version.semver), disabled: Boolean(pending) }, "Pin")
12008
12665
  : null)))))
12009
12666
  : null,
12010
- detail.canManage
12667
+ // Editors live on the shared copy only. Everyone sees the list;
12668
+ // only the owner/admin changes it.
12669
+ detail.scope === "shared"
12670
+ ? React.createElement(AdminPackageEditorsSection, { controller, detail, pending })
12671
+ : null,
12672
+ detail.canEdit
12011
12673
  ? React.createElement("div", { className: "ps-admin-detail__actions" },
12012
- detail.source
12674
+ detail.source && detail.canManage
12013
12675
  ? React.createElement("button", { type: "button", className: "ps-primary-button", onClick: act("sync"), disabled: Boolean(pending) },
12014
12676
  pending === "sync" ? "Syncing…" : "Sync now")
12015
12677
  : null,
@@ -12034,8 +12696,11 @@ function AdminPackageDetailPane({ controller, view }) {
12034
12696
  disabled: Boolean(pending) || !detail.activeSemver,
12035
12697
  title: `Publish ${detail.name}@${detail.activeSemver ?? "?"} into the existing shared package`,
12036
12698
  }, pending === "republish" ? "Publishing…" : `Publish ${detail.activeSemver ?? ""} to shared`)
12037
- : React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.scope === "shared" ? "demote" : "promote"), disabled: Boolean(pending) },
12038
- detail.scope === "shared" ? "Demote to user" : "Promote to shared"),
12699
+ : (detail.canManage
12700
+ ? React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.scope === "shared" ? "demote" : "promote"), disabled: Boolean(pending),
12701
+ title: detail.scope === "shared" && detail.editors.length ? "Demoting revokes every editor grant" : undefined },
12702
+ detail.scope === "shared" ? "Demote to user" : "Promote to shared")
12703
+ : null),
12039
12704
  detail.scope === "shared" && !detail.hasOwnUserCounterpart
12040
12705
  ? React.createElement("button", {
12041
12706
  type: "button",
@@ -12045,16 +12710,126 @@ function AdminPackageDetailPane({ controller, view }) {
12045
12710
  title: "Copy the active version into your user scope as a private test bench",
12046
12711
  }, pending === "republish" ? "Copying…" : "Copy to my user scope")
12047
12712
  : null,
12713
+ // Getting a published package back OUT. The Web API has always
12714
+ // served it; without this the only way was the CLI, which is a
12715
+ // strange gap for a console that can publish, promote and
12716
+ // delete. Feature-detected: a host with nowhere to put a file
12717
+ // does not offer it.
12718
+ typeof controller.transport.saveAgentPackageDownload === "function"
12719
+ ? React.createElement("button", {
12720
+ type: "button",
12721
+ className: "ps-mini-button",
12722
+ onClick: () => downloadPackage(),
12723
+ disabled: Boolean(pending) || !detail.activeSemver,
12724
+ title: `Download ${detail.name}@${detail.activeSemver ?? "?"} as the .tgz the CLI would push`,
12725
+ }, pending === "download" ? "Downloading…" : "Download")
12726
+ : null,
12048
12727
  React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.enabled ? "disable" : "enable"), disabled: Boolean(pending) },
12049
12728
  detail.enabled ? "Disable" : "Enable"),
12050
- React.createElement("button", { type: "button", className: "ps-mini-button is-danger", onClick: confirmDelete, disabled: Boolean(pending) }, "Delete"))
12729
+ detail.canManage
12730
+ ? React.createElement("button", { type: "button", className: "ps-mini-button is-danger", onClick: confirmDelete, disabled: Boolean(pending) }, "Delete")
12731
+ : null)
12051
12732
  : React.createElement("p", { className: "ps-admin-console__hint" },
12052
- "Read-only: only the package creator or an admin can modify it."),
12733
+ "Read-only: only the package creator, a granted editor, or an admin can modify it."),
12053
12734
  detail.actionError
12054
12735
  ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, detail.actionError)
12055
12736
  : null);
12056
12737
  }
12057
12738
 
12739
+ /**
12740
+ * Editors of a SHARED package: named users who may publish, pin and
12741
+ * enable/disable it (never scope, delete, or this list). Everyone who can
12742
+ * see the package sees the list; only the owner/admin changes it. Grantees
12743
+ * are picked from the same member directory the session-share dialog uses.
12744
+ */
12745
+ function AdminPackageEditorsSection({ controller, detail, pending }) {
12746
+ const [query, setQuery] = React.useState("");
12747
+ const [directory, setDirectory] = React.useState([]);
12748
+ React.useEffect(() => {
12749
+ if (!detail.canManage || typeof controller.transport.listKnownUsers !== "function") return undefined;
12750
+ let cancelled = false;
12751
+ controller.transport.listKnownUsers({ limit: 500 })
12752
+ .then((users) => { if (!cancelled) setDirectory(Array.isArray(users) ? users : []); })
12753
+ .catch(() => {});
12754
+ return () => { cancelled = true; };
12755
+ }, [controller, detail.canManage]);
12756
+
12757
+ const editorKeys = new Set(detail.editors.map((e) => `${e.provider}${e.subject}`));
12758
+ const q = query.trim().toLowerCase();
12759
+ const suggestions = q
12760
+ ? directory.filter((u) => !editorKeys.has(`${u.provider}${u.subject}`) && (
12761
+ (u.displayName && u.displayName.toLowerCase().includes(q))
12762
+ || (u.email && u.email.toLowerCase().includes(q))
12763
+ || (u.subject && u.subject.toLowerCase().includes(q)))).slice(0, 8)
12764
+ : [];
12765
+ const grant = (user) => {
12766
+ if (!user?.provider || !user?.subject) return;
12767
+ setQuery("");
12768
+ controller.runAdminPackageAction("grantEditor", { provider: user.provider, subject: user.subject });
12769
+ };
12770
+ // Enter/Add grants only an EXACT match, or the single remaining
12771
+ // suggestion. A typo must never hand write access to whoever happens
12772
+ // to sort first in the directory — the person picks from the list.
12773
+ const grantTyped = () => {
12774
+ const text = query.trim();
12775
+ if (!text) return;
12776
+ const exact = directory.find((u) =>
12777
+ (u.displayName && u.displayName.toLowerCase() === text.toLowerCase())
12778
+ || (u.email && u.email.toLowerCase() === text.toLowerCase())
12779
+ || (u.subject && u.subject.toLowerCase() === text.toLowerCase()));
12780
+ if (exact) return grant(exact);
12781
+ if (suggestions.length === 1) return grant(suggestions[0]);
12782
+ };
12783
+
12784
+ return React.createElement("div", { className: "ps-admin-editors" },
12785
+ React.createElement("div", { className: "ps-admin-detail__label" }, "Editors"),
12786
+ detail.editors.length
12787
+ ? React.createElement("div", { className: "ps-admin-detail__chips" },
12788
+ detail.editors.map((e) => React.createElement("span", {
12789
+ key: `${e.provider}${e.subject}`,
12790
+ className: "ps-agent-chip",
12791
+ title: `${e.provider}:${e.subject}${e.grantedByDisplay ? ` · granted by ${e.grantedByDisplay}` : ""}`,
12792
+ },
12793
+ e.label,
12794
+ detail.canManage
12795
+ ? React.createElement("button", {
12796
+ type: "button",
12797
+ className: "ps-chip-remove",
12798
+ "aria-label": `Remove editor ${e.label}`,
12799
+ disabled: Boolean(pending),
12800
+ onClick: () => controller.runAdminPackageAction("revokeEditor", { provider: e.provider, subject: e.subject }),
12801
+ }, "×")
12802
+ : null)))
12803
+ : React.createElement("p", { className: "ps-admin-console__hint" },
12804
+ detail.canManage
12805
+ ? "No editors yet. Add a person to let them publish, pin and enable this package. Grants are revoked if the package is demoted."
12806
+ : "Only the owner and admins can change this package."),
12807
+ detail.canManage
12808
+ ? React.createElement("div", { className: "ps-admin-editors__add" },
12809
+ React.createElement("input", {
12810
+ type: "text",
12811
+ className: "ps-input",
12812
+ placeholder: "Add editor by name, email, or id…",
12813
+ value: query,
12814
+ disabled: Boolean(pending),
12815
+ onChange: (event) => setQuery(event.target.value),
12816
+ onKeyDown: (event) => { if (event.key === "Enter") { event.preventDefault(); grantTyped(); } },
12817
+ "aria-label": "Add editor",
12818
+ }),
12819
+ React.createElement("button", { type: "button", className: "ps-mini-button", onClick: grantTyped, disabled: Boolean(pending) || !query.trim() }, "Add"),
12820
+ suggestions.length
12821
+ ? React.createElement("div", { className: "ps-admin-editors__suggestions", role: "listbox" },
12822
+ suggestions.map((u) => React.createElement("button", {
12823
+ key: `${u.provider}${u.subject}`,
12824
+ type: "button",
12825
+ role: "option",
12826
+ className: "ps-admin-editors__suggestion",
12827
+ onClick: () => grant(u),
12828
+ }, u.displayName || u.email || u.subject, u.email && u.displayName ? React.createElement("i", null, ` ${u.email}`) : null)))
12829
+ : null)
12830
+ : null);
12831
+ }
12832
+
12058
12833
  function AdminPackageWorkspacePane({ controller, view, layout = null, onLayout = null }) {
12059
12834
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
12060
12835
  const theme = getTheme(themeId);
@@ -12374,7 +13149,7 @@ function AdminDefaultSelect({ label, hint, value, choices, disabled, emptyLabel,
12374
13149
  hint ? React.createElement("span", { className: "ps-admin-console__hint" }, hint) : null);
12375
13150
  }
12376
13151
 
12377
- function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, onDelete }) {
13152
+ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, onUpdate, onDelete }) {
12378
13153
  if (!providers.length) {
12379
13154
  return React.createElement("p", { className: "ps-admin-console__hint" },
12380
13155
  personal ? "No personal providers yet." : "No shared providers are configured.");
@@ -12385,7 +13160,9 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12385
13160
  className: "ps-admin-provider-row",
12386
13161
  },
12387
13162
  React.createElement("div", { className: "ps-admin-provider-row__identity" },
12388
- React.createElement("strong", null, provider.name),
13163
+ // Truncates when the row is tight (see the CSS), so the full name
13164
+ // has to be recoverable on hover.
13165
+ React.createElement("strong", { title: provider.name }, provider.name),
12389
13166
  React.createElement("span", { className: `ps-scope-badge is-${provider.class === "shared" ? "shared" : "user"}` }, provider.class),
12390
13167
  provider.isMyDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "my default") : null,
12391
13168
  provider.isClusterDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "cluster default") : null,
@@ -12399,6 +13176,23 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12399
13176
  onChange: (event) => onSystemUse(provider.name, event.target.checked),
12400
13177
  }),
12401
13178
  "Allow system sessions") : null,
13179
+ // Personal: the owner rotates their own key. Shared: an admin
13180
+ // rotates the cluster's. A shared provider had no way to rotate at
13181
+ // all, so an expired cluster key meant delete-and-recreate — which
13182
+ // drops the cluster-default flag, the allowance, any hold, the
13183
+ // system-use routing and the usage history, i.e. everything this
13184
+ // feature exists to preserve.
13185
+ (personal || isAdmin) ? React.createElement("button", {
13186
+ type: "button",
13187
+ className: "ps-mini-button",
13188
+ // Several providers means several identical "Update Key"
13189
+ // buttons to a screen reader. The trash below already names
13190
+ // its row.
13191
+ "aria-label": `Update key for ${provider.name}`,
13192
+ title: `Update key for ${provider.name}`,
13193
+ disabled: busy,
13194
+ onClick: () => onUpdate(provider),
13195
+ }, "Update key") : null,
12402
13196
  React.createElement(IconButton, {
12403
13197
  icon: React.createElement(TrashGlyph),
12404
13198
  label: `Delete ${provider.name}`,
@@ -12408,7 +13202,7 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12408
13202
  })))));
12409
13203
  }
12410
13204
 
12411
- function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShared }) {
13205
+ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShared, onUpdatePersonal, sheetOpen = false }) {
12412
13206
  const providers = view.modelProviders || {};
12413
13207
  const pending = Boolean(providers.mutation?.pending);
12414
13208
  const systemConfigured = adminDefaultModel(providers.systemSessionDefault);
@@ -12460,6 +13254,7 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
12460
13254
  React.createElement(AdminProviderRows, {
12461
13255
  providers: providers.myProviders || [], isAdmin: view.isAdmin, personal: true, busy: pending,
12462
13256
  onSystemUse: (name, enabled) => controller.setAdminProviderSystemUse(name, enabled),
13257
+ onUpdate: onUpdatePersonal,
12463
13258
  onDelete: deleteProvider,
12464
13259
  })),
12465
13260
  React.createElement("div", { className: "ps-admin-provider-block" },
@@ -12556,8 +13351,12 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
12556
13351
 
12557
13352
  return React.createElement("section", { className: "ps-admin-detail ps-admin-model-providers" },
12558
13353
  pageHeader,
12559
- providers.error || providers.mutation?.error
12560
- ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, providers.mutation?.error || providers.error)
13354
+ // A mutation error belongs to the sheet that raised it while that sheet
13355
+ // is up. `providers.error` is different — it is the LOAD failing, which
13356
+ // is about the pane itself, so it still shows.
13357
+ providers.error || (providers.mutation?.error && !sheetOpen)
13358
+ ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" },
13359
+ (!sheetOpen && providers.mutation?.error) || providers.error)
12561
13360
  : null,
12562
13361
  sharedPage ? sharedProvidersPage : myPage);
12563
13362
  }
@@ -12719,6 +13518,8 @@ export function PilotSwarmWebApp({ controller }) {
12719
13518
  const state = useControllerSelector(controller, (rootState) => ({
12720
13519
  themeId: rootState.ui.themeId,
12721
13520
  touchScale: Boolean(rootState.ui.touchScale),
13521
+ sessionDetailCollapsed: Boolean(rootState.ui.sessionDetailCollapsed),
13522
+ agentPickerUsage: rootState.ui.agentPickerUsage,
12722
13523
  ownerFilter: rootState.sessions.ownerFilter,
12723
13524
  pinnedIds: rootState.sessions.pinnedIds,
12724
13525
  // Without this the profile-save effect reads undefined and a reorder
@@ -12898,6 +13699,16 @@ export function PilotSwarmWebApp({ controller }) {
12898
13699
  try {
12899
13700
  const profile = await transport.getCurrentUserProfile();
12900
13701
  if (!active) return;
13702
+ // This is the same profile the Admin Console fetches, and it
13703
+ // carries isAdmin in every auth mode — including the no-auth
13704
+ // stub, where getAuthContext() reports nothing. Seed it into
13705
+ // state once, so the toolbar can name itself (Settings vs
13706
+ // Admin console) from the first poll instead of after the
13707
+ // console has been opened. The console's own refresh still
13708
+ // runs and replaces this with the fuller load.
13709
+ if (profile && typeof profile.isAdmin === "boolean" && !controller.getState().admin?.profile) {
13710
+ controller.dispatch({ type: "admin/profile/loaded", profile });
13711
+ }
12901
13712
  // Recompute the fallback defaults from CURRENT state each poll:
12902
13713
  // the principal is resolved by now (it was likely null at mount),
12903
13714
  // so the "no persisted filter" fallback becomes the correct
@@ -13036,7 +13847,7 @@ export function PilotSwarmWebApp({ controller }) {
13036
13847
  });
13037
13848
  }, 400);
13038
13849
  return undefined;
13039
- }, [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]);
13850
+ }, [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]);
13040
13851
 
13041
13852
  React.useEffect(() => {
13042
13853
  applyDocumentTheme(state.themeId);