pilotswarm 0.5.36 → 0.5.38
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/package.json +3 -3
- package/tui/src/app.js +0 -4
- package/tui/src/index.js +0 -9
- package/ui/core/src/canvas-actions.js +148 -0
- package/ui/core/src/commands.js +0 -1
- package/ui/core/src/controller.js +296 -21
- package/ui/core/src/history.js +134 -1
- package/ui/core/src/index.js +1 -0
- package/ui/core/src/reducer.js +407 -19
- package/ui/core/src/selectors.js +223 -154
- package/ui/core/src/state.js +139 -2
- package/ui/core/src/themes/index.js +1 -1
- package/ui/react/src/components.js +1 -7
- package/ui/react/src/web-app.js +1719 -366
- package/web/auth/config.js +3 -0
- package/web/auth/index.js +8 -1
- package/web/auth/normalize/proxy.js +48 -0
- package/web/auth/providers/proxy.js +141 -0
- package/web/dist/assets/{abnfDiagram-VRR7QNED-LOLCtsns.js → abnfDiagram-VRR7QNED-DrJhXhSQ.js} +1 -1
- package/web/dist/assets/{arc-BE-jVDEi.js → arc-BEaG9DWd.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CdmUjwtj.js → architectureDiagram-ZJ3FMSHR-D9fc1Imx.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-NZ4TOa8L.js → blockDiagram-677ZJIJ3-rS5Eikvt.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-9ubX8uGA.js → c4Diagram-LMCZKHZV-HKLlX5cR.js} +1 -1
- package/web/dist/assets/channel-DRy7FvyF.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-DNDnjbP6.js → chunk-2Q5K7J3B-kq0TyLSB.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-iilfiffr.js → chunk-32BRIVSS-O-LMarT5.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-BakOMFmf.js → chunk-5VM5RSS4-Di4h0CAu.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-BhluhGyZ.js → chunk-EX3LRPZG-C8rgqUOt.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-B6GKLlNb.js → chunk-JWPE2WC7-BSH41q4T.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-CXmt8msF.js → chunk-MOJQB5TN-BAFuQHAC.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-Bu58-SDr.js → chunk-RYQCIY6F-DAg6JOs7.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-AQdyoYHL.js → chunk-V7JOEXUC-DbVrT8BG.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-DeM5iMsE.js → chunk-VR4S4FIN-C6f5_Grp.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-DjGjajy1.js → chunk-XXDRQBXY-CqcCYpqA.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-BGi4_egJ.js → cose-bilkent-JH36ORCC-DJw04eFh.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CcgV1ve4.js → cynefin-VYW2F7L2-CpjwdEIU.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BRDEsbBG.js → cynefinDiagram-TSTJHNR4-RqEIWoT0.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-OjuC8dJB.js → dagre-VKFMJZFB-C1eGQNK2.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CGdROWYh.js → diagram-FQU43EPY-BODLQ7dz.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-B5J0EUfS.js → diagram-G47NLZAW-DlhmukMC.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-zmb4-cP2.js → diagram-NH7WQ7WH-Ba_jZoCN.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-j514bgaH.js → diagram-OA4YK3LP-DnSiAJBG.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-h5G459mZ.js → diagram-WEI45ONY-B3KoQ7_W.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-GnMgbuFk.js → ebnfDiagram-CCIWWBDH-ChnFc5gf.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-BFgH3UQp.js → erDiagram-Q63AITRT-D2KkJXLZ.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-BsbQ_VXV.js → flowDiagram-23GEKE2U-CmybZufj.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-C_fjo2sf.js → ganttDiagram-NO4QXBWP-BzPurW_v.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BUoXrAI9.js → gitGraphDiagram-IHSO6WYX-DAbXl4gO.js} +1 -1
- package/web/dist/assets/{index-ONJimzcx.js → index-CRY94nFp.js} +6 -6
- package/web/dist/assets/index-DtL0lbis.css +1 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-CJiSANyk.js → infoDiagram-FWYZ7A6U-C6qGjNA1.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-BKoQ-a2c.js → ishikawaDiagram-FXEZZL3T-D2d1tO-9.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-Db6Crcd3.js → journeyDiagram-5HDEW3XC-fP13w1b7.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-B9GaB0UJ.js → kanban-definition-HUTT4EX6-BJfWAXZ0.js} +1 -1
- package/web/dist/assets/{linear-BXwRb8MN.js → linear-CltApkHf.js} +1 -1
- package/web/dist/assets/{mermaid.core-DwzXN6up.js → mermaid.core-B1Dwetk3.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DKC_C_OY.js → mindmap-definition-LN4V7U3C-CdRUD7LF.js} +1 -1
- package/web/dist/assets/{papaparse.min-Cm1Pr54l.js → papaparse.min-C1y3wBH1.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-IeawbZPt.js → pegDiagram-2B236MQR-CeQkqN6Q.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-BOdsYBt8.js → pieDiagram-ENE6RG2P-Dqgc87jA.js} +1 -1
- package/web/dist/assets/pilotswarm-DayYdi39.js +96 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cr5yoN7C.js → quadrantDiagram-ABIIQ3AL-CD7f-w6m.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-E7wuFBuk.js → railroadDiagram-RFXS5EU6-CcsAijSC.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-sZCKiuDs.js → requirementDiagram-TGXJPOKE-BkzF80pL.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_EWziUR.js → sankeyDiagram-HTMAVEWB-D9XOFw7Y.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D2mN7onX.js → sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-CEL9Ly2T.js → sizeCapture-X5ZJPWSS-CDJV16tP.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-C7ejJoeN.js → stateDiagram-2N3HPSRC-C0E-MPXO.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-Ck3pbFxk.js → swimlanes-5IMT3BWC-n5Vsv7n9.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-K3H8Yuyr.js → timeline-definition-FHXFAJF6-DW2gV8-n.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-C3NKjSv9.js → vennDiagram-L72KCM5P-D9dbDm08.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-CeeVuFiO.js → wardleyDiagram-EHGQE667-CIcG7fiC.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-CF-vCvEq.js → xychartDiagram-FW5EYKEG-elEIFsc9.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/runtime.js +20 -1
- package/web/dist/assets/channel-BhHZnKdH.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-CM_bKBKz.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-CM_bKBKz.js +0 -1
- package/web/dist/assets/index-CnH1Yv_d.css +0 -1
- package/web/dist/assets/pilotswarm-kCQFVjJm.js +0 -99
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-UaVDt_gU.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CqO_fF5t.js +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pilotswarm",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.38",
|
|
4
4
|
"description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server — one install, three bins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -81,13 +81,13 @@
|
|
|
81
81
|
"ink": "^6.8.0",
|
|
82
82
|
"jose": "^6.2.2",
|
|
83
83
|
"mermaid": "^11.16.0",
|
|
84
|
-
"pilotswarm-sdk": "0.5.
|
|
84
|
+
"pilotswarm-sdk": "0.5.38",
|
|
85
85
|
"react": "^19.2.4",
|
|
86
86
|
"react-dom": "^19.2.4",
|
|
87
87
|
"ws": "^8.18.2"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@vitejs/plugin-react": "^5.1.0",
|
|
91
|
-
"vite": "
|
|
91
|
+
"vite": "7.3.5"
|
|
92
92
|
}
|
|
93
93
|
}
|
package/tui/src/app.js
CHANGED
|
@@ -932,10 +932,6 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
|
|
|
932
932
|
}
|
|
933
933
|
|
|
934
934
|
if (focus === "chat" || focus === "inspector" || focus === "activity") {
|
|
935
|
-
if (focus === "chat" && plainShortcut && input === "s") {
|
|
936
|
-
controller.handleCommand(UI_COMMANDS.TOGGLE_CHAT_VIEW).catch(() => {});
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
935
|
if (key.upArrow || input === "k") {
|
|
940
936
|
controller.handleCommand(UI_COMMANDS.SCROLL_UP).catch(() => {});
|
|
941
937
|
return;
|
package/tui/src/index.js
CHANGED
|
@@ -158,7 +158,6 @@ export async function startTuiApp(config) {
|
|
|
158
158
|
mode: config.mode,
|
|
159
159
|
branding: config.branding,
|
|
160
160
|
themeId: userConfig.themeId,
|
|
161
|
-
chatViewMode: userConfig.chatViewMode,
|
|
162
161
|
sessionOwnerFilter: userConfig.sessionOwnerFilter,
|
|
163
162
|
layoutAdjustments: userConfig.layoutAdjustments,
|
|
164
163
|
pinnedSessionIds: userConfig.pinnedSessionIds,
|
|
@@ -255,7 +254,6 @@ export async function startTuiApp(config) {
|
|
|
255
254
|
|
|
256
255
|
// Persist theme changes to config file
|
|
257
256
|
let lastPersistedThemeId = store.getState().ui.themeId;
|
|
258
|
-
let lastPersistedChatViewMode = store.getState().ui.chatViewMode;
|
|
259
257
|
let lastPersistedSessionOwnerFilter = JSON.stringify(store.getState().sessions.ownerFilter || null);
|
|
260
258
|
let lastPersistedLayoutAdjustments = JSON.stringify({
|
|
261
259
|
paneAdjust: store.getState().ui.layout?.paneAdjust || 0,
|
|
@@ -268,7 +266,6 @@ export async function startTuiApp(config) {
|
|
|
268
266
|
store.subscribe(() => {
|
|
269
267
|
const state = store.getState();
|
|
270
268
|
const currentThemeId = state.ui.themeId;
|
|
271
|
-
const currentChatViewMode = state.ui.chatViewMode;
|
|
272
269
|
const currentSessionOwnerFilter = state.sessions.ownerFilter || null;
|
|
273
270
|
const currentSessionOwnerFilterJson = JSON.stringify(currentSessionOwnerFilter);
|
|
274
271
|
const currentLayoutAdjustments = {
|
|
@@ -291,12 +288,6 @@ export async function startTuiApp(config) {
|
|
|
291
288
|
changed = true;
|
|
292
289
|
}
|
|
293
290
|
|
|
294
|
-
if ((currentChatViewMode === "summary" || currentChatViewMode === "transcript") && currentChatViewMode !== lastPersistedChatViewMode) {
|
|
295
|
-
lastPersistedChatViewMode = currentChatViewMode;
|
|
296
|
-
patch.chatViewMode = currentChatViewMode;
|
|
297
|
-
changed = true;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
291
|
if (currentSessionOwnerFilterJson !== lastPersistedSessionOwnerFilter) {
|
|
301
292
|
lastPersistedSessionOwnerFilter = currentSessionOwnerFilterJson;
|
|
302
293
|
patch.sessionOwnerFilter = currentSessionOwnerFilter;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas response actions — the browser half of the interactive-canvas
|
|
3
|
+
* contract.
|
|
4
|
+
*
|
|
5
|
+
* The agent draws the canvas AND declares, per draw, exactly which structured
|
|
6
|
+
* responses that page may send back (`responseContract` on `draw_canvas`,
|
|
7
|
+
* carried in `session.canvas_updated` event data). A page control posts
|
|
8
|
+
* `{type: "canvas-action", action, data}` to the parent; the CanvasFrame
|
|
9
|
+
* hands it here. This module is the whole decision:
|
|
10
|
+
*
|
|
11
|
+
* - NO contract on the current canvas → nothing is accepted. Interactivity
|
|
12
|
+
* is opt-in per revision, default-closed.
|
|
13
|
+
* - The action must be declared, every field must match its declared
|
|
14
|
+
* primitive type, no undeclared fields, hard size caps.
|
|
15
|
+
*
|
|
16
|
+
* A conforming action is formatted as a canonical `[canvas-action] {...}`
|
|
17
|
+
* user message. The transcript records it (provenance: it is a real message
|
|
18
|
+
* from the authenticated viewer), the agent reads the JSON, and the portal
|
|
19
|
+
* chat HIDES it — the canvas redraw is the user-visible half of the loop.
|
|
20
|
+
*
|
|
21
|
+
* Provenance (that the message came from the canvas iframe and not some other
|
|
22
|
+
* artifact preview) is enforced by the caller: the CanvasFrame accepts
|
|
23
|
+
* postMessage events only from its own live iframe's contentWindow. This
|
|
24
|
+
* module assumes that check already happened.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const CANVAS_ACTION_PREFIX = "[canvas-action] ";
|
|
28
|
+
|
|
29
|
+
const ACTION_MAX_STRING_CHARS = 2048;
|
|
30
|
+
const ACTION_MAX_SERIALIZED_BYTES = 8192;
|
|
31
|
+
|
|
32
|
+
/** Serialized-size guard that tolerates hosts without TextEncoder. */
|
|
33
|
+
function serializedByteLength(value) {
|
|
34
|
+
// Cyclic objects and BigInts cross postMessage (structured clone) but
|
|
35
|
+
// make JSON.stringify THROW — and this module's contract is that garbage
|
|
36
|
+
// is refused, never thrown, because the throw would escape the sync
|
|
37
|
+
// submit path as an uncaught error. Infinity fails the size check.
|
|
38
|
+
let json;
|
|
39
|
+
try {
|
|
40
|
+
json = JSON.stringify(value) || "";
|
|
41
|
+
} catch {
|
|
42
|
+
return Infinity;
|
|
43
|
+
}
|
|
44
|
+
if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(json).length;
|
|
45
|
+
return json.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate one posted message against the current canvas contract.
|
|
50
|
+
* Returns `{ok: true, action, data}` or `{ok: false, reason}`; never throws
|
|
51
|
+
* on garbage — a hostile page must not be able to crash the portal shell.
|
|
52
|
+
*/
|
|
53
|
+
export function validateCanvasAction(contract, message) {
|
|
54
|
+
const actions = contract?.actions;
|
|
55
|
+
if (!actions || typeof actions !== "object") {
|
|
56
|
+
return { ok: false, reason: "the current canvas declares no response contract" };
|
|
57
|
+
}
|
|
58
|
+
if (!message || typeof message !== "object" || message.type !== "canvas-action") {
|
|
59
|
+
return { ok: false, reason: "not a canvas-action message" };
|
|
60
|
+
}
|
|
61
|
+
const action = typeof message.action === "string" ? message.action : "";
|
|
62
|
+
const declaredFields = Object.prototype.hasOwnProperty.call(actions, action) ? actions[action] : null;
|
|
63
|
+
if (!declaredFields || typeof declaredFields !== "object") {
|
|
64
|
+
return { ok: false, reason: `action ${JSON.stringify(action)} is not declared by the contract` };
|
|
65
|
+
}
|
|
66
|
+
const data = message.data === undefined ? {} : message.data;
|
|
67
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
68
|
+
return { ok: false, reason: "data must be an object" };
|
|
69
|
+
}
|
|
70
|
+
for (const key of Object.keys(data)) {
|
|
71
|
+
if (!Object.prototype.hasOwnProperty.call(declaredFields, key)) {
|
|
72
|
+
return { ok: false, reason: `field ${JSON.stringify(key)} is not declared for action ${JSON.stringify(action)}` };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const clean = {};
|
|
76
|
+
for (const [field, declaredRaw] of Object.entries(declaredFields)) {
|
|
77
|
+
const declared = String(declaredRaw || "");
|
|
78
|
+
const optional = declared.endsWith("?");
|
|
79
|
+
const type = optional ? declared.slice(0, -1) : declared;
|
|
80
|
+
const value = data[field];
|
|
81
|
+
if (value === undefined || value === null) {
|
|
82
|
+
if (optional) continue;
|
|
83
|
+
return { ok: false, reason: `required field ${JSON.stringify(field)} is missing` };
|
|
84
|
+
}
|
|
85
|
+
if (type === "json") {
|
|
86
|
+
// One structured value for batched forms — a 23-item sign-off
|
|
87
|
+
// submits as a single object, not 23 round trips. Internal shape
|
|
88
|
+
// is the agent's contract with itself (it authored both sides);
|
|
89
|
+
// the serialized-size cap below is the only bound that matters.
|
|
90
|
+
if (typeof value !== "object") {
|
|
91
|
+
return { ok: false, reason: `field ${JSON.stringify(field)} must be a JSON object or array` };
|
|
92
|
+
}
|
|
93
|
+
clean[field] = value;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value !== type) {
|
|
97
|
+
return { ok: false, reason: `field ${JSON.stringify(field)} must be a ${type}` };
|
|
98
|
+
}
|
|
99
|
+
if (type === "string" && value.length > ACTION_MAX_STRING_CHARS) {
|
|
100
|
+
return { ok: false, reason: `field ${JSON.stringify(field)} exceeds ${ACTION_MAX_STRING_CHARS} characters` };
|
|
101
|
+
}
|
|
102
|
+
if (type === "number" && !Number.isFinite(value)) {
|
|
103
|
+
return { ok: false, reason: `field ${JSON.stringify(field)} must be a finite number` };
|
|
104
|
+
}
|
|
105
|
+
clean[field] = value;
|
|
106
|
+
}
|
|
107
|
+
if (serializedByteLength(clean) > ACTION_MAX_SERIALIZED_BYTES) {
|
|
108
|
+
return { ok: false, reason: `action payload exceeds ${ACTION_MAX_SERIALIZED_BYTES} bytes` };
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, action, data: clean };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The canonical wire text — what the agent reads and history flags on. */
|
|
114
|
+
export function formatCanvasActionPrompt(action, data) {
|
|
115
|
+
return `${CANVAS_ACTION_PREFIX}${JSON.stringify({ action, data })}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Detector for the history pipelines: is this user message a canvas action? */
|
|
119
|
+
export function isCanvasActionContent(content) {
|
|
120
|
+
return typeof content === "string" && content.startsWith(CANVAS_ACTION_PREFIX);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Parse the canonical text back to {action, data}; null when malformed. */
|
|
124
|
+
export function parseCanvasActionContent(content) {
|
|
125
|
+
if (!isCanvasActionContent(content)) return null;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(content.slice(CANVAS_ACTION_PREFIX.length));
|
|
128
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.action !== "string") return null;
|
|
129
|
+
return { action: parsed.action, data: parsed.data && typeof parsed.data === "object" ? parsed.data : {} };
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A small sliding-window rate limiter: at most `burst` accepted actions per
|
|
137
|
+
* `windowMs`. The page is agent-authored JS speaking AS the viewer, so this
|
|
138
|
+
* is the bound on a buggy page auto-posting in a loop.
|
|
139
|
+
*/
|
|
140
|
+
export function createCanvasActionLimiter({ burst = 3, windowMs = 3000 } = {}) {
|
|
141
|
+
const stamps = [];
|
|
142
|
+
return function tryAcquire(now = Date.now()) {
|
|
143
|
+
while (stamps.length && now - stamps[0] > windowMs) stamps.shift();
|
|
144
|
+
if (stamps.length >= burst) return false;
|
|
145
|
+
stamps.push(now);
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
148
|
+
}
|
package/ui/core/src/commands.js
CHANGED
|
@@ -21,7 +21,6 @@ export const UI_COMMANDS = {
|
|
|
21
21
|
OPEN_MOVE_TO_GROUP: "openMoveToGroup",
|
|
22
22
|
CREATE_SESSION_GROUP: "createSessionGroup",
|
|
23
23
|
OPEN_ARTIFACT_UPLOAD: "openArtifactUpload",
|
|
24
|
-
TOGGLE_CHAT_VIEW: "toggleChatView",
|
|
25
24
|
CLOSE_MODAL: "closeModal",
|
|
26
25
|
MODAL_PREV: "modalPrev",
|
|
27
26
|
MODAL_NEXT: "modalNext",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { UI_COMMANDS, FOCUS_REGIONS, INSPECTOR_TABS, cycleValue } from "./commands.js";
|
|
2
|
+
import { canvasKey as canvasPrefKey } from "./state.js";
|
|
2
3
|
import { parseAgentSourceLink } from "./repo-links.js";
|
|
3
4
|
import { importPackageFilesFromLink, readImportedPackageName } from "./repo-import.js";
|
|
4
5
|
import {
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
getNextHistoryEventLimit,
|
|
11
12
|
} from "./history.js";
|
|
12
13
|
import { applySessionUsageEvent, cloneContextUsageSnapshot } from "./context-usage.js";
|
|
14
|
+
import { validateCanvasAction, formatCanvasActionPrompt, createCanvasActionLimiter } from "./canvas-actions.js";
|
|
13
15
|
import { shouldKeepRecoverableTransportWarning } from "./session-errors.js";
|
|
14
16
|
import {
|
|
15
17
|
computeLegacyLayout,
|
|
@@ -2413,6 +2415,17 @@ export class PilotSwarmUiController {
|
|
|
2413
2415
|
if (pendingGroupRows) {
|
|
2414
2416
|
this.dispatch({ type: "sessions/groupsLoaded", groups: pendingGroupRows, sessions });
|
|
2415
2417
|
}
|
|
2418
|
+
// Canvas summaries ride the list rows (session_canvases join) — seed
|
|
2419
|
+
// the store so rows mark on cold load, before any session is selected
|
|
2420
|
+
// or any live event arrives. Never regresses what live events already
|
|
2421
|
+
// know; see the canvas/seed reducer.
|
|
2422
|
+
const canvasSeed = [];
|
|
2423
|
+
for (const row of sessions) {
|
|
2424
|
+
for (const c of Array.isArray(row?.canvases) ? row.canvases : []) {
|
|
2425
|
+
canvasSeed.push({ sessionId: row.sessionId, slot: c.slot, name: c.name, latestRev: c.latestRev, sizeBytes: c.sizeBytes });
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
if (canvasSeed.length) this.dispatch({ type: "canvas/seed", entries: canvasSeed });
|
|
2416
2429
|
this.dispatch({ type: "sessions/loaded", sessions });
|
|
2417
2430
|
this.refreshOpenSessionOwnerFilterModal();
|
|
2418
2431
|
const selected = this.getState().sessions.activeSessionId;
|
|
@@ -2444,6 +2457,11 @@ export class PilotSwarmUiController {
|
|
|
2444
2457
|
if (!existingHistory?.events?.length) {
|
|
2445
2458
|
await this.ensureSessionHistory(selected, { force: true }).catch(() => {});
|
|
2446
2459
|
}
|
|
2460
|
+
// Boot-restore can hydrate the selection through THIS branch
|
|
2461
|
+
// instead of loadSession (whether the profile poll beats the first
|
|
2462
|
+
// sessions refresh is a race), so the canvas snapshot must be
|
|
2463
|
+
// armed here too. Memoized — refreshes after the first are free.
|
|
2464
|
+
this.ensureCanvasSnapshot(selected).catch(() => {});
|
|
2447
2465
|
if (this.activeSessionSubscriptionId !== selected) {
|
|
2448
2466
|
this.attachActiveSession(selected);
|
|
2449
2467
|
}
|
|
@@ -4163,6 +4181,13 @@ export class PilotSwarmUiController {
|
|
|
4163
4181
|
await this.syncSessionDetail(sessionId).catch(() => {});
|
|
4164
4182
|
this.attachActiveSession(sessionId);
|
|
4165
4183
|
this.ensureInspectorData().catch(() => {});
|
|
4184
|
+
// Canvas snapshot rides the selection burst. Invalidate-then-fetch on
|
|
4185
|
+
// every selection, deliberately: the live event stream only covers the
|
|
4186
|
+
// ATTACHED session, so a session's canvas knowledge goes stale the
|
|
4187
|
+
// moment you switch away — re-selection is the continuity break, and
|
|
4188
|
+
// one indexed limit-1 row is noise against the burst above.
|
|
4189
|
+
this.dispatch({ type: "canvas/snapshotInvalidate", sessionId });
|
|
4190
|
+
this.ensureCanvasSnapshot(sessionId).catch(() => {});
|
|
4166
4191
|
}
|
|
4167
4192
|
|
|
4168
4193
|
/**
|
|
@@ -4229,6 +4254,188 @@ export class PilotSwarmUiController {
|
|
|
4229
4254
|
return true;
|
|
4230
4255
|
}
|
|
4231
4256
|
|
|
4257
|
+
/**
|
|
4258
|
+
* Apply a live `session.canvas_updated` event: converge content always,
|
|
4259
|
+
* flip the right column only when it is unmistakably appropriate.
|
|
4260
|
+
*
|
|
4261
|
+
* Flip guards (the artifact-presented set, plus the user's own choices):
|
|
4262
|
+
* active session only, fresh only, not already in canvas mode, and never
|
|
4263
|
+
* after the user has manually toggled away this session — their explicit
|
|
4264
|
+
* choice downgrades flips to the unseen-changes badge.
|
|
4265
|
+
*/
|
|
4266
|
+
applyCanvasUpdate(sessionId, event) {
|
|
4267
|
+
const rev = Number(event?.data?.rev);
|
|
4268
|
+
if (!sessionId || !Number.isFinite(rev) || rev <= 0) return false;
|
|
4269
|
+
const rawSlot = Number(event?.data?.slot);
|
|
4270
|
+
const slot = Number.isInteger(rawSlot) && rawSlot >= 1 && rawSlot <= 5 ? rawSlot : 1;
|
|
4271
|
+
this.dispatch({
|
|
4272
|
+
type: "canvas/updated",
|
|
4273
|
+
sessionId,
|
|
4274
|
+
slot,
|
|
4275
|
+
...(typeof event?.data?.name === "string" ? { name: event.data.name } : {}),
|
|
4276
|
+
rev,
|
|
4277
|
+
note: typeof event?.data?.note === "string" ? event.data.note : "",
|
|
4278
|
+
sizeBytes: event?.data?.sizeBytes,
|
|
4279
|
+
responseContract: event?.data?.responseContract,
|
|
4280
|
+
});
|
|
4281
|
+
|
|
4282
|
+
const state = this.getState();
|
|
4283
|
+
if (sessionId !== state.sessions.activeSessionId) return true;
|
|
4284
|
+
// The opt-out is PER SLOT: leaving one canvas by hand must not
|
|
4285
|
+
// silence a different canvas's first draw.
|
|
4286
|
+
if (state.canvas.prefs[canvasPrefKey(sessionId, slot)]?.optedOut) return true;
|
|
4287
|
+
const createdAt = event?.createdAt ? Date.parse(event.createdAt) : Number.NaN;
|
|
4288
|
+
if (Number.isFinite(createdAt) && Date.now() - createdAt > PRESENTED_ARTIFACT_FRESHNESS_MS) {
|
|
4289
|
+
return true;
|
|
4290
|
+
}
|
|
4291
|
+
// canvas/flip rather than a plain mode change: it ticks flipSeq even
|
|
4292
|
+
// when the mode is already "canvas", because a phone can be off its
|
|
4293
|
+
// canvas tab while the mode state still says canvas — the tick is
|
|
4294
|
+
// what brings the tab back. On desktop the repeat dispatch is a no-op.
|
|
4295
|
+
this.dispatch({ type: "canvas/flip", sessionId, slot });
|
|
4296
|
+
return true;
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4299
|
+
/**
|
|
4300
|
+
* The cold-load snapshot: one indexed lookup against the full durable log,
|
|
4301
|
+
* fired per session as part of the selection burst and memoized. Its
|
|
4302
|
+
* result is definitive both ways — an event means a canvas exists at that
|
|
4303
|
+
* rev; nothing means none has ever been drawn. The client never
|
|
4304
|
+
* blind-loads canvas.html on a hunch.
|
|
4305
|
+
*/
|
|
4306
|
+
/**
|
|
4307
|
+
* A structured response posted by the canvas page, on its way to the
|
|
4308
|
+
* agent. The CanvasFrame already proved provenance (the message came from
|
|
4309
|
+
* the live canvas iframe and nothing else); this is the rest of the
|
|
4310
|
+
* pipeline: validate against the CURRENT revision's contract (no contract
|
|
4311
|
+
* → accept nothing), rate-limit (the page is agent-authored JS speaking
|
|
4312
|
+
* as the viewer), then send it as a real user message through the
|
|
4313
|
+
* ordinary transport path — no outbox, no optimistic chat bubble. The
|
|
4314
|
+
* history pipeline recognizes the canonical prefix and keeps it out of
|
|
4315
|
+
* the portal chat pane; the transcript still records it for provenance.
|
|
4316
|
+
*/
|
|
4317
|
+
submitCanvasAction(sessionId, message) {
|
|
4318
|
+
if (!sessionId) return Promise.resolve({ ok: false, reason: "no session" });
|
|
4319
|
+
// Creator-only, mirrored from the server's enforcement (the server is
|
|
4320
|
+
// the authority; this is the friendly refusal). The canvas mutates —
|
|
4321
|
+
// a shared viewer may be looking at a different revision than the one
|
|
4322
|
+
// the creator is conversing through, so their clicks must not speak.
|
|
4323
|
+
const me = this.getState().auth?.principal || null;
|
|
4324
|
+
const ownerRow = this.getState().sessions.byId[sessionId]?.owner || null;
|
|
4325
|
+
if (me && ownerRow && (String(ownerRow.provider || "") !== String(me.provider || "")
|
|
4326
|
+
|| String(ownerRow.subject || "") !== String(me.subject || ""))) {
|
|
4327
|
+
return Promise.resolve({ ok: false, reason: "only the session's creator can use canvas controls" });
|
|
4328
|
+
}
|
|
4329
|
+
const contract = this.getState().canvas.bySessionId[sessionId]?.responseContract || null;
|
|
4330
|
+
const verdict = validateCanvasAction(contract, message);
|
|
4331
|
+
if (!verdict.ok) return Promise.resolve(verdict);
|
|
4332
|
+
if (!this.canvasActionLimiters) this.canvasActionLimiters = new Map();
|
|
4333
|
+
let limiter = this.canvasActionLimiters.get(sessionId);
|
|
4334
|
+
if (!limiter) {
|
|
4335
|
+
limiter = createCanvasActionLimiter();
|
|
4336
|
+
this.canvasActionLimiters.set(sessionId, limiter);
|
|
4337
|
+
}
|
|
4338
|
+
if (!limiter()) return Promise.resolve({ ok: false, reason: "rate limited" });
|
|
4339
|
+
const prompt = formatCanvasActionPrompt(verdict.action, verdict.data);
|
|
4340
|
+
return this.transport.sendMessage(sessionId, prompt, { enqueueOnly: true })
|
|
4341
|
+
.then(() => {
|
|
4342
|
+
this.syncSessionEvents(sessionId).catch(() => {});
|
|
4343
|
+
return { ok: true, action: verdict.action };
|
|
4344
|
+
})
|
|
4345
|
+
.catch((error) => ({ ok: false, reason: error?.message || String(error) }));
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
async ensureCanvasSnapshot(sessionId) {
|
|
4349
|
+
if (!sessionId) return;
|
|
4350
|
+
if (this.getState().canvas.bySessionId[sessionId]?.snapshotLoaded) return;
|
|
4351
|
+
if (typeof this.transport.getSessionEventsBefore !== "function") {
|
|
4352
|
+
// A transport without the filtered query (older direct mode) still
|
|
4353
|
+
// converges via live events; record the attempt so we do not spin.
|
|
4354
|
+
this.dispatch({ type: "canvas/snapshot", sessionId, rev: 0 });
|
|
4355
|
+
return;
|
|
4356
|
+
}
|
|
4357
|
+
try {
|
|
4358
|
+
// A 30-event window, not 1: five interleaved slots mean the top
|
|
4359
|
+
// event only describes the most recently drawn slot. Latest per
|
|
4360
|
+
// slot wins; every drawn slot gets its snapshot.
|
|
4361
|
+
const rows = await this.transport.getSessionEventsBefore(
|
|
4362
|
+
sessionId, Number.MAX_SAFE_INTEGER, 30, ["session.canvas_updated"],
|
|
4363
|
+
);
|
|
4364
|
+
const bySlot = new Map();
|
|
4365
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
4366
|
+
const slot = Number(row?.data?.slot) || 1;
|
|
4367
|
+
const rev = Number(row?.data?.rev) || 0;
|
|
4368
|
+
if (!bySlot.has(slot) || rev > (Number(bySlot.get(slot)?.data?.rev) || 0)) bySlot.set(slot, row);
|
|
4369
|
+
}
|
|
4370
|
+
// Always at least a slot-1 snapshot (rev 0 marks snapshotLoaded
|
|
4371
|
+
// so this does not respin).
|
|
4372
|
+
if (!bySlot.has(1)) bySlot.set(1, null);
|
|
4373
|
+
let anyDrawn = false;
|
|
4374
|
+
for (const [slot, top] of bySlot) {
|
|
4375
|
+
const rev = Number(top?.data?.rev) || 0;
|
|
4376
|
+
if (rev > 0) anyDrawn = true;
|
|
4377
|
+
this.dispatch({
|
|
4378
|
+
type: "canvas/snapshot",
|
|
4379
|
+
sessionId,
|
|
4380
|
+
slot,
|
|
4381
|
+
rev,
|
|
4382
|
+
...(typeof top?.data?.name === "string" ? { name: top.data.name } : {}),
|
|
4383
|
+
note: typeof top?.data?.note === "string" ? top.data.note : "",
|
|
4384
|
+
sizeBytes: top?.data?.sizeBytes,
|
|
4385
|
+
responseContract: top?.data?.responseContract,
|
|
4386
|
+
});
|
|
4387
|
+
}
|
|
4388
|
+
// The latest data tick per slot is that page's cold-load state —
|
|
4389
|
+
// replay it so a freshly loaded shell reconstructs without the
|
|
4390
|
+
// agent.
|
|
4391
|
+
if (anyDrawn) {
|
|
4392
|
+
const dataRows = await this.transport.getSessionEventsBefore(
|
|
4393
|
+
sessionId, Number.MAX_SAFE_INTEGER, 30, ["session.canvas_data"],
|
|
4394
|
+
).catch(() => []);
|
|
4395
|
+
const tickBySlot = new Map();
|
|
4396
|
+
for (const row of Array.isArray(dataRows) ? dataRows : []) {
|
|
4397
|
+
const slot = Number(row?.data?.slot) || 1;
|
|
4398
|
+
const dr = Number(row?.data?.dataRev) || 0;
|
|
4399
|
+
if (!tickBySlot.has(slot) || dr > (Number(tickBySlot.get(slot)?.data?.dataRev) || 0)) tickBySlot.set(slot, row);
|
|
4400
|
+
}
|
|
4401
|
+
for (const [slot, tick] of tickBySlot) {
|
|
4402
|
+
if (!tick?.data?.dataRev) continue;
|
|
4403
|
+
this.dispatch({
|
|
4404
|
+
type: "canvas/data",
|
|
4405
|
+
sessionId,
|
|
4406
|
+
slot,
|
|
4407
|
+
dataRev: Number(tick.data.dataRev),
|
|
4408
|
+
payload: tick.data.payload,
|
|
4409
|
+
note: typeof tick.data.note === "string" ? tick.data.note : "",
|
|
4410
|
+
});
|
|
4411
|
+
}
|
|
4412
|
+
}
|
|
4413
|
+
} catch {
|
|
4414
|
+
// Leave snapshotLoaded unset — the next selection retries.
|
|
4415
|
+
}
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4418
|
+
/**
|
|
4419
|
+
* An agent asked to PRESENT an already-drawn canvas — no bytes, no rev,
|
|
4420
|
+
* nothing marked unseen. Same guards as a draw's auto-flip: only the
|
|
4421
|
+
* active session, only a fresh event (a reconnect replay must not yank
|
|
4422
|
+
* the view), and the user's manual dismissal of that slot still wins.
|
|
4423
|
+
*/
|
|
4424
|
+
applyCanvasPresented(sessionId, event) {
|
|
4425
|
+
const rawSlot = Number(event?.data?.slot);
|
|
4426
|
+
const slot = Number.isInteger(rawSlot) && rawSlot >= 1 && rawSlot <= 5 ? rawSlot : 1;
|
|
4427
|
+
if (!sessionId) return false;
|
|
4428
|
+
const state = this.getState();
|
|
4429
|
+
if (sessionId !== state.sessions.activeSessionId) return false;
|
|
4430
|
+
if (state.canvas.prefs[canvasPrefKey(sessionId, slot)]?.optedOut) return false;
|
|
4431
|
+
const createdAt = event?.createdAt ? Date.parse(event.createdAt) : Number.NaN;
|
|
4432
|
+
if (Number.isFinite(createdAt) && Date.now() - createdAt > PRESENTED_ARTIFACT_FRESHNESS_MS) {
|
|
4433
|
+
return false;
|
|
4434
|
+
}
|
|
4435
|
+
this.dispatch({ type: "canvas/flip", sessionId, slot });
|
|
4436
|
+
return true;
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4232
4439
|
mergeSessionEvent(sessionId, event) {
|
|
4233
4440
|
if (!sessionId || !event) return false;
|
|
4234
4441
|
const state = this.getState();
|
|
@@ -4279,6 +4486,27 @@ export class PilotSwarmUiController {
|
|
|
4279
4486
|
if (event.eventType === "session.artifact_presented") {
|
|
4280
4487
|
this.maybeRevealPresentedArtifact(sessionId, event);
|
|
4281
4488
|
}
|
|
4489
|
+
// Canvas: CONTENT convergence is ungated — a stale canvas_updated in a
|
|
4490
|
+
// reconnect replay must still advance latestRev and refresh a mounted
|
|
4491
|
+
// pane. Only the FLIP is guarded (below). This deliberately diverges
|
|
4492
|
+
// from artifact_presented, which drops stale events outright: a
|
|
4493
|
+
// presentation is an action, a canvas is state.
|
|
4494
|
+
if (event.eventType === "session.canvas_updated") {
|
|
4495
|
+
this.applyCanvasUpdate(sessionId, event);
|
|
4496
|
+
}
|
|
4497
|
+
if (event.eventType === "session.canvas_presented") {
|
|
4498
|
+
this.applyCanvasPresented(sessionId, event);
|
|
4499
|
+
}
|
|
4500
|
+
if (event.eventType === "session.canvas_data") {
|
|
4501
|
+
this.dispatch({
|
|
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
|
+
});
|
|
4509
|
+
}
|
|
4282
4510
|
this.reconcileOutboxAgainstEvent(sessionId, event);
|
|
4283
4511
|
const derivedModel = extractSessionModelFromEvent(event);
|
|
4284
4512
|
const currentSession = this.getState().sessions.byId[sessionId] || { sessionId };
|
|
@@ -4453,6 +4681,15 @@ export class PilotSwarmUiController {
|
|
|
4453
4681
|
const eligible = this.getMovableGroupSessionSelection();
|
|
4454
4682
|
|
|
4455
4683
|
if (eligible.length === 0) {
|
|
4684
|
+
// With a GROUP active, the folder button means "this group":
|
|
4685
|
+
// rename it. (Creating from here always made a second group —
|
|
4686
|
+
// reported as a surprise on the phone.)
|
|
4687
|
+
const active = state.sessions.activeSessionId
|
|
4688
|
+
? state.sessions.byId[state.sessions.activeSessionId]
|
|
4689
|
+
: null;
|
|
4690
|
+
if (active?.isGroup && active.groupId) {
|
|
4691
|
+
return this.openRenameGroupModal(active);
|
|
4692
|
+
}
|
|
4456
4693
|
// Nothing selected is a legitimate intent: make an empty folder to
|
|
4457
4694
|
// drag sessions into, rather than scolding the user.
|
|
4458
4695
|
return this.openCreateEmptyGroupModal();
|
|
@@ -4521,6 +4758,32 @@ export class PilotSwarmUiController {
|
|
|
4521
4758
|
return this.openMoveToGroupModal();
|
|
4522
4759
|
}
|
|
4523
4760
|
|
|
4761
|
+
/** Rename the active group in place (same modal, rename mode). */
|
|
4762
|
+
openRenameGroupModal(group) {
|
|
4763
|
+
if (typeof this.transport.updateSessionGroup !== "function") {
|
|
4764
|
+
this.dispatch({ type: "ui/status", text: "Group rename is not supported by this transport" });
|
|
4765
|
+
return null;
|
|
4766
|
+
}
|
|
4767
|
+
const state = this.getState();
|
|
4768
|
+
const current = String(group.title || "").trim();
|
|
4769
|
+
this.dispatch({
|
|
4770
|
+
type: "ui/modal",
|
|
4771
|
+
modal: {
|
|
4772
|
+
type: "sessionGroupName",
|
|
4773
|
+
mode: "rename",
|
|
4774
|
+
groupId: group.groupId,
|
|
4775
|
+
title: "Rename Group",
|
|
4776
|
+
previousFocus: state.ui.focusRegion,
|
|
4777
|
+
sessionIds: [],
|
|
4778
|
+
value: current,
|
|
4779
|
+
cursorIndex: current.length,
|
|
4780
|
+
maxLength: 80,
|
|
4781
|
+
},
|
|
4782
|
+
});
|
|
4783
|
+
this.dispatch({ type: "ui/status", text: "Edit the group name and press Enter" });
|
|
4784
|
+
return null;
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4524
4787
|
/** Name-and-create an EMPTY group (no sessions), ready to drag into. */
|
|
4525
4788
|
async openCreateEmptyGroupModal() {
|
|
4526
4789
|
if (typeof this.transport.createSessionGroup !== "function") {
|
|
@@ -4672,6 +4935,19 @@ export class PilotSwarmUiController {
|
|
|
4672
4935
|
this.dispatch({ type: "ui/status", text: "Group name cannot be empty" });
|
|
4673
4936
|
return;
|
|
4674
4937
|
}
|
|
4938
|
+
if (modal.mode === "rename" && modal.groupId) {
|
|
4939
|
+
const previousFocusRename = modal.previousFocus;
|
|
4940
|
+
this.dispatch({ type: "ui/modal", modal: null });
|
|
4941
|
+
if (previousFocusRename) this.setFocus(previousFocusRename);
|
|
4942
|
+
try {
|
|
4943
|
+
await this.transport.updateSessionGroup(modal.groupId, { title });
|
|
4944
|
+
this.dispatch({ type: "ui/status", text: `Renamed group to "${title}"` });
|
|
4945
|
+
await this.refreshSessions().catch(() => {});
|
|
4946
|
+
} catch (error) {
|
|
4947
|
+
this.dispatch({ type: "ui/status", text: `Rename failed: ${error?.message || String(error)}` });
|
|
4948
|
+
}
|
|
4949
|
+
return;
|
|
4950
|
+
}
|
|
4675
4951
|
if (typeof this.transport.createSessionGroup !== "function") {
|
|
4676
4952
|
this.dispatch({ type: "ui/status", text: "Session grouping is not supported by this transport" });
|
|
4677
4953
|
return;
|
|
@@ -4728,22 +5004,7 @@ export class PilotSwarmUiController {
|
|
|
4728
5004
|
return sessionId ? state.sessions.byId[sessionId] || null : null;
|
|
4729
5005
|
}
|
|
4730
5006
|
|
|
4731
|
-
toggleChatViewMode() {
|
|
4732
|
-
const activeSession = this.getActiveSession();
|
|
4733
|
-
if (activeSession?.isGroup) {
|
|
4734
|
-
this.dispatch({ type: "ui/status", text: "Session groups show group details" });
|
|
4735
|
-
return;
|
|
4736
|
-
}
|
|
4737
|
-
const nextMode = this.getState().ui?.chatViewMode === "summary" ? "transcript" : "summary";
|
|
4738
|
-
this.dispatch({ type: "ui/chatViewMode", mode: nextMode });
|
|
4739
|
-
this.dispatch({ type: "ui/status", text: nextMode === "summary" ? "Showing session summary" : "Showing chat transcript" });
|
|
4740
|
-
}
|
|
4741
5007
|
|
|
4742
|
-
setChatViewMode(mode) {
|
|
4743
|
-
if (mode !== "summary" && mode !== "transcript") return;
|
|
4744
|
-
if (this.getActiveSession()?.isGroup) return;
|
|
4745
|
-
this.dispatch({ type: "ui/chatViewMode", mode });
|
|
4746
|
-
}
|
|
4747
5008
|
|
|
4748
5009
|
/**
|
|
4749
5010
|
* Opens the agent step of the new-session flow.
|
|
@@ -7257,7 +7518,15 @@ export class PilotSwarmUiController {
|
|
|
7257
7518
|
|
|
7258
7519
|
applyPaneVisualScrollOffset(pane, offset, options = {}, state = this.getState()) {
|
|
7259
7520
|
const maxOffset = this.getPaneMaxScrollOffset(pane, state);
|
|
7260
|
-
|
|
7521
|
+
// fromViewport: the offset was measured off the real DOM (scrollTop /
|
|
7522
|
+
// row height), which the browser has already bounded. Clamping it
|
|
7523
|
+
// against the CONTROLLER's max — counted in TUI render-metric rows, a
|
|
7524
|
+
// different unit — teleported the pane on the first wheel tick: one
|
|
7525
|
+
// 100px notch re-applied as metric-max × 16px, thousands of pixels
|
|
7526
|
+
// away. The two row spaces must never clamp each other.
|
|
7527
|
+
const nextOffset = options.fromViewport
|
|
7528
|
+
? Math.max(0, Number(offset) || 0)
|
|
7529
|
+
: Math.max(0, Math.min(Number(offset) || 0, maxOffset));
|
|
7261
7530
|
if (this.paneUsesStickyBottomFollow(pane, state)) {
|
|
7262
7531
|
const followBottom = options.followBottom !== undefined
|
|
7263
7532
|
? Boolean(options.followBottom)
|
|
@@ -7276,7 +7545,7 @@ export class PilotSwarmUiController {
|
|
|
7276
7545
|
updatePaneScrollFromViewport(pane, offset, options = {}) {
|
|
7277
7546
|
const state = this.getState();
|
|
7278
7547
|
if (this.paneUsesStickyBottomFollow(pane, state)) {
|
|
7279
|
-
this.applyPaneVisualScrollOffset(pane, offset, options, state);
|
|
7548
|
+
this.applyPaneVisualScrollOffset(pane, offset, { ...options, fromViewport: true }, state);
|
|
7280
7549
|
return;
|
|
7281
7550
|
}
|
|
7282
7551
|
this.scrollPaneTo(pane, offset);
|
|
@@ -7829,7 +8098,9 @@ export class PilotSwarmUiController {
|
|
|
7829
8098
|
pane: "chat",
|
|
7830
8099
|
offset: 0,
|
|
7831
8100
|
});
|
|
7832
|
-
} else if (preserveChatView && previousScrollOffset > 0) {
|
|
8101
|
+
} else if (preserveChatView && previousScrollOffset > 0 && !options.autoTriggered) {
|
|
8102
|
+
// EXPLICIT "load older" (the TUI's `e`): jump up to the start
|
|
8103
|
+
// of what was just fetched — the user asked to SEE it.
|
|
7833
8104
|
const nextState = this.getState();
|
|
7834
8105
|
const nextRenderedLines = this.getActiveChatRenderMetrics(nextState).totalLines;
|
|
7835
8106
|
const addedLines = Math.max(0, nextRenderedLines - previousRenderedLines);
|
|
@@ -7841,6 +8112,13 @@ export class PilotSwarmUiController {
|
|
|
7841
8112
|
});
|
|
7842
8113
|
}
|
|
7843
8114
|
}
|
|
8115
|
+
// AUTO-triggered (scrolling reached the top): change NOTHING. The
|
|
8116
|
+
// offset is distance-from-BOTTOM, which prepended content cannot
|
|
8117
|
+
// move — leaving it alone keeps the viewport glued to the exact
|
|
8118
|
+
// messages the user was reading, and they scroll on into the new
|
|
8119
|
+
// page naturally. Adding the delta here teleported the view to
|
|
8120
|
+
// the TOP of a 3.3x-larger window — observed as "loading history
|
|
8121
|
+
// takes you way back instead of a few pages up".
|
|
7844
8122
|
|
|
7845
8123
|
const stateLabel = history.hasOlderEvents
|
|
7846
8124
|
? options.autoTriggered
|
|
@@ -8523,9 +8801,6 @@ export class PilotSwarmUiController {
|
|
|
8523
8801
|
case UI_COMMANDS.OPEN_ARTIFACT_UPLOAD:
|
|
8524
8802
|
this.openArtifactUploadModal();
|
|
8525
8803
|
return;
|
|
8526
|
-
case UI_COMMANDS.TOGGLE_CHAT_VIEW:
|
|
8527
|
-
this.toggleChatViewMode();
|
|
8528
|
-
return;
|
|
8529
8804
|
case UI_COMMANDS.CLOSE_MODAL:
|
|
8530
8805
|
this.closeModal();
|
|
8531
8806
|
return;
|