pilotswarm 0.5.46 → 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 (82) hide show
  1. package/mcp/README.md +1 -0
  2. package/mcp/dist/src/tools/providers.d.ts.map +1 -1
  3. package/mcp/dist/src/tools/providers.js +15 -3
  4. package/mcp/dist/src/tools/providers.js.map +1 -1
  5. package/package.json +2 -2
  6. package/tui/src/app.js +7 -11
  7. package/tui/src/node-sdk-transport.js +16 -0
  8. package/ui/core/src/controller.js +265 -68
  9. package/ui/core/src/history.js +37 -0
  10. package/ui/core/src/reducer.js +51 -4
  11. package/ui/core/src/selectors.js +63 -15
  12. package/ui/core/src/state.js +11 -0
  13. package/ui/react/src/components.js +1 -0
  14. package/ui/react/src/web-app.js +611 -103
  15. package/web/dist/assets/{abnfDiagram-VRR7QNED-GXM5pEfT.js → abnfDiagram-VRR7QNED-VPL8m_9W.js} +1 -1
  16. package/web/dist/assets/{arc-D52-xt6J.js → arc-T5I3-c1B.js} +1 -1
  17. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Bxh49GyJ.js → architectureDiagram-ZJ3FMSHR-DEP70bg6.js} +1 -1
  18. package/web/dist/assets/{blockDiagram-677ZJIJ3-D8LSE94K.js → blockDiagram-677ZJIJ3-CvBu1MbO.js} +1 -1
  19. package/web/dist/assets/{c4Diagram-LMCZKHZV-Dcs1NVIx.js → c4Diagram-LMCZKHZV-B5qwF4gm.js} +1 -1
  20. package/web/dist/assets/channel-D5LojeMw.js +1 -0
  21. package/web/dist/assets/{chunk-2Q5K7J3B-Dy3zu7gI.js → chunk-2Q5K7J3B-zVW8dS7X.js} +1 -1
  22. package/web/dist/assets/{chunk-32BRIVSS-5v78VX3T.js → chunk-32BRIVSS-DWctxZZS.js} +1 -1
  23. package/web/dist/assets/{chunk-5VM5RSS4-DUJMCfdj.js → chunk-5VM5RSS4-Bq7_Vmf9.js} +1 -1
  24. package/web/dist/assets/{chunk-EX3LRPZG-DYyT1So9.js → chunk-EX3LRPZG-B8CFB3v-.js} +1 -1
  25. package/web/dist/assets/{chunk-JWPE2WC7-Buzzx5R2.js → chunk-JWPE2WC7-B7gLPjgZ.js} +1 -1
  26. package/web/dist/assets/{chunk-MOJQB5TN-B8fOWRD7.js → chunk-MOJQB5TN-B2x6q12r.js} +1 -1
  27. package/web/dist/assets/{chunk-RYQCIY6F-Bp04YO26.js → chunk-RYQCIY6F-XorU9Stx.js} +1 -1
  28. package/web/dist/assets/{chunk-V7JOEXUC-Gw6h8gPE.js → chunk-V7JOEXUC-CsLa6pw5.js} +1 -1
  29. package/web/dist/assets/{chunk-VR4S4FIN-ASf4dPE-.js → chunk-VR4S4FIN-BBv9mwmj.js} +1 -1
  30. package/web/dist/assets/{chunk-XXDRQBXY-C4B-MNNk.js → chunk-XXDRQBXY-Cjm4mm6l.js} +1 -1
  31. package/web/dist/assets/classDiagram-OUVF2IWQ-C5edc5Or.js +1 -0
  32. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C5edc5Or.js +1 -0
  33. package/web/dist/assets/{cose-bilkent-JH36ORCC-C0S415ey.js → cose-bilkent-JH36ORCC-Zw9ZQbVl.js} +1 -1
  34. package/web/dist/assets/{cynefin-VYW2F7L2-LWUlL1XB.js → cynefin-VYW2F7L2-BYnR9YLM.js} +1 -1
  35. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-ClnNDuqy.js → cynefinDiagram-TSTJHNR4-Cx8L0dOv.js} +1 -1
  36. package/web/dist/assets/{dagre-VKFMJZFB-CRMTDGPv.js → dagre-VKFMJZFB-7GaqVcqz.js} +1 -1
  37. package/web/dist/assets/{diagram-FQU43EPY-Cjt1nFI8.js → diagram-FQU43EPY-BWSRPJvU.js} +1 -1
  38. package/web/dist/assets/{diagram-G47NLZAW-DynrWO-8.js → diagram-G47NLZAW-BELuAE1e.js} +1 -1
  39. package/web/dist/assets/{diagram-NH7WQ7WH-CkEgxg4T.js → diagram-NH7WQ7WH-DGXhPpri.js} +1 -1
  40. package/web/dist/assets/{diagram-OA4YK3LP-DzB43wzH.js → diagram-OA4YK3LP-DV8HqN0T.js} +1 -1
  41. package/web/dist/assets/{diagram-WEI45ONY-DqxH3-sW.js → diagram-WEI45ONY-Clv1X3ZL.js} +1 -1
  42. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-5NxDrqIa.js → ebnfDiagram-CCIWWBDH-TpQx36oi.js} +1 -1
  43. package/web/dist/assets/{erDiagram-Q63AITRT-OfiKzPj4.js → erDiagram-Q63AITRT-DVVWd00c.js} +1 -1
  44. package/web/dist/assets/{flowDiagram-23GEKE2U-DsqfPM3s.js → flowDiagram-23GEKE2U-Cdz_8_Dw.js} +1 -1
  45. package/web/dist/assets/{ganttDiagram-NO4QXBWP-N48fLign.js → ganttDiagram-NO4QXBWP-zlco-o7u.js} +1 -1
  46. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-rn1cXRYh.js → gitGraphDiagram-IHSO6WYX-CfAZW1En.js} +1 -1
  47. package/web/dist/assets/index-CMtzFJre.css +1 -0
  48. package/web/dist/assets/{index-BHp6FirC.js → index-tQXCQojP.js} +5 -5
  49. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CjJ3TLmy.js → infoDiagram-FWYZ7A6U-0R-8efwI.js} +1 -1
  50. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DvCylaIQ.js → ishikawaDiagram-FXEZZL3T-lPpoXGlp.js} +1 -1
  51. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B_Ethynx.js → journeyDiagram-5HDEW3XC-D05vSVqu.js} +1 -1
  52. package/web/dist/assets/{kanban-definition-HUTT4EX6-BlQuhDqn.js → kanban-definition-HUTT4EX6-ClaDdESv.js} +1 -1
  53. package/web/dist/assets/{linear-DIwvxXrN.js → linear-CUnhHMvY.js} +1 -1
  54. package/web/dist/assets/{mermaid.core-CfAsl9Pc.js → mermaid.core-DaYS-X8Y.js} +4 -4
  55. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BxJep4HE.js → mindmap-definition-LN4V7U3C-N480VHds.js} +1 -1
  56. package/web/dist/assets/{papaparse.min-BjO79nbw.js → papaparse.min-CNTcNgd8.js} +1 -1
  57. package/web/dist/assets/{pegDiagram-2B236MQR-yceXoFi0.js → pegDiagram-2B236MQR-CVWyJ5ck.js} +1 -1
  58. package/web/dist/assets/{pieDiagram-ENE6RG2P-BNBzpT5B.js → pieDiagram-ENE6RG2P-UR9uohPf.js} +1 -1
  59. package/web/dist/assets/pilotswarm-BG6hk0oE.js +97 -0
  60. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-DCg1DTax.js → quadrantDiagram-ABIIQ3AL-D2SIOetn.js} +1 -1
  61. package/web/dist/assets/{railroadDiagram-RFXS5EU6-DQdy8xST.js → railroadDiagram-RFXS5EU6-OgN6qgOU.js} +1 -1
  62. package/web/dist/assets/{requirementDiagram-TGXJPOKE-N8PYSqS8.js → requirementDiagram-TGXJPOKE-BSUURBBz.js} +1 -1
  63. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DK9WhfTA.js → sankeyDiagram-HTMAVEWB-BBZhmSBZ.js} +1 -1
  64. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DEpALkWi.js → sequenceDiagram-DBY2YBRQ-C10IDNdW.js} +1 -1
  65. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CKOEPe9x.js → sizeCapture-X5ZJPWSS-B9787MHa.js} +1 -1
  66. package/web/dist/assets/{stateDiagram-2N3HPSRC-Dwwyy0hH.js → stateDiagram-2N3HPSRC-BmSBo7es.js} +1 -1
  67. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BqpsfoJI.js +1 -0
  68. package/web/dist/assets/{swimlanes-5IMT3BWC-hDoJpKdQ.js → swimlanes-5IMT3BWC-DOXpv_KQ.js} +2 -2
  69. package/web/dist/assets/swimlanesDiagram-G3AALYLV-k8WG_bWt.js +8 -0
  70. package/web/dist/assets/{timeline-definition-FHXFAJF6-CPXnQ7LH.js → timeline-definition-FHXFAJF6-SjhGfPdI.js} +1 -1
  71. package/web/dist/assets/{vennDiagram-L72KCM5P-CYsOwGTT.js → vennDiagram-L72KCM5P-BiujYen6.js} +1 -1
  72. package/web/dist/assets/{wardleyDiagram-EHGQE667-DKh__oEb.js → wardleyDiagram-EHGQE667-6nLXBvwc.js} +1 -1
  73. package/web/dist/assets/{xychartDiagram-FW5EYKEG-Bws7l8k9.js → xychartDiagram-FW5EYKEG-Di7ioTvW.js} +1 -1
  74. package/web/dist/index.html +3 -3
  75. package/web/runtime.js +12 -0
  76. package/web/dist/assets/channel-DE0-pG_Z.js +0 -1
  77. package/web/dist/assets/classDiagram-OUVF2IWQ-B0o6by6m.js +0 -1
  78. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B0o6by6m.js +0 -1
  79. package/web/dist/assets/index-DEymVbi5.css +0 -1
  80. package/web/dist/assets/pilotswarm-DewDyJhu.js +0 -97
  81. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-CRKYt8KU.js +0 -1
  82. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DtnCdOQI.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
 
