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