pilotswarm 0.5.38 → 0.5.40
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/dist/src/tools/agent-packages.d.ts.map +1 -1
- package/mcp/dist/src/tools/agent-packages.js +34 -14
- package/mcp/dist/src/tools/agent-packages.js.map +1 -1
- package/package.json +2 -2
- package/tui/src/node-sdk-transport.js +138 -18
- package/ui/core/src/controller.js +133 -37
- package/ui/core/src/history.js +5 -1
- package/ui/core/src/index.js +1 -0
- package/ui/core/src/layout.js +10 -0
- package/ui/core/src/portal-links.js +46 -0
- package/ui/core/src/reducer.js +140 -7
- package/ui/core/src/selectors.js +51 -7
- package/ui/react/src/web-app.js +445 -53
- package/web/README.md +28 -0
- package/web/api/canvas-plane.js +116 -0
- package/web/api/ws.js +120 -5
- package/web/config.js +60 -0
- package/web/dist/assets/{abnfDiagram-VRR7QNED-DrJhXhSQ.js → abnfDiagram-VRR7QNED-C6FaYMB7.js} +1 -1
- package/web/dist/assets/{arc-BEaG9DWd.js → arc-iZBUHcPs.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-D9fc1Imx.js → architectureDiagram-ZJ3FMSHR-D4-wnpAG.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-rS5Eikvt.js → blockDiagram-677ZJIJ3-Dz-s103f.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-HKLlX5cR.js → c4Diagram-LMCZKHZV-DE7sGdHE.js} +1 -1
- package/web/dist/assets/channel-Bk68hLPV.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-kq0TyLSB.js → chunk-2Q5K7J3B-DSFs_jRM.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-O-LMarT5.js → chunk-32BRIVSS-8sBVtZ4f.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-Di4h0CAu.js → chunk-5VM5RSS4-CbIX7k0G.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-C8rgqUOt.js → chunk-EX3LRPZG-ZfmPtWF8.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-BSH41q4T.js → chunk-JWPE2WC7-DifZrqsQ.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-BAFuQHAC.js → chunk-MOJQB5TN-CEvg_lZT.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-DAg6JOs7.js → chunk-RYQCIY6F-DmYOwk6-.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-DbVrT8BG.js → chunk-V7JOEXUC-DOxG9Vxu.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-C6f5_Grp.js → chunk-VR4S4FIN-ic-KB2tF.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-CqcCYpqA.js → chunk-XXDRQBXY-BqeYnem3.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-arTJL-2y.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-arTJL-2y.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-DJw04eFh.js → cose-bilkent-JH36ORCC-uaIgs2-N.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CpjwdEIU.js → cynefin-VYW2F7L2-DzFeugVQ.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-RqEIWoT0.js → cynefinDiagram-TSTJHNR4-NrAChuCW.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-C1eGQNK2.js → dagre-VKFMJZFB-Djf9LD2t.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-BODLQ7dz.js → diagram-FQU43EPY-BpIP2iD9.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-DlhmukMC.js → diagram-G47NLZAW-D_DCw8Oy.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-Ba_jZoCN.js → diagram-NH7WQ7WH-BztvRvrM.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DnSiAJBG.js → diagram-OA4YK3LP-BGIwSnbM.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-B3KoQ7_W.js → diagram-WEI45ONY-Czj2ttCt.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-ChnFc5gf.js → ebnfDiagram-CCIWWBDH-0nqAhMvJ.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-D2KkJXLZ.js → erDiagram-Q63AITRT-uLoGb9TG.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-CmybZufj.js → flowDiagram-23GEKE2U-Bgxy7CWO.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-BzPurW_v.js → ganttDiagram-NO4QXBWP-5q8zYzbu.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-DAbXl4gO.js → gitGraphDiagram-IHSO6WYX-D6-i59JZ.js} +1 -1
- package/web/dist/assets/index-B3WlYEvi.css +1 -0
- package/web/dist/assets/index-BFa_e8zL.js +24 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-C6qGjNA1.js → infoDiagram-FWYZ7A6U-bdx9GrWW.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-D2d1tO-9.js → ishikawaDiagram-FXEZZL3T-xzL6Bssj.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-fP13w1b7.js → journeyDiagram-5HDEW3XC-C1-18xOk.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-BJfWAXZ0.js → kanban-definition-HUTT4EX6-BS4KDL4_.js} +1 -1
- package/web/dist/assets/{linear-CltApkHf.js → linear-Dmv3ton3.js} +1 -1
- package/web/dist/assets/{mermaid.core-B1Dwetk3.js → mermaid.core-Nhzw7Fl2.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-CdRUD7LF.js → mindmap-definition-LN4V7U3C-2DSMdlsp.js} +1 -1
- package/web/dist/assets/{papaparse.min-C1y3wBH1.js → papaparse.min-H4g6q6aH.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-CeQkqN6Q.js → pegDiagram-2B236MQR-CM_rgJ2s.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-Dqgc87jA.js → pieDiagram-ENE6RG2P-Cbo8FtuT.js} +1 -1
- package/web/dist/assets/pilotswarm-ClLipXts.js +97 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CD7f-w6m.js → quadrantDiagram-ABIIQ3AL-CVsF_pZA.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-CcsAijSC.js → railroadDiagram-RFXS5EU6-CauKYIfy.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-BkzF80pL.js → requirementDiagram-TGXJPOKE-DoXh8EL8.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-D9XOFw7Y.js → sankeyDiagram-HTMAVEWB-gA_ej2MB.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js → sequenceDiagram-DBY2YBRQ-keYBa83Q.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-CDJV16tP.js → sizeCapture-X5ZJPWSS-Y6NCdqw8.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-C0E-MPXO.js → stateDiagram-2N3HPSRC-MvJfZXqW.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-xTZh36yB.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-n5Vsv7n9.js → swimlanes-5IMT3BWC-D41i7zcr.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-BjlBuuiD.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-DW2gV8-n.js → timeline-definition-FHXFAJF6-CUA2ENV8.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-D9dbDm08.js → vennDiagram-L72KCM5P-CBTp-2d6.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-CIcG7fiC.js → wardleyDiagram-EHGQE667-BIFjtpCg.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-elEIFsc9.js → xychartDiagram-FW5EYKEG-DiamOwOI.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/runtime.js +101 -7
- package/web/server.js +51 -2
- package/web/dist/assets/channel-DRy7FvyF.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +0 -1
- package/web/dist/assets/index-CRY94nFp.js +0 -24
- package/web/dist/assets/index-DtL0lbis.css +0 -1
- package/web/dist/assets/pilotswarm-DayYdi39.js +0 -96
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +0 -8
|
@@ -51,6 +51,22 @@ import {
|
|
|
51
51
|
import { findArtifactEntry } from "./state.js";
|
|
52
52
|
import { getTheme, listThemes } from "./themes/index.js";
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The copy selector for a package LIST row: which same-named copy it is.
|
|
56
|
+
* Scope shadowing means one name can be a shared package AND a user copy at
|
|
57
|
+
* once; the row's scope (+ owner, for admins looking at someone else's user
|
|
58
|
+
* copy) is what disambiguates every read and action on it.
|
|
59
|
+
*/
|
|
60
|
+
export function packageRowSelector(row) {
|
|
61
|
+
if (!row) return null;
|
|
62
|
+
return {
|
|
63
|
+
...(row.scope === "shared" || row.scope === "user" ? { scope: row.scope } : {}),
|
|
64
|
+
...(row.scope === "user" && row.owner?.provider && row.owner?.subject
|
|
65
|
+
? { owner: { provider: row.owner.provider, subject: row.owner.subject } }
|
|
66
|
+
: {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
const ORCHESTRATION_STATS_REFRESH_MS = 20_000;
|
|
55
71
|
const SESSION_STATS_REFRESH_MS = 20_000;
|
|
56
72
|
const FLEET_STATS_REFRESH_MS = 30_000;
|
|
@@ -3062,27 +3078,39 @@ export class PilotSwarmUiController {
|
|
|
3062
3078
|
}
|
|
3063
3079
|
}
|
|
3064
3080
|
|
|
3065
|
-
/**
|
|
3066
|
-
|
|
3067
|
-
|
|
3081
|
+
/**
|
|
3082
|
+
* Select a package: loads its detail and workspace tree.
|
|
3083
|
+
*
|
|
3084
|
+
* `selector` = { scope, owner? } from the clicked ROW. One name can be
|
|
3085
|
+
* two packages at once (scope shadowing: a shared copy plus a user
|
|
3086
|
+
* copy), and without the selector every read here resolved "own copy
|
|
3087
|
+
* first" — both rows showed the same history, and actions could land on
|
|
3088
|
+
* the copy the user was not looking at.
|
|
3089
|
+
*/
|
|
3090
|
+
async selectAdminPackage(name, selector = null) {
|
|
3091
|
+
this.dispatch({ type: "admin/packages/select", name, selector });
|
|
3068
3092
|
if (!name) return;
|
|
3093
|
+
// Selection token minted by the reducer just now; every response from
|
|
3094
|
+
// this selection carries it so a slow response for another COPY of
|
|
3095
|
+
// the same name (scope shadowing) can never land on this one.
|
|
3096
|
+
const seq = this.getState().admin?.packages?.selectionSeq ?? 0;
|
|
3069
3097
|
await Promise.all([
|
|
3070
3098
|
(async () => {
|
|
3071
3099
|
try {
|
|
3072
|
-
const detail = await this.transport.getAgentPackage(name);
|
|
3100
|
+
const detail = await this.transport.getAgentPackage(name, selector);
|
|
3073
3101
|
if (!detail) throw new Error(`package "${name}" not found`);
|
|
3074
|
-
this.dispatch({ type: "admin/packages/detail/loaded", name, detail });
|
|
3102
|
+
this.dispatch({ type: "admin/packages/detail/loaded", name, seq, detail });
|
|
3075
3103
|
} catch (error) {
|
|
3076
|
-
this.dispatch({ type: "admin/packages/detail/loadFailed", name, error: error?.message || String(error) });
|
|
3104
|
+
this.dispatch({ type: "admin/packages/detail/loadFailed", name, seq, error: error?.message || String(error) });
|
|
3077
3105
|
}
|
|
3078
3106
|
})(),
|
|
3079
3107
|
(async () => {
|
|
3080
3108
|
try {
|
|
3081
|
-
const tree = await this.transport.getAgentPackageTree(name);
|
|
3109
|
+
const tree = await this.transport.getAgentPackageTree(name, null, selector);
|
|
3082
3110
|
// A slower tree for a PREVIOUS selection must not clobber
|
|
3083
3111
|
// the current package's workspace (stale-response race).
|
|
3084
|
-
if (this.getState().admin?.packages?.
|
|
3085
|
-
this.dispatch({ type: "admin/packages/tree/loaded", name, tree });
|
|
3112
|
+
if ((this.getState().admin?.packages?.selectionSeq ?? 0) !== seq) return;
|
|
3113
|
+
this.dispatch({ type: "admin/packages/tree/loaded", name, seq, tree });
|
|
3086
3114
|
// The package's own CHANGELOG, when it ships one. Loaded
|
|
3087
3115
|
// alongside the tree rather than on demand because it is
|
|
3088
3116
|
// the first thing a reviewer wants: what changed, why, and
|
|
@@ -3091,11 +3119,12 @@ export class PilotSwarmUiController {
|
|
|
3091
3119
|
if (hasChangelog && typeof this.transport.getAgentPackageFile === "function") {
|
|
3092
3120
|
void (async () => {
|
|
3093
3121
|
try {
|
|
3094
|
-
const file = await this.transport.getAgentPackageFile(name, null, "CHANGELOG.md");
|
|
3095
|
-
if (this.getState().admin?.packages?.
|
|
3122
|
+
const file = await this.transport.getAgentPackageFile(name, null, "CHANGELOG.md", selector);
|
|
3123
|
+
if ((this.getState().admin?.packages?.selectionSeq ?? 0) !== seq) return;
|
|
3096
3124
|
this.dispatch({
|
|
3097
3125
|
type: "admin/packages/changelog/loaded",
|
|
3098
3126
|
name,
|
|
3127
|
+
seq,
|
|
3099
3128
|
content: typeof file === "string" ? file : (file?.content ?? file?.text ?? ""),
|
|
3100
3129
|
});
|
|
3101
3130
|
} catch {
|
|
@@ -3104,13 +3133,13 @@ export class PilotSwarmUiController {
|
|
|
3104
3133
|
}
|
|
3105
3134
|
})();
|
|
3106
3135
|
} else {
|
|
3107
|
-
this.dispatch({ type: "admin/packages/changelog/loaded", name, content: null });
|
|
3136
|
+
this.dispatch({ type: "admin/packages/changelog/loaded", name, seq, content: null });
|
|
3108
3137
|
}
|
|
3109
3138
|
// Default preview: plugin.json (always present in a valid package).
|
|
3110
3139
|
const first = tree?.files?.find((f) => f.path === "plugin.json") ?? tree?.files?.[0];
|
|
3111
3140
|
if (first) void this.selectAdminPackageFile(first.path);
|
|
3112
3141
|
} catch (error) {
|
|
3113
|
-
this.dispatch({ type: "admin/packages/tree/loadFailed", name, error: error?.message || String(error) });
|
|
3142
|
+
this.dispatch({ type: "admin/packages/tree/loadFailed", name, seq, error: error?.message || String(error) });
|
|
3114
3143
|
}
|
|
3115
3144
|
})(),
|
|
3116
3145
|
]);
|
|
@@ -3121,14 +3150,22 @@ export class PilotSwarmUiController {
|
|
|
3121
3150
|
const pkgs = this.getState().admin?.packages;
|
|
3122
3151
|
if (!pkgs?.list?.length) return;
|
|
3123
3152
|
// Same shared→user projection the settings tree renders, so j/k walks
|
|
3124
|
-
// the list in VISUAL order regardless of transport ordering.
|
|
3125
|
-
|
|
3153
|
+
// the list in VISUAL order regardless of transport ordering. Rows are
|
|
3154
|
+
// COPIES, not names: a shadowed name appears once per scope.
|
|
3155
|
+
const rows = [
|
|
3126
3156
|
...pkgs.list.filter((p) => p.scope === "shared"),
|
|
3127
3157
|
...pkgs.list.filter((p) => p.scope !== "shared"),
|
|
3128
|
-
]
|
|
3129
|
-
const
|
|
3130
|
-
const
|
|
3131
|
-
|
|
3158
|
+
];
|
|
3159
|
+
const keyOf = (row) => `${row.name}\u0001${row.scope ?? ""}\u0001${row.owner?.subject ?? ""}`;
|
|
3160
|
+
const selectedKey = pkgs.selectedName
|
|
3161
|
+
? `${pkgs.selectedName}\u0001${pkgs.selectedSelector?.scope ?? ""}\u0001${pkgs.selectedSelector?.owner?.subject ?? ""}`
|
|
3162
|
+
: null;
|
|
3163
|
+
let index = rows.findIndex((row) => keyOf(row) === selectedKey);
|
|
3164
|
+
if (index < 0) index = rows.findIndex((row) => row.name === pkgs.selectedName);
|
|
3165
|
+
const next = rows[Math.min(rows.length - 1, Math.max(0, (index < 0 ? (delta > 0 ? -1 : 0) : index) + delta))];
|
|
3166
|
+
if (next && (next.name !== pkgs.selectedName || keyOf(next) !== selectedKey)) {
|
|
3167
|
+
await this.selectAdminPackage(next.name, packageRowSelector(next));
|
|
3168
|
+
}
|
|
3132
3169
|
}
|
|
3133
3170
|
|
|
3134
3171
|
toggleAdminPackageDir(dir) {
|
|
@@ -3139,12 +3176,17 @@ export class PilotSwarmUiController {
|
|
|
3139
3176
|
const pkgs = this.getState().admin?.packages;
|
|
3140
3177
|
const name = pkgs?.selectedName;
|
|
3141
3178
|
if (!name || !filePath) return;
|
|
3179
|
+
// Tag the preview with the current selection token so a slow file for
|
|
3180
|
+
// one copy can't render under another copy of the same name (their
|
|
3181
|
+
// file paths are identical — both ship plugin.json).
|
|
3182
|
+
const seq = pkgs?.selectionSeq ?? 0;
|
|
3183
|
+
const selector = pkgs?.selectedSelector ?? null;
|
|
3142
3184
|
this.dispatch({ type: "admin/packages/file/loading", path: filePath });
|
|
3143
3185
|
try {
|
|
3144
|
-
const file = await this.transport.getAgentPackageFile(name, null, filePath);
|
|
3145
|
-
this.dispatch({ type: "admin/packages/file/loaded", file });
|
|
3186
|
+
const file = await this.transport.getAgentPackageFile(name, null, filePath, selector);
|
|
3187
|
+
this.dispatch({ type: "admin/packages/file/loaded", seq, file });
|
|
3146
3188
|
} catch (error) {
|
|
3147
|
-
this.dispatch({ type: "admin/packages/file/loadFailed", path: filePath, error: error?.message || String(error) });
|
|
3189
|
+
this.dispatch({ type: "admin/packages/file/loadFailed", seq, path: filePath, error: error?.message || String(error) });
|
|
3148
3190
|
}
|
|
3149
3191
|
}
|
|
3150
3192
|
|
|
@@ -3157,32 +3199,53 @@ export class PilotSwarmUiController {
|
|
|
3157
3199
|
async runAdminPackageAction(kind, arg = null) {
|
|
3158
3200
|
const pkgs = this.getState().admin?.packages;
|
|
3159
3201
|
const name = pkgs?.selectedName;
|
|
3202
|
+
const selector = pkgs?.selectedSelector ?? null;
|
|
3160
3203
|
if (!name) return;
|
|
3161
3204
|
this.dispatch({ type: "admin/packages/action/pending", action: kind });
|
|
3162
3205
|
try {
|
|
3163
3206
|
switch (kind) {
|
|
3164
3207
|
case "promote":
|
|
3165
|
-
await this.transport.setAgentPackageScope(name, "shared");
|
|
3208
|
+
await this.transport.setAgentPackageScope(name, "shared", selector);
|
|
3166
3209
|
break;
|
|
3167
3210
|
case "demote":
|
|
3168
|
-
await this.transport.setAgentPackageScope(name, "user");
|
|
3211
|
+
await this.transport.setAgentPackageScope(name, "user", selector);
|
|
3212
|
+
break;
|
|
3213
|
+
// Publish the selected copy's version (arg, default active)
|
|
3214
|
+
// into the same-named package in the OTHER scope. THE update
|
|
3215
|
+
// path when the shared name already exists — promote can only
|
|
3216
|
+
// move a row to an unused name.
|
|
3217
|
+
case "republish":
|
|
3218
|
+
await this.transport.republishAgentPackageVersion(
|
|
3219
|
+
name, arg, selector?.scope === "shared" ? "user" : "shared",
|
|
3220
|
+
);
|
|
3169
3221
|
break;
|
|
3170
3222
|
case "pin":
|
|
3171
|
-
await this.transport.pinAgentPackageVersion(name, arg);
|
|
3223
|
+
await this.transport.pinAgentPackageVersion(name, arg, selector);
|
|
3172
3224
|
break;
|
|
3173
3225
|
case "enable":
|
|
3174
3226
|
case "disable":
|
|
3175
|
-
await this.transport.setAgentPackageEnabled(name, kind === "enable");
|
|
3227
|
+
await this.transport.setAgentPackageEnabled(name, kind === "enable", selector);
|
|
3176
3228
|
break;
|
|
3177
3229
|
case "delete":
|
|
3178
|
-
await this.transport.deleteAgentPackage(name);
|
|
3230
|
+
await this.transport.deleteAgentPackage(name, selector);
|
|
3179
3231
|
break;
|
|
3180
3232
|
default:
|
|
3181
3233
|
throw new Error(`unknown package action: ${kind}`);
|
|
3182
3234
|
}
|
|
3183
3235
|
this.dispatch({ type: "admin/packages/action/done" });
|
|
3184
3236
|
await this.refreshAdminAgentPackages();
|
|
3185
|
-
|
|
3237
|
+
// Re-select the copy that still exists. promote/demote MOVE the row
|
|
3238
|
+
// to the other scope, so the pre-action selector now points at a
|
|
3239
|
+
// copy that is gone — re-selecting it would error the pane with
|
|
3240
|
+
// "package not found". Every other action leaves the copy in place.
|
|
3241
|
+
if (kind !== "delete") {
|
|
3242
|
+
const nextSelector = kind === "promote"
|
|
3243
|
+
? { scope: "shared", ...(selector?.owner ? { owner: selector.owner } : {}) }
|
|
3244
|
+
: kind === "demote"
|
|
3245
|
+
? { scope: "user", ...(selector?.owner ? { owner: selector.owner } : {}) }
|
|
3246
|
+
: selector;
|
|
3247
|
+
await this.selectAdminPackage(name, nextSelector);
|
|
3248
|
+
}
|
|
3186
3249
|
} catch (error) {
|
|
3187
3250
|
this.dispatch({ type: "admin/packages/action/failed", error: error?.message || String(error) });
|
|
3188
3251
|
}
|
|
@@ -4436,8 +4499,41 @@ export class PilotSwarmUiController {
|
|
|
4436
4499
|
return true;
|
|
4437
4500
|
}
|
|
4438
4501
|
|
|
4502
|
+
applyCanvasDataEvent(sessionId, event) {
|
|
4503
|
+
this.dispatch({
|
|
4504
|
+
type: "canvas/data",
|
|
4505
|
+
sessionId,
|
|
4506
|
+
slot: Number(event?.data?.slot) || 1,
|
|
4507
|
+
dataRev: Number(event?.data?.dataRev),
|
|
4508
|
+
payload: event?.data?.payload,
|
|
4509
|
+
// Plane-fed ticks (the canvas data plane's live mirror) carry
|
|
4510
|
+
// their own seq lineage and the patch that produced them — the
|
|
4511
|
+
// reducer orders plane ticks by planeSeq and hands the patch to
|
|
4512
|
+
// pages that opt into targeted updates.
|
|
4513
|
+
...(Number.isFinite(Number(event?.data?.planeSeq)) ? { planeSeq: Number(event.data.planeSeq) } : {}),
|
|
4514
|
+
...(event?.data?.patch && typeof event.data.patch === "object" ? { patch: event.data.patch } : {}),
|
|
4515
|
+
note: typeof event?.data?.note === "string" ? event.data.note : "",
|
|
4516
|
+
});
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4439
4519
|
mergeSessionEvent(sessionId, event) {
|
|
4440
4520
|
if (!sessionId || !event) return false;
|
|
4521
|
+
// Plane-synthesized canvas events are TRANSIENT: they update canvas
|
|
4522
|
+
// state and nothing else. They carry no seq — letting them into
|
|
4523
|
+
// history would corrupt the replay cursor — and they must never
|
|
4524
|
+
// appear in the transcript.
|
|
4525
|
+
if (event.transient === true) {
|
|
4526
|
+
if (event.eventType === "session.canvas_data") {
|
|
4527
|
+
this.applyCanvasDataEvent(sessionId, event);
|
|
4528
|
+
}
|
|
4529
|
+
if (event.eventType === "session.canvas_plane_released") {
|
|
4530
|
+
// The plane died for this session (server error/rollback).
|
|
4531
|
+
// Clear the reducer's takeover so legacy durable ticks — the
|
|
4532
|
+
// fallback path — apply again instead of being dropped.
|
|
4533
|
+
this.dispatch({ type: "canvas/planeReleased", sessionId });
|
|
4534
|
+
}
|
|
4535
|
+
return false;
|
|
4536
|
+
}
|
|
4441
4537
|
const state = this.getState();
|
|
4442
4538
|
const existing = state.history.bySessionId.get(sessionId) || { chat: [], activity: [], lastSeq: 0 };
|
|
4443
4539
|
if (event.seq <= (existing.lastSeq || 0)) return false;
|
|
@@ -4498,14 +4594,7 @@ export class PilotSwarmUiController {
|
|
|
4498
4594
|
this.applyCanvasPresented(sessionId, event);
|
|
4499
4595
|
}
|
|
4500
4596
|
if (event.eventType === "session.canvas_data") {
|
|
4501
|
-
this.
|
|
4502
|
-
type: "canvas/data",
|
|
4503
|
-
sessionId,
|
|
4504
|
-
slot: Number(event?.data?.slot) || 1,
|
|
4505
|
-
dataRev: Number(event?.data?.dataRev),
|
|
4506
|
-
payload: event?.data?.payload,
|
|
4507
|
-
note: typeof event?.data?.note === "string" ? event.data.note : "",
|
|
4508
|
-
});
|
|
4597
|
+
this.applyCanvasDataEvent(sessionId, event);
|
|
4509
4598
|
}
|
|
4510
4599
|
this.reconcileOutboxAgainstEvent(sessionId, event);
|
|
4511
4600
|
const derivedModel = extractSessionModelFromEvent(event);
|
|
@@ -7225,7 +7314,14 @@ export class PilotSwarmUiController {
|
|
|
7225
7314
|
}
|
|
7226
7315
|
|
|
7227
7316
|
setFocus(focusRegion) {
|
|
7228
|
-
|
|
7317
|
+
// Carry the raw request alongside the normalized region: on a phone a
|
|
7318
|
+
// workspace region (sessions/chat) normalizes to inspector, and the
|
|
7319
|
+
// mobile follow-focus effect must not read that as "open diagnostics".
|
|
7320
|
+
this.dispatch({
|
|
7321
|
+
type: "ui/focus",
|
|
7322
|
+
focusRegion: normalizeFocusRegion(focusRegion, this.getCurrentLayout()),
|
|
7323
|
+
requestedFocusRegion: focusRegion,
|
|
7324
|
+
});
|
|
7229
7325
|
}
|
|
7230
7326
|
|
|
7231
7327
|
focusNext() {
|
package/ui/core/src/history.js
CHANGED
|
@@ -1123,7 +1123,11 @@ export function appendEventToHistory(history, event) {
|
|
|
1123
1123
|
chat: clampHistoryItems(history?.chat || [], loadedEventLimit),
|
|
1124
1124
|
activity: clampHistoryItems(history?.activity || [], loadedEventLimit),
|
|
1125
1125
|
events: nextEvents,
|
|
1126
|
-
|
|
1126
|
+
// The replay cursor. A seq-less event (plane-synthesized canvas
|
|
1127
|
+
// ticks are transient and carry none) must never poison it —
|
|
1128
|
+
// lastSeq=undefined reads as afterSeq 0 on the next sync and the
|
|
1129
|
+
// whole transcript re-merges, duplicated.
|
|
1130
|
+
lastSeq: Number.isFinite(Number(event.seq)) ? event.seq : (history?.lastSeq || 0),
|
|
1127
1131
|
loadedEventLimit,
|
|
1128
1132
|
loadedEventCount: Math.max(Number(history?.loadedEventCount || 0), nextEvents.length),
|
|
1129
1133
|
hasOlderEvents: Boolean(history?.hasOlderEvents),
|
package/ui/core/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./repo-import.js";
|
|
|
8
8
|
export * from "./session-tree.js";
|
|
9
9
|
export * from "./state.js";
|
|
10
10
|
export * from "./reducer.js";
|
|
11
|
+
export * from "./portal-links.js";
|
|
11
12
|
export * from "./store.js";
|
|
12
13
|
export * from "./selectors.js";
|
|
13
14
|
export * from "./controller.js";
|
package/ui/core/src/layout.js
CHANGED
|
@@ -57,6 +57,16 @@ export function getMaxSessionPaneHeight(totalHeight, bodyHeight) {
|
|
|
57
57
|
return Math.max(MIN_SESSION_PANE_HEIGHT, Math.min(maxByChatMinimum, maxByWindow));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
export function getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight) {
|
|
61
|
+
const height = Math.max(0, Number(columnHeight) || 0);
|
|
62
|
+
const divider = clamp(Math.max(0, Number(dividerHeight) || 0), 0, height);
|
|
63
|
+
const minAdjust = -(height / 2);
|
|
64
|
+
return {
|
|
65
|
+
minAdjust,
|
|
66
|
+
maxAdjust: Math.max(minAdjust, (height / 2) - divider),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
export function computeLegacyLayout(viewport, paneAdjust = 0, promptRows = 1, sessionPaneAdjust = 0, activityPaneAdjust = 0, fullscreenPane = null) {
|
|
61
71
|
const safeViewport = normalizeViewport(viewport);
|
|
62
72
|
const totalWidth = safeViewport.width;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-origin portal links.
|
|
3
|
+
*
|
|
4
|
+
* A deployment can be reachable through several entry points at once — a
|
|
5
|
+
* public edge and a private/VPN hostname, say — and a link built from the
|
|
6
|
+
* sender's window.location.origin only works for recipients on the SAME
|
|
7
|
+
* network path. When the portal is configured with named link origins,
|
|
8
|
+
* every link-producing surface offers one variant per origin, labeled.
|
|
9
|
+
*
|
|
10
|
+
* This replaces waldemort's deploy-time monkey patch
|
|
11
|
+
* (patch-pilotswarm-dual-session-links.mjs), which anchor-matched the
|
|
12
|
+
* minified bundle and broke on every release. The plug-in surface is one
|
|
13
|
+
* env var on the portal server: PORTAL_LINK_ORIGINS.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Rebase one produced URL onto each configured origin.
|
|
18
|
+
* @param {string} url - a full URL produced against the current origin
|
|
19
|
+
* @param {Array<{label: string, origin: string}>} linkOrigins
|
|
20
|
+
* @returns {Array<{label: string|null, url: string}>} one entry per origin;
|
|
21
|
+
* with no (or one) configured origin, a single unlabeled entry with the
|
|
22
|
+
* original URL — zero behavior change for plain deployments.
|
|
23
|
+
*/
|
|
24
|
+
export function buildPortalLinks(url, linkOrigins) {
|
|
25
|
+
const origins = Array.isArray(linkOrigins) ? linkOrigins.filter((o) => o?.origin && o?.label) : [];
|
|
26
|
+
if (origins.length < 2) return [{ label: null, url: String(url || "") }];
|
|
27
|
+
let suffix;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = new URL(String(url || ""));
|
|
30
|
+
suffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
31
|
+
} catch {
|
|
32
|
+
return [{ label: null, url: String(url || "") }];
|
|
33
|
+
}
|
|
34
|
+
return origins.map((o) => ({ label: String(o.label), url: `${o.origin}${suffix}` }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Clipboard shape: a bare URL for the single-origin case; one labeled line
|
|
39
|
+
* per origin otherwise, so the sender pastes both and the recipient picks
|
|
40
|
+
* the one their network can reach.
|
|
41
|
+
*/
|
|
42
|
+
export function formatPortalLinksForCopy(links) {
|
|
43
|
+
const list = Array.isArray(links) ? links : [];
|
|
44
|
+
if (list.length <= 1) return String(list[0]?.url || "");
|
|
45
|
+
return list.map((l) => `${l.label}: ${l.url}`).join("\n");
|
|
46
|
+
}
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -19,6 +19,20 @@ import {
|
|
|
19
19
|
createInitialState,
|
|
20
20
|
} from "./state.js";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Is this package detail/tree/changelog response for a selection the user has
|
|
24
|
+
* already left? Responses carry the selectionSeq captured when their fetch
|
|
25
|
+
* started; the NAME alone cannot tell two copies of a shadowed name apart
|
|
26
|
+
* (scope shadowing: one name, two packages), so a name-only guard let a slow
|
|
27
|
+
* response for one copy land on top of the other. Name-only actions (older
|
|
28
|
+
* hosts) keep the historical name compare.
|
|
29
|
+
*/
|
|
30
|
+
function packageResponseIsStale(packages, action) {
|
|
31
|
+
if (packages.selectedName !== action.name) return true;
|
|
32
|
+
if (typeof action.seq === "number") return (packages.selectionSeq ?? 0) !== action.seq;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
function cloneHistoryMap(historyMap) {
|
|
23
37
|
return new Map(historyMap);
|
|
24
38
|
}
|
|
@@ -619,6 +633,7 @@ function applyVisibleSessionSelection(state, nextSessions) {
|
|
|
619
633
|
sessions = {
|
|
620
634
|
...sessions,
|
|
621
635
|
activeSessionId: intentTargetId,
|
|
636
|
+
listDeselected: false,
|
|
622
637
|
navigationIntent: intent.status === "resolved"
|
|
623
638
|
? intent
|
|
624
639
|
: { sessionId: intent.sessionId, status: "resolved" },
|
|
@@ -1026,6 +1041,15 @@ export function appReducer(state, action) {
|
|
|
1026
1041
|
ui: {
|
|
1027
1042
|
...state.ui,
|
|
1028
1043
|
focusRegion: action.focusRegion,
|
|
1044
|
+
// The RAW region the caller asked for, before layout
|
|
1045
|
+
// normalization. A workspace region (sessions/chat) with no
|
|
1046
|
+
// slot in the current layout normalizes to the first
|
|
1047
|
+
// focusable pane — inspector, on a phone — so the normalized
|
|
1048
|
+
// value alone cannot tell "focus the inspector" from
|
|
1049
|
+
// "sessions got rewritten to inspector". The mobile
|
|
1050
|
+
// follow-focus pane switch reads this to avoid opening
|
|
1051
|
+
// diagnostics when the user only asked for the workspace.
|
|
1052
|
+
requestedFocusRegion: action.requestedFocusRegion ?? action.focusRegion,
|
|
1029
1053
|
},
|
|
1030
1054
|
};
|
|
1031
1055
|
|
|
@@ -1087,6 +1111,10 @@ export function appReducer(state, action) {
|
|
|
1087
1111
|
if (!sessionId) return state;
|
|
1088
1112
|
const nextSessions = {
|
|
1089
1113
|
...state.sessions,
|
|
1114
|
+
// A deep link is explicit navigation. Keep the list highlight
|
|
1115
|
+
// aligned with the linked chat even if empty-space click had
|
|
1116
|
+
// previously deselected the session row.
|
|
1117
|
+
listDeselected: false,
|
|
1090
1118
|
navigationIntent: { sessionId, status: "pending" },
|
|
1091
1119
|
filterExceptionId: null,
|
|
1092
1120
|
};
|
|
@@ -1542,6 +1570,39 @@ export function appReducer(state, action) {
|
|
|
1542
1570
|
if (previousActiveId && previousActiveId !== action.sessionId) {
|
|
1543
1571
|
savedChatScroll[previousActiveId] = Number(state.ui.scroll?.chat) || 0;
|
|
1544
1572
|
}
|
|
1573
|
+
// Per-session prompt drafts: a half-written message belongs to the
|
|
1574
|
+
// session it was written in. Stash the outgoing draft (text +
|
|
1575
|
+
// staged attachments) and restore the incoming one, exactly like
|
|
1576
|
+
// the chat-scroll memory above. Exception: while editing a pending
|
|
1577
|
+
// outbox message the composer text lives in that outbox item, not
|
|
1578
|
+
// in a draft — leave the item alone, drop the edit latch, and give
|
|
1579
|
+
// the incoming session its own draft back.
|
|
1580
|
+
const switchingSession = previousActiveId !== action.sessionId;
|
|
1581
|
+
const savedDrafts = { ...(state.ui.promptDraftBySession || {}) };
|
|
1582
|
+
let nextPrompt = state.ui.prompt;
|
|
1583
|
+
let nextPromptCursor = state.ui.promptCursor;
|
|
1584
|
+
let nextPromptAttachments = state.ui.promptAttachments;
|
|
1585
|
+
let nextPromptEdit = state.ui.promptEdit ?? null;
|
|
1586
|
+
if (switchingSession) {
|
|
1587
|
+
const editingPending = Boolean(state.ui.promptEdit);
|
|
1588
|
+
if (!editingPending && previousActiveId) {
|
|
1589
|
+
const outgoing = {
|
|
1590
|
+
prompt: String(state.ui.prompt || ""),
|
|
1591
|
+
attachments: Array.isArray(state.ui.promptAttachments) ? state.ui.promptAttachments : [],
|
|
1592
|
+
};
|
|
1593
|
+
if (outgoing.prompt || outgoing.attachments.length > 0) {
|
|
1594
|
+
savedDrafts[previousActiveId] = outgoing;
|
|
1595
|
+
} else {
|
|
1596
|
+
delete savedDrafts[previousActiveId];
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
const incoming = action.sessionId ? savedDrafts[action.sessionId] : null;
|
|
1600
|
+
delete savedDrafts[action.sessionId];
|
|
1601
|
+
nextPrompt = incoming?.prompt || "";
|
|
1602
|
+
nextPromptCursor = nextPrompt.length;
|
|
1603
|
+
nextPromptAttachments = Array.isArray(incoming?.attachments) ? incoming.attachments : [];
|
|
1604
|
+
nextPromptEdit = null;
|
|
1605
|
+
}
|
|
1545
1606
|
// Manual navigation to a different session releases the deep-link
|
|
1546
1607
|
// latch and its transient filter exception.
|
|
1547
1608
|
const releasesNavigationLatch = Boolean(
|
|
@@ -1560,6 +1621,12 @@ export function appReducer(state, action) {
|
|
|
1560
1621
|
ui: {
|
|
1561
1622
|
...state.ui,
|
|
1562
1623
|
chatScrollBySession: savedChatScroll,
|
|
1624
|
+
promptDraftBySession: savedDrafts,
|
|
1625
|
+
prompt: nextPrompt,
|
|
1626
|
+
promptCursor: nextPromptCursor,
|
|
1627
|
+
promptRows: getPromptInputRows(nextPrompt),
|
|
1628
|
+
promptAttachments: nextPromptAttachments,
|
|
1629
|
+
promptEdit: nextPromptEdit,
|
|
1563
1630
|
// Status notices are per-moment, usually per-session (send
|
|
1564
1631
|
// refusals, access hints) — a stale one must not follow the
|
|
1565
1632
|
// user to the next session.
|
|
@@ -1833,6 +1900,8 @@ export function appReducer(state, action) {
|
|
|
1833
1900
|
for (const id of ids) delete nextOutbox[id];
|
|
1834
1901
|
const nextChatScroll = { ...(state.ui.chatScrollBySession || {}) };
|
|
1835
1902
|
for (const id of ids) delete nextChatScroll[id];
|
|
1903
|
+
const nextDrafts = { ...(state.ui.promptDraftBySession || {}) };
|
|
1904
|
+
for (const id of ids) delete nextDrafts[id];
|
|
1836
1905
|
return {
|
|
1837
1906
|
...state,
|
|
1838
1907
|
history: {
|
|
@@ -1846,6 +1915,7 @@ export function appReducer(state, action) {
|
|
|
1846
1915
|
ui: {
|
|
1847
1916
|
...state.ui,
|
|
1848
1917
|
chatScrollBySession: nextChatScroll,
|
|
1918
|
+
promptDraftBySession: nextDrafts,
|
|
1849
1919
|
},
|
|
1850
1920
|
};
|
|
1851
1921
|
}
|
|
@@ -2319,6 +2389,7 @@ export function appReducer(state, action) {
|
|
|
2319
2389
|
workerState: Array.isArray(action.workerState) ? action.workerState : [],
|
|
2320
2390
|
fetchedAt: Date.now(),
|
|
2321
2391
|
selectedName: stillThere ? previous.selectedName : null,
|
|
2392
|
+
selectedSelector: stillThere ? (previous.selectedSelector ?? null) : null,
|
|
2322
2393
|
...(stillThere ? {} : { detail: null, workspace: { ...previous.workspace, tree: null, selectedPath: null, file: null } }),
|
|
2323
2394
|
},
|
|
2324
2395
|
},
|
|
@@ -2336,6 +2407,18 @@ export function appReducer(state, action) {
|
|
|
2336
2407
|
packages: {
|
|
2337
2408
|
...state.admin.packages,
|
|
2338
2409
|
selectedName: action.name || null,
|
|
2410
|
+
// WHICH copy of the name: {scope, owner} from the row
|
|
2411
|
+
// the user clicked. One name can be two packages
|
|
2412
|
+
// (scope shadowing) — without this every read and
|
|
2413
|
+
// action fell back to "own copy first" and could
|
|
2414
|
+
// target the row the user was NOT looking at.
|
|
2415
|
+
selectedSelector: action.selector || null,
|
|
2416
|
+
// Monotonic selection token. The stale-response guards
|
|
2417
|
+
// below compare it, because comparing the NAME alone
|
|
2418
|
+
// cannot tell two copies of a shadowed name apart — a
|
|
2419
|
+
// slow response for the user copy could land on top of
|
|
2420
|
+
// the shared copy the user just clicked.
|
|
2421
|
+
selectionSeq: (state.admin.packages.selectionSeq ?? 0) + 1,
|
|
2339
2422
|
detail: null,
|
|
2340
2423
|
detailLoading: Boolean(action.name),
|
|
2341
2424
|
detailError: null,
|
|
@@ -2354,17 +2437,17 @@ export function appReducer(state, action) {
|
|
|
2354
2437
|
};
|
|
2355
2438
|
}
|
|
2356
2439
|
case "admin/packages/detail/loaded": {
|
|
2357
|
-
if (state.admin.packages
|
|
2440
|
+
if (packageResponseIsStale(state.admin.packages, action)) return state;
|
|
2358
2441
|
return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, detail: action.detail, detailLoading: false, detailError: null } } };
|
|
2359
2442
|
}
|
|
2360
2443
|
case "admin/packages/detail/loadFailed": {
|
|
2361
|
-
if (state.admin.packages
|
|
2444
|
+
if (packageResponseIsStale(state.admin.packages, action)) return state;
|
|
2362
2445
|
return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, detail: null, detailLoading: false, detailError: action.error || "Failed to load package" } } };
|
|
2363
2446
|
}
|
|
2364
2447
|
case "admin/packages/changelog/loaded": {
|
|
2365
2448
|
// Stale-response guard, same as the tree: a slow changelog for a
|
|
2366
2449
|
// previously-selected package must not surface under the current one.
|
|
2367
|
-
if (state.admin.packages
|
|
2450
|
+
if (packageResponseIsStale(state.admin.packages, action)) return state;
|
|
2368
2451
|
return {
|
|
2369
2452
|
...state,
|
|
2370
2453
|
admin: {
|
|
@@ -2377,7 +2460,7 @@ export function appReducer(state, action) {
|
|
|
2377
2460
|
};
|
|
2378
2461
|
}
|
|
2379
2462
|
case "admin/packages/tree/loaded": {
|
|
2380
|
-
if (state.admin.packages
|
|
2463
|
+
if (packageResponseIsStale(state.admin.packages, action)) return state;
|
|
2381
2464
|
const topDirs = Array.isArray(action.tree?.dirs)
|
|
2382
2465
|
? action.tree.dirs.filter((dir) => !dir.includes("/"))
|
|
2383
2466
|
: [];
|
|
@@ -2400,7 +2483,7 @@ export function appReducer(state, action) {
|
|
|
2400
2483
|
};
|
|
2401
2484
|
}
|
|
2402
2485
|
case "admin/packages/tree/loadFailed": {
|
|
2403
|
-
if (state.admin.packages
|
|
2486
|
+
if (packageResponseIsStale(state.admin.packages, action)) return state;
|
|
2404
2487
|
return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...state.admin.packages.workspace, treeLoading: false, treeError: action.error || "Failed to load package files" } } } };
|
|
2405
2488
|
}
|
|
2406
2489
|
case "admin/packages/toggleDir": {
|
|
@@ -2414,11 +2497,17 @@ export function appReducer(state, action) {
|
|
|
2414
2497
|
return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...state.admin.packages.workspace, selectedPath: action.path, file: null, fileLoading: true, fileError: null } } } };
|
|
2415
2498
|
}
|
|
2416
2499
|
case "admin/packages/file/loaded": {
|
|
2500
|
+
// Path AND selection token: two copies of a shadowed name have the
|
|
2501
|
+
// SAME file paths (both ship plugin.json), so a slow preview for
|
|
2502
|
+
// the previously-selected copy would otherwise land under the
|
|
2503
|
+
// current one. The seq check tells the two copies apart.
|
|
2417
2504
|
if (state.admin.packages.workspace.selectedPath !== action.file?.path) return state;
|
|
2505
|
+
if (typeof action.seq === "number" && (state.admin.packages.selectionSeq ?? 0) !== action.seq) return state;
|
|
2418
2506
|
return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...state.admin.packages.workspace, file: action.file, fileLoading: false, fileError: null } } } };
|
|
2419
2507
|
}
|
|
2420
2508
|
case "admin/packages/file/loadFailed": {
|
|
2421
2509
|
if (state.admin.packages.workspace.selectedPath !== action.path) return state;
|
|
2510
|
+
if (typeof action.seq === "number" && (state.admin.packages.selectionSeq ?? 0) !== action.seq) return state;
|
|
2422
2511
|
return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...state.admin.packages.workspace, fileLoading: false, fileError: action.error || "Failed to load file" } } } };
|
|
2423
2512
|
}
|
|
2424
2513
|
case "admin/packages/action/pending": {
|
|
@@ -2906,7 +2995,20 @@ export function appReducer(state, action) {
|
|
|
2906
2995
|
const dataRev = Number(action.dataRev);
|
|
2907
2996
|
if (!sessionId || !Number.isFinite(dataRev) || dataRev <= 0) return state;
|
|
2908
2997
|
const existing = state.canvas.bySessionId[key] || {};
|
|
2909
|
-
|
|
2998
|
+
const planeSeq = Number(action.planeSeq);
|
|
2999
|
+
const isPlane = Number.isFinite(planeSeq) && planeSeq > 0;
|
|
3000
|
+
// Plane takeover: once a slot has seen ONE plane-fed tick, that
|
|
3001
|
+
// lineage owns the slot. Plane ticks order by planeSeq (their
|
|
3002
|
+
// numbering restarts relative to legacy dataRev, so the numeric
|
|
3003
|
+
// guard below would wrongly starve them); legacy dual-written
|
|
3004
|
+
// events for a plane-owned slot are strictly staler copies of
|
|
3005
|
+
// state the plane already delivered — drop them.
|
|
3006
|
+
if (isPlane) {
|
|
3007
|
+
if ((existing.planeSeq || 0) >= planeSeq) return state;
|
|
3008
|
+
} else {
|
|
3009
|
+
if (existing.planeSeq) return state;
|
|
3010
|
+
if ((existing.latestDataRev || 0) >= dataRev) return state;
|
|
3011
|
+
}
|
|
2910
3012
|
return {
|
|
2911
3013
|
...state,
|
|
2912
3014
|
canvas: {
|
|
@@ -2915,14 +3017,45 @@ export function appReducer(state, action) {
|
|
|
2915
3017
|
...state.canvas.bySessionId,
|
|
2916
3018
|
[key]: {
|
|
2917
3019
|
...existing,
|
|
2918
|
-
|
|
3020
|
+
// The DISPLAYED tick number stays monotonic across
|
|
3021
|
+
// the takeover: viewed-marking and badges compare
|
|
3022
|
+
// against it, and a smaller number would replay
|
|
3023
|
+
// as "already seen".
|
|
3024
|
+
latestDataRev: Math.max(dataRev, existing.latestDataRev || 0),
|
|
3025
|
+
...(isPlane ? { planeSeq } : {}),
|
|
2919
3026
|
dataPayload: action.payload && typeof action.payload === "object" ? action.payload : null,
|
|
3027
|
+
// The patch that produced this state, for pages
|
|
3028
|
+
// that opt into targeted updates. Whole-state
|
|
3029
|
+
// ticks clear it — there is no delta to hand out.
|
|
3030
|
+
dataPatch: isPlane && action.patch && typeof action.patch === "object" ? action.patch : null,
|
|
2920
3031
|
},
|
|
2921
3032
|
},
|
|
2922
3033
|
},
|
|
2923
3034
|
};
|
|
2924
3035
|
}
|
|
2925
3036
|
|
|
3037
|
+
case "canvas/planeReleased": {
|
|
3038
|
+
// The plane died for this session: release the takeover on every
|
|
3039
|
+
// slot so legacy durable ticks resume applying. Displayed tick
|
|
3040
|
+
// numbers (latestDataRev) survive, so stale legacy dupes still
|
|
3041
|
+
// drop by ordinary ordering while fresh ones flow.
|
|
3042
|
+
const sessionId = String(action.sessionId || "").trim();
|
|
3043
|
+
if (!sessionId) return state;
|
|
3044
|
+
let changed = false;
|
|
3045
|
+
const nextBySession = { ...state.canvas.bySessionId };
|
|
3046
|
+
for (let slot = 1; slot <= 5; slot++) {
|
|
3047
|
+
const key = canvasKey(sessionId, slot);
|
|
3048
|
+
const entry = nextBySession[key];
|
|
3049
|
+
if (entry && entry.planeSeq) {
|
|
3050
|
+
const { planeSeq: _released, ...rest } = entry;
|
|
3051
|
+
nextBySession[key] = rest;
|
|
3052
|
+
changed = true;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
if (!changed) return state;
|
|
3056
|
+
return { ...state, canvas: { ...state.canvas, bySessionId: nextBySession } };
|
|
3057
|
+
}
|
|
3058
|
+
|
|
2926
3059
|
case "canvas/snapshot": {
|
|
2927
3060
|
const sessionId = String(action.sessionId || "").trim();
|
|
2928
3061
|
if (!sessionId) return state;
|