@@ -4040,7 +4087,36 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
4040
4087
  */
4041
4088
  const SESSION_DETAIL_NONE = "—";
4042
4089
 
4043
- 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 }) {
4044
4120
  // EVERY field renders on EVERY selection, empty ones as an em dash. The box
4045
4121
  // is a fixed grid of rows, so moving through the list cannot change its
4046
4122
  // height — a box that grew and shrank would shove the list under the
@@ -4104,9 +4180,19 @@ function SessionDetailBox({ session, childCount = 0, pause = null, controller =
4104
4180
  // the "Paused now" band shows, with a way through to the provider that
4105
4181
  // caused it. `waitReason` covers every OTHER kind of wait (a scheduled
4106
4182
  // one, an agent asking to sleep), which is a different fact and says so.
4107
- const waitReason = typeof session?.waitReason === "string" && session.waitReason.trim()
4108
- ? session.waitReason.trim()
4109
- : null;
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);
4110
4196
  const pauseBlock = (!pause && !waitReason) ? null : React.createElement("div", {
4111
4197
  className: `ps-session-detail-pause${pause ? " is-paused" : ""}`,
4112
4198
  },
@@ -4128,7 +4214,60 @@ function SessionDetailBox({ session, childCount = 0, pause = null, controller =
4128
4214
  ).catch(() => {}),
4129
4215
  }, pause.provider ? `Open ${pause.provider}` : "Open providers and budgets") : null));
