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.
Files changed (86) hide show
  1. package/package.json +3 -3
  2. package/tui/src/app.js +0 -4
  3. package/tui/src/index.js +0 -9
  4. package/ui/core/src/canvas-actions.js +148 -0
  5. package/ui/core/src/commands.js +0 -1
  6. package/ui/core/src/controller.js +296 -21
  7. package/ui/core/src/history.js +134 -1
  8. package/ui/core/src/index.js +1 -0
  9. package/ui/core/src/reducer.js +407 -19
  10. package/ui/core/src/selectors.js +223 -154
  11. package/ui/core/src/state.js +139 -2
  12. package/ui/core/src/themes/index.js +1 -1
  13. package/ui/react/src/components.js +1 -7
  14. package/ui/react/src/web-app.js +1719 -366
  15. package/web/auth/config.js +3 -0
  16. package/web/auth/index.js +8 -1
  17. package/web/auth/normalize/proxy.js +48 -0
  18. package/web/auth/providers/proxy.js +141 -0
  19. package/web/dist/assets/{abnfDiagram-VRR7QNED-LOLCtsns.js → abnfDiagram-VRR7QNED-DrJhXhSQ.js} +1 -1
  20. package/web/dist/assets/{arc-BE-jVDEi.js → arc-BEaG9DWd.js} +1 -1
  21. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CdmUjwtj.js → architectureDiagram-ZJ3FMSHR-D9fc1Imx.js} +1 -1
  22. package/web/dist/assets/{blockDiagram-677ZJIJ3-NZ4TOa8L.js → blockDiagram-677ZJIJ3-rS5Eikvt.js} +1 -1
  23. package/web/dist/assets/{c4Diagram-LMCZKHZV-9ubX8uGA.js → c4Diagram-LMCZKHZV-HKLlX5cR.js} +1 -1
  24. package/web/dist/assets/channel-DRy7FvyF.js +1 -0
  25. package/web/dist/assets/{chunk-2Q5K7J3B-DNDnjbP6.js → chunk-2Q5K7J3B-kq0TyLSB.js} +1 -1
  26. package/web/dist/assets/{chunk-32BRIVSS-iilfiffr.js → chunk-32BRIVSS-O-LMarT5.js} +1 -1
  27. package/web/dist/assets/{chunk-5VM5RSS4-BakOMFmf.js → chunk-5VM5RSS4-Di4h0CAu.js} +1 -1
  28. package/web/dist/assets/{chunk-EX3LRPZG-BhluhGyZ.js → chunk-EX3LRPZG-C8rgqUOt.js} +1 -1
  29. package/web/dist/assets/{chunk-JWPE2WC7-B6GKLlNb.js → chunk-JWPE2WC7-BSH41q4T.js} +1 -1
  30. package/web/dist/assets/{chunk-MOJQB5TN-CXmt8msF.js → chunk-MOJQB5TN-BAFuQHAC.js} +1 -1
  31. package/web/dist/assets/{chunk-RYQCIY6F-Bu58-SDr.js → chunk-RYQCIY6F-DAg6JOs7.js} +1 -1
  32. package/web/dist/assets/{chunk-V7JOEXUC-AQdyoYHL.js → chunk-V7JOEXUC-DbVrT8BG.js} +1 -1
  33. package/web/dist/assets/{chunk-VR4S4FIN-DeM5iMsE.js → chunk-VR4S4FIN-C6f5_Grp.js} +1 -1
  34. package/web/dist/assets/{chunk-XXDRQBXY-DjGjajy1.js → chunk-XXDRQBXY-CqcCYpqA.js} +1 -1
  35. package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +1 -0
  36. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +1 -0
  37. package/web/dist/assets/{cose-bilkent-JH36ORCC-BGi4_egJ.js → cose-bilkent-JH36ORCC-DJw04eFh.js} +1 -1
  38. package/web/dist/assets/{cynefin-VYW2F7L2-CcgV1ve4.js → cynefin-VYW2F7L2-CpjwdEIU.js} +1 -1
  39. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BRDEsbBG.js → cynefinDiagram-TSTJHNR4-RqEIWoT0.js} +1 -1
  40. package/web/dist/assets/{dagre-VKFMJZFB-OjuC8dJB.js → dagre-VKFMJZFB-C1eGQNK2.js} +1 -1
  41. package/web/dist/assets/{diagram-FQU43EPY-CGdROWYh.js → diagram-FQU43EPY-BODLQ7dz.js} +1 -1
  42. package/web/dist/assets/{diagram-G47NLZAW-B5J0EUfS.js → diagram-G47NLZAW-DlhmukMC.js} +1 -1
  43. package/web/dist/assets/{diagram-NH7WQ7WH-zmb4-cP2.js → diagram-NH7WQ7WH-Ba_jZoCN.js} +1 -1
  44. package/web/dist/assets/{diagram-OA4YK3LP-j514bgaH.js → diagram-OA4YK3LP-DnSiAJBG.js} +1 -1
  45. package/web/dist/assets/{diagram-WEI45ONY-h5G459mZ.js → diagram-WEI45ONY-B3KoQ7_W.js} +1 -1
  46. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-GnMgbuFk.js → ebnfDiagram-CCIWWBDH-ChnFc5gf.js} +1 -1
  47. package/web/dist/assets/{erDiagram-Q63AITRT-BFgH3UQp.js → erDiagram-Q63AITRT-D2KkJXLZ.js} +1 -1
  48. package/web/dist/assets/{flowDiagram-23GEKE2U-BsbQ_VXV.js → flowDiagram-23GEKE2U-CmybZufj.js} +1 -1
  49. package/web/dist/assets/{ganttDiagram-NO4QXBWP-C_fjo2sf.js → ganttDiagram-NO4QXBWP-BzPurW_v.js} +1 -1
  50. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BUoXrAI9.js → gitGraphDiagram-IHSO6WYX-DAbXl4gO.js} +1 -1
  51. package/web/dist/assets/{index-ONJimzcx.js → index-CRY94nFp.js} +6 -6
  52. package/web/dist/assets/index-DtL0lbis.css +1 -0
  53. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CJiSANyk.js → infoDiagram-FWYZ7A6U-C6qGjNA1.js} +1 -1
  54. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-BKoQ-a2c.js → ishikawaDiagram-FXEZZL3T-D2d1tO-9.js} +1 -1
  55. package/web/dist/assets/{journeyDiagram-5HDEW3XC-Db6Crcd3.js → journeyDiagram-5HDEW3XC-fP13w1b7.js} +1 -1
  56. package/web/dist/assets/{kanban-definition-HUTT4EX6-B9GaB0UJ.js → kanban-definition-HUTT4EX6-BJfWAXZ0.js} +1 -1
  57. package/web/dist/assets/{linear-BXwRb8MN.js → linear-CltApkHf.js} +1 -1
  58. package/web/dist/assets/{mermaid.core-DwzXN6up.js → mermaid.core-B1Dwetk3.js} +4 -4
  59. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DKC_C_OY.js → mindmap-definition-LN4V7U3C-CdRUD7LF.js} +1 -1
  60. package/web/dist/assets/{papaparse.min-Cm1Pr54l.js → papaparse.min-C1y3wBH1.js} +1 -1
  61. package/web/dist/assets/{pegDiagram-2B236MQR-IeawbZPt.js → pegDiagram-2B236MQR-CeQkqN6Q.js} +1 -1
  62. package/web/dist/assets/{pieDiagram-ENE6RG2P-BOdsYBt8.js → pieDiagram-ENE6RG2P-Dqgc87jA.js} +1 -1
  63. package/web/dist/assets/pilotswarm-DayYdi39.js +96 -0
  64. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cr5yoN7C.js → quadrantDiagram-ABIIQ3AL-CD7f-w6m.js} +1 -1
  65. package/web/dist/assets/{railroadDiagram-RFXS5EU6-E7wuFBuk.js → railroadDiagram-RFXS5EU6-CcsAijSC.js} +1 -1
  66. package/web/dist/assets/{requirementDiagram-TGXJPOKE-sZCKiuDs.js → requirementDiagram-TGXJPOKE-BkzF80pL.js} +1 -1
  67. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_EWziUR.js → sankeyDiagram-HTMAVEWB-D9XOFw7Y.js} +1 -1
  68. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D2mN7onX.js → sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js} +1 -1
  69. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CEL9Ly2T.js → sizeCapture-X5ZJPWSS-CDJV16tP.js} +1 -1
  70. package/web/dist/assets/{stateDiagram-2N3HPSRC-C7ejJoeN.js → stateDiagram-2N3HPSRC-C0E-MPXO.js} +1 -1
  71. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +1 -0
  72. package/web/dist/assets/{swimlanes-5IMT3BWC-Ck3pbFxk.js → swimlanes-5IMT3BWC-n5Vsv7n9.js} +2 -2
  73. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +8 -0
  74. package/web/dist/assets/{timeline-definition-FHXFAJF6-K3H8Yuyr.js → timeline-definition-FHXFAJF6-DW2gV8-n.js} +1 -1
  75. package/web/dist/assets/{vennDiagram-L72KCM5P-C3NKjSv9.js → vennDiagram-L72KCM5P-D9dbDm08.js} +1 -1
  76. package/web/dist/assets/{wardleyDiagram-EHGQE667-CeeVuFiO.js → wardleyDiagram-EHGQE667-CIcG7fiC.js} +1 -1
  77. package/web/dist/assets/{xychartDiagram-FW5EYKEG-CF-vCvEq.js → xychartDiagram-FW5EYKEG-elEIFsc9.js} +1 -1
  78. package/web/dist/index.html +4 -4
  79. package/web/runtime.js +20 -1
  80. package/web/dist/assets/channel-BhHZnKdH.js +0 -1
  81. package/web/dist/assets/classDiagram-OUVF2IWQ-CM_bKBKz.js +0 -1
  82. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CM_bKBKz.js +0 -1
  83. package/web/dist/assets/index-CnH1Yv_d.css +0 -1
  84. package/web/dist/assets/pilotswarm-kCQFVjJm.js +0 -99
  85. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-UaVDt_gU.js +0 -1
  86. 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.36",
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.36",
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": "^7.2.0"
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
+ }
@@ -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
- const nextOffset = Math.max(0, Math.min(Number(offset) || 0, maxOffset));
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;