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