4130
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
+
4131
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"),
4132
4271
  // The row above ellipsizes to one line, so this is the only place the
4133
4272
  // whole name is legible. Two lines are RESERVED whether or not they are
4134
4273
  // used, so the box height still cannot move with the selection.
@@ -4435,76 +4574,226 @@ function useStableValue(value) {
4435
4574
  }
4436
4575
 
4437
4576
  /**
4438
- * 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.
4439
4579
  *
4440
4580
  * The session list has `width: max-content` rows, so it overflows sideways
4441
- * whenever a title is long. Declaring `touch-action: pan-x pan-y` told the
4442
- * browser both axes were equally on offer, and on a phone a swipe is never
4443
- * perfectly vertical — so scrolling down kept sliding the list sideways.
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.
4444
4583
  *
4445
- * The fix is to give the browser ONE axis (`touch-action: pan-y`, set in CSS,
4446
- * which keeps native vertical momentum) and drive horizontal ourselves, only
4447
- * for a gesture that is unambiguously sideways. Once a gesture commits to an
4448
- * axis it keeps it until the finger lifts: to scroll across you stop and make
4449
- * a deliberate left/right swipe, which is exactly the intent.
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.
4594
+ *
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.
4450
4605
  */
4451
4606
  const PAN_COMMIT_PX = 10; // ignore the first few px — every swipe starts noisy
4452
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
+ }
4453
4627
 
4454
- function useHorizontalPanOptIn(ref) {
4628
+ function useAxisLockedPan(ref, enabled = true) {
4455
4629
  React.useEffect(() => {
4456
4630
  const el = ref.current;
4457
- 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;
4458
4639
 
4459
4640
  let startX = 0;
4460
4641
  let startY = 0;
4461
4642
  let lastX = 0;
4462
- 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);
4463
4661
 
4464
4662
  const onTouchStart = (event) => {
4465
- if (event.touches.length !== 1) { axis = "y"; return; }
4466
- 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];
4467
4672
  startX = lastX = touch.clientX;
4468
- startY = touch.clientY;
4673
+ startY = lastY = touch.clientY;
4674
+ lastT = now();
4675
+ velocity = 0;
4469
4676
  axis = null;
4470
4677
  };
4471
4678
 
4472
4679
  const onTouchMove = (event) => {
4473
- if (axis === "y" || event.touches.length !== 1) return;
4474
- const touch = event.touches[0];
4680
+ if (axis === "done" || event.targetTouches.length !== 1) return;
4681
+ const touch = event.targetTouches[0];
4475
4682
  const dx = touch.clientX - startX;
4476
4683
  const dy = touch.clientY - startY;
4477
4684
 
4478
4685
  if (axis === null) {
4479
- if (Math.abs(dx) < PAN_COMMIT_PX && Math.abs(dy) < PAN_COMMIT_PX) return;
4480
- // Ties, and anything close to a tie, go to vertical. Vertical is
4481
- // the primary axis; sideways has to be asked for.
4482
- axis = Math.abs(dx) > Math.abs(dy) * PAN_HORIZONTAL_RATIO ? "x" : "y";
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.
4483
4716
  lastX = touch.clientX;
4484
- if (axis === "y") return;
4717
+ lastY = touch.clientY;
4718
+ lastT = now();
4485
4719
  }
4486
4720
 
4487
- // Committed horizontal: the browser is not panning this axis, so we
4488
- // do it. preventDefault stops the gesture also scrolling an
4489
- // ancestor sideways.
4490
- el.scrollLeft -= touch.clientX - lastX;
4491
- lastX = touch.clientX;
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.
4492
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;
4744
+ };
4745
+
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);
4493
4761
  };
