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.
- package/mcp/README.md +1 -0
- package/mcp/dist/src/tools/providers.d.ts.map +1 -1
- package/mcp/dist/src/tools/providers.js +15 -3
- package/mcp/dist/src/tools/providers.js.map +1 -1
- package/package.json +2 -2
- package/tui/src/app.js +7 -11
- package/tui/src/node-sdk-transport.js +16 -0
- package/ui/core/src/controller.js +265 -68
- package/ui/core/src/history.js +37 -0
- package/ui/core/src/reducer.js +51 -4
- package/ui/core/src/selectors.js +63 -15
- package/ui/core/src/state.js +11 -0
- package/ui/react/src/components.js +1 -0
- package/ui/react/src/web-app.js +611 -103
- package/web/dist/assets/{abnfDiagram-VRR7QNED-GXM5pEfT.js → abnfDiagram-VRR7QNED-VPL8m_9W.js} +1 -1
- package/web/dist/assets/{arc-D52-xt6J.js → arc-T5I3-c1B.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Bxh49GyJ.js → architectureDiagram-ZJ3FMSHR-DEP70bg6.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-D8LSE94K.js → blockDiagram-677ZJIJ3-CvBu1MbO.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-Dcs1NVIx.js → c4Diagram-LMCZKHZV-B5qwF4gm.js} +1 -1
- package/web/dist/assets/channel-D5LojeMw.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-Dy3zu7gI.js → chunk-2Q5K7J3B-zVW8dS7X.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-5v78VX3T.js → chunk-32BRIVSS-DWctxZZS.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-DUJMCfdj.js → chunk-5VM5RSS4-Bq7_Vmf9.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-DYyT1So9.js → chunk-EX3LRPZG-B8CFB3v-.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-Buzzx5R2.js → chunk-JWPE2WC7-B7gLPjgZ.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-B8fOWRD7.js → chunk-MOJQB5TN-B2x6q12r.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-Bp04YO26.js → chunk-RYQCIY6F-XorU9Stx.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-Gw6h8gPE.js → chunk-V7JOEXUC-CsLa6pw5.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-ASf4dPE-.js → chunk-VR4S4FIN-BBv9mwmj.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-C4B-MNNk.js → chunk-XXDRQBXY-Cjm4mm6l.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-C5edc5Or.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-C5edc5Or.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-C0S415ey.js → cose-bilkent-JH36ORCC-Zw9ZQbVl.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-LWUlL1XB.js → cynefin-VYW2F7L2-BYnR9YLM.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-ClnNDuqy.js → cynefinDiagram-TSTJHNR4-Cx8L0dOv.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-CRMTDGPv.js → dagre-VKFMJZFB-7GaqVcqz.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-Cjt1nFI8.js → diagram-FQU43EPY-BWSRPJvU.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-DynrWO-8.js → diagram-G47NLZAW-BELuAE1e.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-CkEgxg4T.js → diagram-NH7WQ7WH-DGXhPpri.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DzB43wzH.js → diagram-OA4YK3LP-DV8HqN0T.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-DqxH3-sW.js → diagram-WEI45ONY-Clv1X3ZL.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-5NxDrqIa.js → ebnfDiagram-CCIWWBDH-TpQx36oi.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-OfiKzPj4.js → erDiagram-Q63AITRT-DVVWd00c.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-DsqfPM3s.js → flowDiagram-23GEKE2U-Cdz_8_Dw.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-N48fLign.js → ganttDiagram-NO4QXBWP-zlco-o7u.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-rn1cXRYh.js → gitGraphDiagram-IHSO6WYX-CfAZW1En.js} +1 -1
- package/web/dist/assets/index-CMtzFJre.css +1 -0
- package/web/dist/assets/{index-BHp6FirC.js → index-tQXCQojP.js} +5 -5
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-CjJ3TLmy.js → infoDiagram-FWYZ7A6U-0R-8efwI.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DvCylaIQ.js → ishikawaDiagram-FXEZZL3T-lPpoXGlp.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-B_Ethynx.js → journeyDiagram-5HDEW3XC-D05vSVqu.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-BlQuhDqn.js → kanban-definition-HUTT4EX6-ClaDdESv.js} +1 -1
- package/web/dist/assets/{linear-DIwvxXrN.js → linear-CUnhHMvY.js} +1 -1
- package/web/dist/assets/{mermaid.core-CfAsl9Pc.js → mermaid.core-DaYS-X8Y.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-BxJep4HE.js → mindmap-definition-LN4V7U3C-N480VHds.js} +1 -1
- package/web/dist/assets/{papaparse.min-BjO79nbw.js → papaparse.min-CNTcNgd8.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-yceXoFi0.js → pegDiagram-2B236MQR-CVWyJ5ck.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-BNBzpT5B.js → pieDiagram-ENE6RG2P-UR9uohPf.js} +1 -1
- package/web/dist/assets/pilotswarm-BG6hk0oE.js +97 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-DCg1DTax.js → quadrantDiagram-ABIIQ3AL-D2SIOetn.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-DQdy8xST.js → railroadDiagram-RFXS5EU6-OgN6qgOU.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-N8PYSqS8.js → requirementDiagram-TGXJPOKE-BSUURBBz.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DK9WhfTA.js → sankeyDiagram-HTMAVEWB-BBZhmSBZ.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DEpALkWi.js → sequenceDiagram-DBY2YBRQ-C10IDNdW.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-CKOEPe9x.js → sizeCapture-X5ZJPWSS-B9787MHa.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-Dwwyy0hH.js → stateDiagram-2N3HPSRC-BmSBo7es.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BqpsfoJI.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-hDoJpKdQ.js → swimlanes-5IMT3BWC-DOXpv_KQ.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-k8WG_bWt.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-CPXnQ7LH.js → timeline-definition-FHXFAJF6-SjhGfPdI.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-CYsOwGTT.js → vennDiagram-L72KCM5P-BiujYen6.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-DKh__oEb.js → wardleyDiagram-EHGQE667-6nLXBvwc.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-Bws7l8k9.js → xychartDiagram-FW5EYKEG-Di7ioTvW.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/runtime.js +12 -0
- package/web/dist/assets/channel-DE0-pG_Z.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-B0o6by6m.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-B0o6by6m.js +0 -1
- package/web/dist/assets/index-DEymVbi5.css +0 -1
- package/web/dist/assets/pilotswarm-DewDyJhu.js +0 -97
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-CRKYt8KU.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-DtnCdOQI.js +0 -8
package/ui/react/src/web-app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
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
|
-
*
|
|
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.
|
|
4442
|
-
*
|
|
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
|
-
*
|
|
4446
|
-
*
|
|
4447
|
-
*
|
|
4448
|
-
*
|
|
4449
|
-
*
|
|
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
|
|
4628
|
+
function useAxisLockedPan(ref, enabled = true) {
|
|
4455
4629
|
React.useEffect(() => {
|
|
4456
4630
|
const el = ref.current;
|
|
4457
|
-
|
|
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
|
|
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
|
-
|
|
4466
|
-
|
|
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 === "
|
|
4474
|
-
const touch = event.
|
|
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
|
-
|
|
4480
|
-
//
|
|
4481
|
-
// the
|
|
4482
|
-
|
|
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
|
-
|
|
4717
|
+
lastY = touch.clientY;
|
|
4718
|
+
lastT = now();
|
|
4485
4719
|
}
|
|
4486
4720
|
|
|
4487
|
-
//
|
|
4488
|
-
//
|
|
4489
|
-
//
|
|
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 = () => {
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
7200
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
: "
|
|
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
|
-
|
|
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:
|
|
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:
|
|
10515
|
-
|
|
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":
|
|
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
|
-
//
|
|
11182
|
-
|
|
11183
|
-
|
|
11184
|
-
|
|
11185
|
-
|
|
11186
|
-
|
|
11187
|
-
|
|
11188
|
-
|
|
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
|
-
|
|
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 === "
|
|
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"
|
|
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 =
|
|
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 ||
|
|
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
|
-
|
|
11935
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12863
|
-
|
|
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);
|