4494
4762
 
4495
- const onTouchEnd = () => { axis = null; };
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
+ };
4496
4782
 
4497
4783
  el.addEventListener("touchstart", onTouchStart, { passive: true });
4498
4784
  el.addEventListener("touchmove", onTouchMove, { passive: false });
4499
4785
  el.addEventListener("touchend", onTouchEnd, { passive: true });
4500
- el.addEventListener("touchcancel", onTouchEnd, { passive: true });
4786
+ el.addEventListener("touchcancel", onTouchCancel, { passive: true });
4787
+ el.addEventListener("click", onClickCapture, true);
4501
4788
  return () => {
4789
+ stopFling();
4502
4790
  el.removeEventListener("touchstart", onTouchStart);
4503
4791
  el.removeEventListener("touchmove", onTouchMove);
4504
4792
  el.removeEventListener("touchend", onTouchEnd);
4505
- el.removeEventListener("touchcancel", onTouchEnd);
4793
+ el.removeEventListener("touchcancel", onTouchCancel);
4794
+ el.removeEventListener("click", onClickCapture, true);
4506
4795
  };
4507
- }, [ref]);
4796
+ }, [ref, enabled]);
4508
4797
  }
4509
4798
 
4510
4799
  function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false, showDetailBox = null }) {
@@ -4515,13 +4804,21 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4515
4804
  const isMobilePane = String(panelClassName).includes("ps-mobile-session-pane");
4516
4805
  // Vertical is the primary scroll axis; sideways takes a deliberate swipe.
4517
4806
  const sessionListRef = React.useRef(null);
4518
- useHorizontalPanOptIn(sessionListRef);
4519
4807
  // Selection reverts to plain taps wherever the primary input is a finger:
4520
4808
  // the mobile pane, and the chat-focus overlay's list on a phone. Matches
4521
4809
  // the `(pointer: fine)` guard on touch-action in the stylesheet.
4522
- 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);
4523
4816
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
4524
4817
  const theme = getTheme(themeId);
4818
+ const detailCollapsed = useControllerSelector(
4819
+ controller,
4820
+ (state) => Boolean(state.ui.sessionDetailCollapsed),
4821
+ );
4525
4822
  const sessionButtonRefs = React.useRef(new Map());
4526
4823
  const viewState = useControllerSelector(controller, (state) => ({
4527
4824
  branding: state.branding,
@@ -5318,6 +5615,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5318
5615
  // change it. The row already carries the joined pause record.
5319
5616
  pause: activeRow?.pause || null,
5320
5617
  controller,
5618
+ collapsed: detailCollapsed,
5619
+ onToggle: () => controller.dispatch({
5620
+ type: "ui/sessionDetailCollapsed",
5621
+ collapsed: !detailCollapsed,
5622
+ }),
5321
5623
  })
5322
5624
  : null),
5323
5625
  (manageOpen && activeSession && !activeSession.isGroup)
@@ -5917,7 +6219,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
5917
6219
  : value === "shared_read" ? "everyone here can view"
5918
6220
  : "everyone here can view and send"))),
5919
6221
  React.createElement("div", { className: "ps-share-section-label" }, "Special access"),
5920
- React.createElement("div", { className: "ps-share-section-sub" }, "Give specific people more than the general level. A person's grant wins over general access."),
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."),
5921
6223
  draftShares.length === 0
5922
6224
  ? React.createElement("div", { className: "ps-share-empty" }, "No individual grants — everyone has the general access above.")
5923
6225
  : draftShares.map((row) => React.createElement("div", { key: `${row.provider}/${row.subject}`, className: "ps-share-grant-row" },
@@ -7185,6 +7487,12 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
7185
7487
  * maximized / onToggleMaximized are the phone's full-screen state, owned by
7186
7488
  * MobileCanvasLayer. Passing no handler simply omits the button.
7187
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
+
7188
7496
  function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote = false, maximized = false, onToggleMaximized = null }) {
7189
7497
  const view = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
7190
7498
  const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
@@ -7193,11 +7501,33 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
7193
7501
  }, [controller]);
7194
7502
 
7195
7503
  // Belt to the selection-burst braces: a pane mounted before the burst's
7196
- // snapshot resolves (deep link straight into canvas mode) fetches it here;
7197
- // the memo makes the duplicate call free.
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.
7198
7524
  React.useEffect(() => {
7199
- if (view.sessionId) controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
7200
- }, [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]);
7201
7531
 
7202
7532
  const zenOn = useControllerSelector(controller, (s) => s?.ui?.canvasZen === true);
7203
7533
  const [shareOpen, setShareOpen] = React.useState(false);
@@ -8688,6 +9018,19 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8688
9018
  setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
8689
9019
  }, [mobile]);
8690
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";
8691
9034
  const budgetOpen = useControllerSelector(controller, (state) => Boolean(state.ui?.budgetOpen));
8692
9035
  const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
8693
9036
  const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
@@ -8790,7 +9133,7 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8790
9133
  ...(mobile ? [] : [{
8791
9134
  key: "admin",
8792
9135
  icon: React.createElement(CogGlyph),
8793
- label: adminVisible ? "Close admin console" : "Admin console",
9136
+ label: adminVisible ? `Close ${consoleName}` : (isAdminUser ? "Admin console" : "Settings"),
8794
9137
  onClick: () => controller.handleCommand(adminVisible ? UI_COMMANDS.CLOSE_ADMIN_CONSOLE : UI_COMMANDS.OPEN_ADMIN_CONSOLE).catch(() => {}),
8795
9138
  active: adminVisible,
8796
9139
  }]),
@@ -10440,9 +10783,10 @@ function EditProviderSheet({
10440
10783
  * credential is the key itself, never a pointer to one.
10441
10784
  */
10442
10785
  function CreateProviderSheet({
10443
- types, existingNames, isAdmin, busy, error, onCancel, onConfirm,
10444
- initialName = "", initialShared = false, initialType = "",
10786
+ types, existingNames, isAdmin, busy, error, onDirty, onCancel, onConfirm,
10787
+ initialName = "", initialShared = false, initialType = "", updateProvider = null,
10445
10788
  }) {
10789
+ const updating = Boolean(updateProvider);
10446
10790
  const [name, setName] = React.useState(initialName);
10447
10791
  const [type, setType] = React.useState(initialType || types[0]?.id || "");
10448
10792
  const [credential, setCredential] = React.useState("");
@@ -10455,7 +10799,7 @@ function CreateProviderSheet({
10455
10799
  const trimmed = name.trim();
10456
10800
  // A name is unique across the cluster and immutable, so a clash is
10457
10801
  // permanent. The database refuses it too; refusing here saves the trip.
10458
- const taken = existingNames.some((existing) => existing.toLowerCase() === trimmed.toLowerCase());
10802
+ const taken = !updating && existingNames.some((existing) => existing.toLowerCase() === trimmed.toLowerCase());
10459
10803
  // MAJOR 16: the database's own check constraint allows only these, and it
10460
10804
  // reports every violation with one fixed sentence — so a name refused for
10461
10805
  // its LENGTH was told to use letters and numbers, which it already did.
@@ -10481,52 +10825,70 @@ function CreateProviderSheet({
10481
10825
  };
10482
10826
 
10483
10827
  return React.createElement(BudgetSheet, {
10484
- title: isGithub ? "Add GitHub Copilot provider" : "Add model provider",
10828
+ title: updating ? `Update key for ${trimmed}` : (isGithub ? "Add GitHub Copilot provider" : "Add model provider"),
10485
10829
  subtitle: shared ? "Shared" : "Personal",
10486
- confirmLabel: "Add provider", busy, disabled: !valid, error, onCancel: cancel, onConfirm: submit,
10487
- 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
+ : "",
10488
10838
  },
10489
10839
  React.createElement(SheetField, {
10490
10840
  label: "Name",
10491
10841
  hint: taken ? "A provider with that name already exists, and names are unique across the cluster."
10492
10842
  : tooLong ? `Too long: ${trimmed.length} characters, and a name may be at most 64.`
10493
10843
  : badChars ? "Letters, numbers, dot, dash and underscore only — no colon, no spaces, no accents: a session refers to it as \"<provider>:<model>\"."
10494
- : "Permanent. Used in provider:model.",
10844
+ : updating ? "Not changing. Sessions keep referring to it as provider:model."
10845
+ : "Permanent. Used in provider:model.",
10495
10846
  },
10496
10847
  React.createElement("input", {
10497
- 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" : ""}`,
10498
10849
  "aria-label": "Provider name",
10499
10850
  value: name, placeholder: "azure-prod",
10500
- onChange: (event) => setName(event.target.value),
10851
+ disabled: updating,
10852
+ onChange: (event) => { onDirty?.(); setName(event.target.value); },
10501
10853
  onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
10502
10854
  })),
10503
10855
  React.createElement(SheetField, {
10504
10856
  label: "Type",
10505
- hint: "Backend and model catalog.",
10857
+ hint: updating ? "Not changing." : "Backend and model catalog.",
10506
10858
  },
10507
10859
  React.createElement("select", {
10508
- className: "ps-budget-input", "aria-label": "Provider type",
10509
- 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); },
10510
10862
  }, types.length === 0
10511
10863
  ? React.createElement("option", { value: "" }, "No provider types are listed on this deployment")
10512
10864
  : types.map((option) => React.createElement("option", { key: option.id, value: option.id }, option.id)))),
10513
10865
  React.createElement(SheetField, {
10514
- label: isGithub ? "GitHub Copilot token" : "API key",
10515
- 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.",
10516
10872
  },
10517
10873
  React.createElement("input", {
10518
10874
  className: "ps-budget-input", type: "password", autoComplete: "off",
10519
- "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"),
10520
10878
  value: credential, placeholder: "Paste token",
10521
- 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(); },
10522
10884
  })),
10523
- React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
10885
+ !updating ? React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
10524
10886
  React.createElement("input", {
10525
10887
  className: "ps-budget-input", value: baseUrl, placeholder: "https://…",
10526
10888
  "aria-label": "Base URL",
10527
10889
  onChange: (event) => setBaseUrl(event.target.value),
10528
- })),
10529
- isAdmin ? React.createElement(SheetField, { label: "Access" },
10890
+ })) : null,
10891
+ isAdmin && !updating ? React.createElement(SheetField, { label: "Access" },
10530
10892
  React.createElement(Segmented, {
10531
10893
  value: shared ? "shared" : "mine", onChange: (value) => setShared(value === "shared"),
10532
10894
  label: "Access",
@@ -11018,7 +11380,7 @@ function ModalLayer({ controller }) {
11018
11380
  }),
11019
11381
  React.createElement("span", null, "Mobile"));
11020
11382
 
11021
- const renderListModal = (presentation, confirmLabel = "Apply", footerExtras = null) => {
11383
+ const renderListModal = (presentation, confirmLabel = "Apply", footerExtras = null, headerExtras = null) => {
11022
11384
  const rows = Array.isArray(presentation.rows) ? presentation.rows : [];
11023
11385
  const rowItemIndexes = Array.isArray(presentation.rowItemIndexes) ? presentation.rowItemIndexes : null;
11024
11386
  const usesHangingIndent = modal.type === "modelPicker" || modal.type === "reasoningEffortPicker" || modal.type === "contextTierPicker" || modal.type === "sessionAgentPicker";
@@ -11039,13 +11401,8 @@ function ModalLayer({ controller }) {
11039
11401
  key: item?.id || `row:${rowIndex}`,
11040
11402
  type: "button",
11041
11403
  className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
11042
- // A section header is a disclosure control: one click opens
11043
- // or closes it. Selecting it and making the user hit the
11044
- // footer button would be a two-step for something that
11045
- // reads as a twisty.
11046
11404
  onClick: () => {
11047
11405
  controller.dispatch({ type: "ui/modalSelection", index: itemIndex });
11048
- if (item?.kind === "section") controller.toggleAgentPickerSection(item.sectionKey);
11049
11406
  },
11050
11407
  },
11051
11408
  React.createElement("div", { className: "ps-line ps-modal-list-line" },
@@ -11088,6 +11445,7 @@ function ModalLayer({ controller }) {
11088
11445
  React.createElement("div", { className: "ps-modal-title" }, presentation.title),
11089
11446
  React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
11090
11447
  ),
11448
+ headerExtras,
11091
11449
  React.createElement("div", { className: "ps-modal-grid" },
11092
11450
  React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
11093
11451
  renderedList,
@@ -11178,14 +11536,43 @@ function ModalLayer({ controller }) {
11178
11536
  return renderListModal(modalState.contextTierPicker, pickerConfirmLabel);
11179
11537
  }
11180
11538
  if (modal.type === "sessionAgentPicker" && modalState.sessionAgentPicker) {
11181
- // The primary button follows the selection: on a section it is the
11182
- // disclosure, not a create, and labelling it "Create Session" would
11183
- // promise something Enter does not do there.
11184
- const picked = modal.items?.[modal.selectedIndex || 0];
11185
- const confirmLabel = picked?.kind === "section"
11186
- ? (picked.collapsed ? "Open" : "Close")
11187
- : "Create Session";
11188
- return renderListModal(modalState.sessionAgentPicker, confirmLabel);
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);
11189
11576
  }
11190
11577
  if (modal.type === "sessionGroupPicker" && modalState.sessionGroupPicker) {
11191
11578
  return renderListModal(modalState.sessionGroupPicker, "Move");
@@ -11220,6 +11607,7 @@ function ModalLayer({ controller }) {
11220
11607
  ),
11221
11608
  React.createElement("input", {
11222
11609
  className: "ps-modal-input ps-modal-search",
11610
+ "data-modal-search": "true",
11223
11611
  value: ownerFilterQuery,
11224
11612
  placeholder: "Search people…",
11225
11613
  autoFocus: true,
@@ -11573,26 +11961,56 @@ function useKeyboardShortcuts(controller, mobile) {
11573
11961
  controller.handleCommand(UI_COMMANDS.MODAL_PANE_NEXT).catch(() => {});
11574
11962
  return;
11575
11963
  }
11576
- // Disclosure keys for the agent picker. Scoped to that modal:
11577
- // every other list modal is flat, and left/right there would
11578
- // either do nothing or fight the Tab pane-cycling. Without
11579
- // this the picker's own hint ("Enter or → to open") named a
11580
- // key nothing listened for.
11581
- if (modal.type === "sessionAgentPicker" && (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
11964
+ if (event.key === "ArrowUp" || event.key === "k") {
11582
11965
  event.preventDefault();
11583
- controller.handleCommand(
11584
- event.key === "ArrowRight" ? UI_COMMANDS.MODAL_PANE_NEXT : UI_COMMANDS.MODAL_PANE_PREV,
11585
- ).catch(() => {});
11966
+ controller.handleCommand(UI_COMMANDS.MODAL_PREV).catch(() => {});
11586
11967
  return;
11587
11968
  }
11588
- 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") {
11589
12006
  event.preventDefault();
11590
12007
  controller.handleCommand(UI_COMMANDS.MODAL_PREV).catch(() => {});
11591
12008
  return;
11592
12009
  }
11593
- if (event.key === "ArrowDown" || event.key === "j") {
12010
+ if (event.key === "ArrowDown") {
11594
12011
  event.preventDefault();
11595
12012
  controller.handleCommand(UI_COMMANDS.MODAL_NEXT).catch(() => {});
12013
+ return;
11596
12014
  }
11597
12015
  return;
11598
12016
  }
@@ -11902,37 +12320,58 @@ function AdminConsolePanel({ controller, mobile = false }) {
11902
12320
  setProviderSheet(null);
11903
12321
  setProviderSheetBusy(false);
11904
12322
  setProviderSheetError(null);
11905
- }, []);
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]);
11906
12329
  const submitProvider = React.useCallback(async (input) => {
11907
12330
  setProviderSheetBusy(true);
11908
12331
  setProviderSheetError(null);
11909
- 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);
11910
12337
  setProviderSheetBusy(false);
11911
12338
  if (!outcome?.ok) {
11912
- setProviderSheetError(outcome?.error || "The provider could not be created.");
12339
+ setProviderSheetError(outcome?.error || `The provider key could not be ${providerSheet?.update ? "updated" : "created"}.`);
11913
12340
  return;
11914
12341
  }
11915
12342
  closeProviderSheet();
11916
- }, [closeProviderSheet, controller]);
12343
+ }, [closeProviderSheet, controller, providerSheet]);
11917
12344
 
11918
12345
  const principalLabel = formatAdminPrincipalLabel(view.principal);
11919
12346
 
11920
12347
  const header = React.createElement("header", { className: "ps-admin-console__header" },
11921
- React.createElement("h2", null, "Admin Console"),
12348
+ React.createElement("h2", null, view.isAdmin ? "Admin Console" : "Settings"),
11922
12349
  React.createElement("span", { className: "ps-admin-console__who" }, principalLabel),
11923
12350
  React.createElement("button", {
11924
12351
  type: "button",
11925
12352
  className: "ps-mini-button is-icon",
11926
12353
  onClick: onClose,
11927
- title: "Close the admin console",
11928
- "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",
11929
12356
  }, "\u2715"));
11930
12357
 
11931
12358
  const providerSection = React.createElement(AdminModelProvidersSection, {
11932
12359
  controller,
11933
12360
  view,
11934
- onAddPersonal: () => setProviderSheet({ shared: false, github: true }),
11935
- 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
+ },
11936
12375
  });
11937
12376
 
11938
12377
  const tree = React.createElement(AdminSettingsTree, { controller, view });
@@ -11952,11 +12391,17 @@ function AdminConsolePanel({ controller, mobile = false }) {
11952
12391
  ].map((provider) => provider.name),
11953
12392
  isAdmin: view.isAdmin,
11954
12393
  initialShared: providerSheet.shared,
12394
+ updateProvider: providerSheet.update || null,
11955
12395
  initialType: providerSheet.github
11956
12396
  ? (view.modelProviders?.providerTypes || []).find((type) => /github/i.test(type.id))?.id || ""
11957
- : "",
12397
+ : (providerSheet.update?.typeId || ""),
12398
+ initialName: providerSheet.update?.name || "",
11958
12399
  busy: providerSheetBusy,
11959
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),
11960
12405
  onCancel: closeProviderSheet,
11961
12406
  onConfirm: submitProvider,
11962
12407
  })
@@ -12158,6 +12603,19 @@ function AdminPackageDetailPane({ controller, view }) {
12158
12603
  };
12159
12604
  const act = (kind, arg) => () => controller.runAdminPackageAction(kind, arg);
12160
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
+ };
12161
12619
  return React.createElement("div", { className: "ps-admin-detail" },
12162
12620
  detail.error
12163
12621
  ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" },
@@ -12252,6 +12710,20 @@ function AdminPackageDetailPane({ controller, view }) {
12252
12710
  title: "Copy the active version into your user scope as a private test bench",
12253
12711
  }, pending === "republish" ? "Copying…" : "Copy to my user scope")
12254
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,
12255
12727
  React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.enabled ? "disable" : "enable"), disabled: Boolean(pending) },
12256
12728
  detail.enabled ? "Disable" : "Enable"),
12257
12729
  detail.canManage
@@ -12677,7 +13149,7 @@ function AdminDefaultSelect({ label, hint, value, choices, disabled, emptyLabel,
12677
13149
  hint ? React.createElement("span", { className: "ps-admin-console__hint" }, hint) : null);
12678
13150
  }
12679
13151
 
12680
- function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, onDelete }) {
13152
+ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, onUpdate, onDelete }) {
12681
13153
  if (!providers.length) {
12682
13154
  return React.createElement("p", { className: "ps-admin-console__hint" },
12683
13155
  personal ? "No personal providers yet." : "No shared providers are configured.");
@@ -12688,7 +13160,9 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12688
13160
  className: "ps-admin-provider-row",
12689
13161
  },
12690
13162
  React.createElement("div", { className: "ps-admin-provider-row__identity" },
12691
- 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),
12692
13166
  React.createElement("span", { className: `ps-scope-badge is-${provider.class === "shared" ? "shared" : "user"}` }, provider.class),
12693
13167
  provider.isMyDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "my default") : null,
12694
13168
  provider.isClusterDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "cluster default") : null,
@@ -12702,6 +13176,23 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12702
13176
  onChange: (event) => onSystemUse(provider.name, event.target.checked),
12703
13177
  }),
12704
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,
12705
13196
  React.createElement(IconButton, {
12706
13197
  icon: React.createElement(TrashGlyph),
12707
13198
  label: `Delete ${provider.name}`,
@@ -12711,7 +13202,7 @@ function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, on
12711
13202
  })))));
12712
13203
  }
12713
13204
 
12714
- function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShared }) {
13205
+ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShared, onUpdatePersonal, sheetOpen = false }) {
12715
13206
  const providers = view.modelProviders || {};
12716
13207
  const pending = Boolean(providers.mutation?.pending);
12717
13208
  const systemConfigured = adminDefaultModel(providers.systemSessionDefault);
@@ -12763,6 +13254,7 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
12763
13254
  React.createElement(AdminProviderRows, {
12764
13255
  providers: providers.myProviders || [], isAdmin: view.isAdmin, personal: true, busy: pending,
12765
13256
  onSystemUse: (name, enabled) => controller.setAdminProviderSystemUse(name, enabled),
13257
+ onUpdate: onUpdatePersonal,
12766
13258
  onDelete: deleteProvider,
12767
13259
  })),
12768
13260
  React.createElement("div", { className: "ps-admin-provider-block" },
@@ -12859,8 +13351,12 @@ function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShar
12859
13351
 
12860
13352
  return React.createElement("section", { className: "ps-admin-detail ps-admin-model-providers" },
12861
13353
  pageHeader,
12862
- providers.error || providers.mutation?.error
12863
- ? 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)
12864
13360
  : null,
12865
13361
  sharedPage ? sharedProvidersPage : myPage);
12866
13362
  }
@@ -13022,6 +13518,8 @@ export function PilotSwarmWebApp({ controller }) {
13022
13518
  const state = useControllerSelector(controller, (rootState) => ({
13023
13519
  themeId: rootState.ui.themeId,
13024
13520
  touchScale: Boolean(rootState.ui.touchScale),
13521
+ sessionDetailCollapsed: Boolean(rootState.ui.sessionDetailCollapsed),
13522
+ agentPickerUsage: rootState.ui.agentPickerUsage,
13025
13523
  ownerFilter: rootState.sessions.ownerFilter,
13026
13524
  pinnedIds: rootState.sessions.pinnedIds,
13027
13525
  // Without this the profile-save effect reads undefined and a reorder
@@ -13201,6 +13699,16 @@ export function PilotSwarmWebApp({ controller }) {
13201
13699
  try {
13202
13700
  const profile = await transport.getCurrentUserProfile();
13203
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
+ }
13204
13712
  // Recompute the fallback defaults from CURRENT state each poll:
13205
13713
  // the principal is resolved by now (it was likely null at mount),
13206
13714
  // so the "no persisted filter" fallback becomes the correct
@@ -13339,7 +13847,7 @@ export function PilotSwarmWebApp({ controller }) {
13339
13847
  });
13340
13848
  }, 400);
13341
13849
  return undefined;
13342
- }, [controller, state.activeSessionId, state.activityPaneAdjust, state.canvasPrefs, state.collapsedSessionIds, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.rightPaneMode, state.sessionPaneAdjust, state.themeId, state.touchScale]);
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]);
13343
13851
 
13344
13852
  React.useEffect(() => {
13345
13853
  applyDocumentTheme(state.themeId);