pilotswarm 0.5.35 → 0.5.37

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 (83) hide show
  1. package/package.json +2 -2
  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 +217 -19
  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 +209 -19
  10. package/ui/core/src/selectors.js +143 -154
  11. package/ui/core/src/state.js +51 -2
  12. package/ui/core/src/themes/duroxide.js +6 -1
  13. package/ui/core/src/themes/win95.js +4 -1
  14. package/ui/react/src/components.js +1 -7
  15. package/ui/react/src/web-app.js +853 -260
  16. package/web/dist/assets/{abnfDiagram-VRR7QNED-BwJEohLG.js → abnfDiagram-VRR7QNED-DlvPH-8O.js} +1 -1
  17. package/web/dist/assets/{arc-CWUJ_S5s.js → arc-r1liz5e5.js} +1 -1
  18. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-i6mrLxuE.js → architectureDiagram-ZJ3FMSHR-Bci5dNH3.js} +1 -1
  19. package/web/dist/assets/{blockDiagram-677ZJIJ3-C_KG-qPQ.js → blockDiagram-677ZJIJ3-Bug32AXp.js} +1 -1
  20. package/web/dist/assets/{c4Diagram-LMCZKHZV-BBJH7hbB.js → c4Diagram-LMCZKHZV-DwxSFqgE.js} +1 -1
  21. package/web/dist/assets/channel-jVpSYat3.js +1 -0
  22. package/web/dist/assets/{chunk-2Q5K7J3B-CXwaFcaM.js → chunk-2Q5K7J3B-B_dbI_xg.js} +1 -1
  23. package/web/dist/assets/{chunk-32BRIVSS-C8ujjkK-.js → chunk-32BRIVSS-BkF68W-R.js} +1 -1
  24. package/web/dist/assets/{chunk-5VM5RSS4-M9rz7YYk.js → chunk-5VM5RSS4-BSbNIbJu.js} +1 -1
  25. package/web/dist/assets/{chunk-EX3LRPZG-bk6tRxOA.js → chunk-EX3LRPZG-Blv_Iqa2.js} +1 -1
  26. package/web/dist/assets/{chunk-JWPE2WC7-DZ4WSHGH.js → chunk-JWPE2WC7-Dk_dBFKd.js} +1 -1
  27. package/web/dist/assets/{chunk-MOJQB5TN-B5-y6tMF.js → chunk-MOJQB5TN-DqNFBrfw.js} +1 -1
  28. package/web/dist/assets/{chunk-RYQCIY6F-yKcnHxm0.js → chunk-RYQCIY6F-Dlv7Svpb.js} +1 -1
  29. package/web/dist/assets/{chunk-V7JOEXUC-CpoNcATD.js → chunk-V7JOEXUC-DozGikwu.js} +1 -1
  30. package/web/dist/assets/{chunk-VR4S4FIN-Bf1yD2fn.js → chunk-VR4S4FIN-CmkqvGT4.js} +1 -1
  31. package/web/dist/assets/{chunk-XXDRQBXY-EnPvDVhP.js → chunk-XXDRQBXY-DyByai5N.js} +1 -1
  32. package/web/dist/assets/classDiagram-OUVF2IWQ-C78zSajE.js +1 -0
  33. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C78zSajE.js +1 -0
  34. package/web/dist/assets/{cose-bilkent-JH36ORCC-qhKoA2qj.js → cose-bilkent-JH36ORCC-Bv-gRVvA.js} +1 -1
  35. package/web/dist/assets/{cynefin-VYW2F7L2-jcyYelJS.js → cynefin-VYW2F7L2-DRnAsyZU.js} +1 -1
  36. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-D4RLufLP.js → cynefinDiagram-TSTJHNR4-B1fnUtjo.js} +1 -1
  37. package/web/dist/assets/{dagre-VKFMJZFB-C9BA3GTO.js → dagre-VKFMJZFB-DM6-QENB.js} +1 -1
  38. package/web/dist/assets/{diagram-FQU43EPY-DbSSrVUJ.js → diagram-FQU43EPY-BPxxnLdU.js} +1 -1
  39. package/web/dist/assets/{diagram-G47NLZAW-3egISQXQ.js → diagram-G47NLZAW-BgtJT3qF.js} +1 -1
  40. package/web/dist/assets/{diagram-NH7WQ7WH-8IKEMGRg.js → diagram-NH7WQ7WH-uekeIzsy.js} +1 -1
  41. package/web/dist/assets/{diagram-OA4YK3LP-VnaogqtK.js → diagram-OA4YK3LP-BVy3JZWE.js} +1 -1
  42. package/web/dist/assets/{diagram-WEI45ONY-DxigXH0T.js → diagram-WEI45ONY-BWdjUr32.js} +1 -1
  43. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-BSkNwJ9x.js → ebnfDiagram-CCIWWBDH-DD4fwhNK.js} +1 -1
  44. package/web/dist/assets/{erDiagram-Q63AITRT-CKN8JXl6.js → erDiagram-Q63AITRT-BllD_6Kh.js} +1 -1
  45. package/web/dist/assets/{flowDiagram-23GEKE2U-DDaD5ExP.js → flowDiagram-23GEKE2U-QEYKkAUN.js} +1 -1
  46. package/web/dist/assets/{ganttDiagram-NO4QXBWP-tOrBOuoI.js → ganttDiagram-NO4QXBWP-uZcfKTSW.js} +1 -1
  47. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Cdp867ON.js → gitGraphDiagram-IHSO6WYX-XPdbPaH-.js} +1 -1
  48. package/web/dist/assets/index-BuDSoRIL.css +1 -0
  49. package/web/dist/assets/{index-BmwU43_u.js → index-D1itTxvB.js} +4 -4
  50. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DwcKHD2h.js → infoDiagram-FWYZ7A6U-DXCnQOJs.js} +1 -1
  51. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CWffK5kz.js → ishikawaDiagram-FXEZZL3T-687_4FBy.js} +1 -1
  52. package/web/dist/assets/{journeyDiagram-5HDEW3XC-BEAgS-EZ.js → journeyDiagram-5HDEW3XC-GXlKuda8.js} +1 -1
  53. package/web/dist/assets/{kanban-definition-HUTT4EX6-xDm-mRRj.js → kanban-definition-HUTT4EX6-C38-2DvN.js} +1 -1
  54. package/web/dist/assets/{linear-oP9MA0as.js → linear-lGPXiBcz.js} +1 -1
  55. package/web/dist/assets/{mermaid.core-DgXXt1OS.js → mermaid.core-C_neEtab.js} +4 -4
  56. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BBQgVrYC.js → mindmap-definition-LN4V7U3C-BaJ9tpEG.js} +1 -1
  57. package/web/dist/assets/{papaparse.min-CR6JmBWm.js → papaparse.min-rDwKYbBf.js} +1 -1
  58. package/web/dist/assets/{pegDiagram-2B236MQR-C2O0cf3j.js → pegDiagram-2B236MQR-BdXJLYgY.js} +1 -1
  59. package/web/dist/assets/{pieDiagram-ENE6RG2P-D08pUtYU.js → pieDiagram-ENE6RG2P-DU19t6fM.js} +1 -1
  60. package/web/dist/assets/pilotswarm-BNKGG936.js +96 -0
  61. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js → quadrantDiagram-ABIIQ3AL-CBkOdPp0.js} +1 -1
  62. package/web/dist/assets/{railroadDiagram-RFXS5EU6-ClkdveLI.js → railroadDiagram-RFXS5EU6-D2DMcGDw.js} +1 -1
  63. package/web/dist/assets/{requirementDiagram-TGXJPOKE-IY4xbn-Q.js → requirementDiagram-TGXJPOKE-Bx9tF1pr.js} +1 -1
  64. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Cpt5i7KM.js → sankeyDiagram-HTMAVEWB-DcLlRfsi.js} +1 -1
  65. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-c07lALcz.js → sequenceDiagram-DBY2YBRQ-xr6UVRBO.js} +1 -1
  66. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CmCDiNbI.js → sizeCapture-X5ZJPWSS-0fUkSXmg.js} +1 -1
  67. package/web/dist/assets/{stateDiagram-2N3HPSRC-Dueu17zX.js → stateDiagram-2N3HPSRC-BWgJGGpg.js} +1 -1
  68. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DmAC8DdB.js +1 -0
  69. package/web/dist/assets/{swimlanes-5IMT3BWC-Csc2Fup8.js → swimlanes-5IMT3BWC-CZKya4_i.js} +2 -2
  70. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CVYx20_X.js +8 -0
  71. package/web/dist/assets/{timeline-definition-FHXFAJF6-4-lf702e.js → timeline-definition-FHXFAJF6-Df8BpeAu.js} +1 -1
  72. package/web/dist/assets/{vennDiagram-L72KCM5P-CmlaKpLI.js → vennDiagram-L72KCM5P-ChX5bjT6.js} +1 -1
  73. package/web/dist/assets/{wardleyDiagram-EHGQE667-B9Qe44pi.js → wardleyDiagram-EHGQE667-Dr48BgaJ.js} +1 -1
  74. package/web/dist/assets/{xychartDiagram-FW5EYKEG-BFcc0CLn.js → xychartDiagram-FW5EYKEG-Cia_MFfc.js} +1 -1
  75. package/web/dist/index.html +4 -4
  76. package/web/runtime.js +20 -1
  77. package/web/dist/assets/channel-5Ddqi9ib.js +0 -1
  78. package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +0 -1
  79. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +0 -1
  80. package/web/dist/assets/index-D-nA7gUe.css +0 -1
  81. package/web/dist/assets/pilotswarm-D7NLCbuc.js +0 -99
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +0 -1
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.35",
3
+ "version": "0.5.37",
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,7 +81,7 @@
81
81
  "ink": "^6.8.0",
82
82
  "jose": "^6.2.2",
83
83
  "mermaid": "^11.16.0",
84
- "pilotswarm-sdk": "0.5.35",
84
+ "pilotswarm-sdk": "0.5.37",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
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",
@@ -10,6 +10,7 @@ import {
10
10
  getNextHistoryEventLimit,
11
11
  } from "./history.js";
12
12
  import { applySessionUsageEvent, cloneContextUsageSnapshot } from "./context-usage.js";
13
+ import { validateCanvasAction, formatCanvasActionPrompt, createCanvasActionLimiter } from "./canvas-actions.js";
13
14
  import { shouldKeepRecoverableTransportWarning } from "./session-errors.js";
14
15
  import {
15
16
  computeLegacyLayout,
@@ -2444,6 +2445,11 @@ export class PilotSwarmUiController {
2444
2445
  if (!existingHistory?.events?.length) {
2445
2446
  await this.ensureSessionHistory(selected, { force: true }).catch(() => {});
2446
2447
  }
2448
+ // Boot-restore can hydrate the selection through THIS branch
2449
+ // instead of loadSession (whether the profile poll beats the first
2450
+ // sessions refresh is a race), so the canvas snapshot must be
2451
+ // armed here too. Memoized — refreshes after the first are free.
2452
+ this.ensureCanvasSnapshot(selected).catch(() => {});
2447
2453
  if (this.activeSessionSubscriptionId !== selected) {
2448
2454
  this.attachActiveSession(selected);
2449
2455
  }
@@ -4163,6 +4169,13 @@ export class PilotSwarmUiController {
4163
4169
  await this.syncSessionDetail(sessionId).catch(() => {});
4164
4170
  this.attachActiveSession(sessionId);
4165
4171
  this.ensureInspectorData().catch(() => {});
4172
+ // Canvas snapshot rides the selection burst. Invalidate-then-fetch on
4173
+ // every selection, deliberately: the live event stream only covers the
4174
+ // ATTACHED session, so a session's canvas knowledge goes stale the
4175
+ // moment you switch away — re-selection is the continuity break, and
4176
+ // one indexed limit-1 row is noise against the burst above.
4177
+ this.dispatch({ type: "canvas/snapshotInvalidate", sessionId });
4178
+ this.ensureCanvasSnapshot(sessionId).catch(() => {});
4166
4179
  }
4167
4180
 
4168
4181
  /**
@@ -4229,6 +4242,135 @@ export class PilotSwarmUiController {
4229
4242
  return true;
4230
4243
  }
4231
4244
 
4245
+ /**
4246
+ * Apply a live `session.canvas_updated` event: converge content always,
4247
+ * flip the right column only when it is unmistakably appropriate.
4248
+ *
4249
+ * Flip guards (the artifact-presented set, plus the user's own choices):
4250
+ * active session only, fresh only, not already in canvas mode, and never
4251
+ * after the user has manually toggled away this session — their explicit
4252
+ * choice downgrades flips to the unseen-changes badge.
4253
+ */
4254
+ applyCanvasUpdate(sessionId, event) {
4255
+ const rev = Number(event?.data?.rev);
4256
+ if (!sessionId || !Number.isFinite(rev) || rev <= 0) return false;
4257
+ this.dispatch({
4258
+ type: "canvas/updated",
4259
+ sessionId,
4260
+ rev,
4261
+ note: typeof event?.data?.note === "string" ? event.data.note : "",
4262
+ sizeBytes: event?.data?.sizeBytes,
4263
+ responseContract: event?.data?.responseContract,
4264
+ });
4265
+
4266
+ const state = this.getState();
4267
+ if (sessionId !== state.sessions.activeSessionId) return true;
4268
+ if (state.canvas.prefs[sessionId]?.optedOut) return true;
4269
+ const createdAt = event?.createdAt ? Date.parse(event.createdAt) : Number.NaN;
4270
+ if (Number.isFinite(createdAt) && Date.now() - createdAt > PRESENTED_ARTIFACT_FRESHNESS_MS) {
4271
+ return true;
4272
+ }
4273
+ // canvas/flip rather than a plain mode change: it ticks flipSeq even
4274
+ // when the mode is already "canvas", because a phone can be off its
4275
+ // canvas tab while the mode state still says canvas — the tick is
4276
+ // what brings the tab back. On desktop the repeat dispatch is a no-op.
4277
+ this.dispatch({ type: "canvas/flip", sessionId });
4278
+ return true;
4279
+ }
4280
+
4281
+ /**
4282
+ * The cold-load snapshot: one indexed lookup against the full durable log,
4283
+ * fired per session as part of the selection burst and memoized. Its
4284
+ * result is definitive both ways — an event means a canvas exists at that
4285
+ * rev; nothing means none has ever been drawn. The client never
4286
+ * blind-loads canvas.html on a hunch.
4287
+ */
4288
+ /**
4289
+ * A structured response posted by the canvas page, on its way to the
4290
+ * agent. The CanvasFrame already proved provenance (the message came from
4291
+ * the live canvas iframe and nothing else); this is the rest of the
4292
+ * pipeline: validate against the CURRENT revision's contract (no contract
4293
+ * → accept nothing), rate-limit (the page is agent-authored JS speaking
4294
+ * as the viewer), then send it as a real user message through the
4295
+ * ordinary transport path — no outbox, no optimistic chat bubble. The
4296
+ * history pipeline recognizes the canonical prefix and keeps it out of
4297
+ * the portal chat pane; the transcript still records it for provenance.
4298
+ */
4299
+ submitCanvasAction(sessionId, message) {
4300
+ if (!sessionId) return Promise.resolve({ ok: false, reason: "no session" });
4301
+ // Creator-only, mirrored from the server's enforcement (the server is
4302
+ // the authority; this is the friendly refusal). The canvas mutates —
4303
+ // a shared viewer may be looking at a different revision than the one
4304
+ // the creator is conversing through, so their clicks must not speak.
4305
+ const me = this.getState().auth?.principal || null;
4306
+ const ownerRow = this.getState().sessions.byId[sessionId]?.owner || null;
4307
+ if (me && ownerRow && (String(ownerRow.provider || "") !== String(me.provider || "")
4308
+ || String(ownerRow.subject || "") !== String(me.subject || ""))) {
4309
+ return Promise.resolve({ ok: false, reason: "only the session's creator can use canvas controls" });
4310
+ }
4311
+ const contract = this.getState().canvas.bySessionId[sessionId]?.responseContract || null;
4312
+ const verdict = validateCanvasAction(contract, message);
4313
+ if (!verdict.ok) return Promise.resolve(verdict);
4314
+ if (!this.canvasActionLimiters) this.canvasActionLimiters = new Map();
4315
+ let limiter = this.canvasActionLimiters.get(sessionId);
4316
+ if (!limiter) {
4317
+ limiter = createCanvasActionLimiter();
4318
+ this.canvasActionLimiters.set(sessionId, limiter);
4319
+ }
4320
+ if (!limiter()) return Promise.resolve({ ok: false, reason: "rate limited" });
4321
+ const prompt = formatCanvasActionPrompt(verdict.action, verdict.data);
4322
+ return this.transport.sendMessage(sessionId, prompt, { enqueueOnly: true })
4323
+ .then(() => {
4324
+ this.syncSessionEvents(sessionId).catch(() => {});
4325
+ return { ok: true, action: verdict.action };
4326
+ })
4327
+ .catch((error) => ({ ok: false, reason: error?.message || String(error) }));
4328
+ }
4329
+
4330
+ async ensureCanvasSnapshot(sessionId) {
4331
+ if (!sessionId) return;
4332
+ if (this.getState().canvas.bySessionId[sessionId]?.snapshotLoaded) return;
4333
+ if (typeof this.transport.getSessionEventsBefore !== "function") {
4334
+ // A transport without the filtered query (older direct mode) still
4335
+ // converges via live events; record the attempt so we do not spin.
4336
+ this.dispatch({ type: "canvas/snapshot", sessionId, rev: 0 });
4337
+ return;
4338
+ }
4339
+ try {
4340
+ const rows = await this.transport.getSessionEventsBefore(
4341
+ sessionId, Number.MAX_SAFE_INTEGER, 1, ["session.canvas_updated"],
4342
+ );
4343
+ const top = Array.isArray(rows) ? rows[0] : null;
4344
+ this.dispatch({
4345
+ type: "canvas/snapshot",
4346
+ sessionId,
4347
+ rev: Number(top?.data?.rev) || 0,
4348
+ note: typeof top?.data?.note === "string" ? top.data.note : "",
4349
+ sizeBytes: top?.data?.sizeBytes,
4350
+ responseContract: top?.data?.responseContract,
4351
+ });
4352
+ // The latest data tick is the page's cold-load state — replay it
4353
+ // so a freshly loaded shell can reconstruct without the agent.
4354
+ if ((Number(top?.data?.rev) || 0) > 0) {
4355
+ const dataRows = await this.transport.getSessionEventsBefore(
4356
+ sessionId, Number.MAX_SAFE_INTEGER, 1, ["session.canvas_data"],
4357
+ ).catch(() => []);
4358
+ const tick = Array.isArray(dataRows) ? dataRows[0] : null;
4359
+ if (tick?.data?.dataRev) {
4360
+ this.dispatch({
4361
+ type: "canvas/data",
4362
+ sessionId,
4363
+ dataRev: Number(tick.data.dataRev),
4364
+ payload: tick.data.payload,
4365
+ note: typeof tick.data.note === "string" ? tick.data.note : "",
4366
+ });
4367
+ }
4368
+ }
4369
+ } catch {
4370
+ // Leave snapshotLoaded unset — the next selection retries.
4371
+ }
4372
+ }
4373
+
4232
4374
  mergeSessionEvent(sessionId, event) {
4233
4375
  if (!sessionId || !event) return false;
4234
4376
  const state = this.getState();
@@ -4279,6 +4421,23 @@ export class PilotSwarmUiController {
4279
4421
  if (event.eventType === "session.artifact_presented") {
4280
4422
  this.maybeRevealPresentedArtifact(sessionId, event);
4281
4423
  }
4424
+ // Canvas: CONTENT convergence is ungated — a stale canvas_updated in a
4425
+ // reconnect replay must still advance latestRev and refresh a mounted
4426
+ // pane. Only the FLIP is guarded (below). This deliberately diverges
4427
+ // from artifact_presented, which drops stale events outright: a
4428
+ // presentation is an action, a canvas is state.
4429
+ if (event.eventType === "session.canvas_updated") {
4430
+ this.applyCanvasUpdate(sessionId, event);
4431
+ }
4432
+ if (event.eventType === "session.canvas_data") {
4433
+ this.dispatch({
4434
+ type: "canvas/data",
4435
+ sessionId,
4436
+ dataRev: Number(event?.data?.dataRev),
4437
+ payload: event?.data?.payload,
4438
+ note: typeof event?.data?.note === "string" ? event.data.note : "",
4439
+ });
4440
+ }
4282
4441
  this.reconcileOutboxAgainstEvent(sessionId, event);
4283
4442
  const derivedModel = extractSessionModelFromEvent(event);
4284
4443
  const currentSession = this.getState().sessions.byId[sessionId] || { sessionId };
@@ -4453,6 +4612,15 @@ export class PilotSwarmUiController {
4453
4612
  const eligible = this.getMovableGroupSessionSelection();
4454
4613
 
4455
4614
  if (eligible.length === 0) {
4615
+ // With a GROUP active, the folder button means "this group":
4616
+ // rename it. (Creating from here always made a second group —
4617
+ // reported as a surprise on the phone.)
4618
+ const active = state.sessions.activeSessionId
4619
+ ? state.sessions.byId[state.sessions.activeSessionId]
4620
+ : null;
4621
+ if (active?.isGroup && active.groupId) {
4622
+ return this.openRenameGroupModal(active);
4623
+ }
4456
4624
  // Nothing selected is a legitimate intent: make an empty folder to
4457
4625
  // drag sessions into, rather than scolding the user.
4458
4626
  return this.openCreateEmptyGroupModal();
@@ -4521,6 +4689,32 @@ export class PilotSwarmUiController {
4521
4689
  return this.openMoveToGroupModal();
4522
4690
  }
4523
4691
 
4692
+ /** Rename the active group in place (same modal, rename mode). */
4693
+ openRenameGroupModal(group) {
4694
+ if (typeof this.transport.updateSessionGroup !== "function") {
4695
+ this.dispatch({ type: "ui/status", text: "Group rename is not supported by this transport" });
4696
+ return null;
4697
+ }
4698
+ const state = this.getState();
4699
+ const current = String(group.title || "").trim();
4700
+ this.dispatch({
4701
+ type: "ui/modal",
4702
+ modal: {
4703
+ type: "sessionGroupName",
4704
+ mode: "rename",
4705
+ groupId: group.groupId,
4706
+ title: "Rename Group",
4707
+ previousFocus: state.ui.focusRegion,
4708
+ sessionIds: [],
4709
+ value: current,
4710
+ cursorIndex: current.length,
4711
+ maxLength: 80,
4712
+ },
4713
+ });
4714
+ this.dispatch({ type: "ui/status", text: "Edit the group name and press Enter" });
4715
+ return null;
4716
+ }
4717
+
4524
4718
  /** Name-and-create an EMPTY group (no sessions), ready to drag into. */
4525
4719
  async openCreateEmptyGroupModal() {
4526
4720
  if (typeof this.transport.createSessionGroup !== "function") {
@@ -4672,6 +4866,19 @@ export class PilotSwarmUiController {
4672
4866
  this.dispatch({ type: "ui/status", text: "Group name cannot be empty" });
4673
4867
  return;
4674
4868
  }
4869
+ if (modal.mode === "rename" && modal.groupId) {
4870
+ const previousFocusRename = modal.previousFocus;
4871
+ this.dispatch({ type: "ui/modal", modal: null });
4872
+ if (previousFocusRename) this.setFocus(previousFocusRename);
4873
+ try {
4874
+ await this.transport.updateSessionGroup(modal.groupId, { title });
4875
+ this.dispatch({ type: "ui/status", text: `Renamed group to "${title}"` });
4876
+ await this.refreshSessions().catch(() => {});
4877
+ } catch (error) {
4878
+ this.dispatch({ type: "ui/status", text: `Rename failed: ${error?.message || String(error)}` });
4879
+ }
4880
+ return;
4881
+ }
4675
4882
  if (typeof this.transport.createSessionGroup !== "function") {
4676
4883
  this.dispatch({ type: "ui/status", text: "Session grouping is not supported by this transport" });
4677
4884
  return;
@@ -4728,22 +4935,7 @@ export class PilotSwarmUiController {
4728
4935
  return sessionId ? state.sessions.byId[sessionId] || null : null;
4729
4936
  }
4730
4937
 
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
4938
 
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
4939
 
4748
4940
  /**
4749
4941
  * Opens the agent step of the new-session flow.
@@ -7829,7 +8021,9 @@ export class PilotSwarmUiController {
7829
8021
  pane: "chat",
7830
8022
  offset: 0,
7831
8023
  });
7832
- } else if (preserveChatView && previousScrollOffset > 0) {
8024
+ } else if (preserveChatView && previousScrollOffset > 0 && !options.autoTriggered) {
8025
+ // EXPLICIT "load older" (the TUI's `e`): jump up to the start
8026
+ // of what was just fetched — the user asked to SEE it.
7833
8027
  const nextState = this.getState();
7834
8028
  const nextRenderedLines = this.getActiveChatRenderMetrics(nextState).totalLines;
7835
8029
  const addedLines = Math.max(0, nextRenderedLines - previousRenderedLines);
@@ -7841,6 +8035,13 @@ export class PilotSwarmUiController {
7841
8035
  });
7842
8036
  }
7843
8037
  }
8038
+ // AUTO-triggered (scrolling reached the top): change NOTHING. The
8039
+ // offset is distance-from-BOTTOM, which prepended content cannot
8040
+ // move — leaving it alone keeps the viewport glued to the exact
8041
+ // messages the user was reading, and they scroll on into the new
8042
+ // page naturally. Adding the delta here teleported the view to
8043
+ // the TOP of a 3.3x-larger window — observed as "loading history
8044
+ // takes you way back instead of a few pages up".
7844
8045
 
7845
8046
  const stateLabel = history.hasOlderEvents
7846
8047
  ? options.autoTriggered
@@ -8523,9 +8724,6 @@ export class PilotSwarmUiController {
8523
8724
  case UI_COMMANDS.OPEN_ARTIFACT_UPLOAD:
8524
8725
  this.openArtifactUploadModal();
8525
8726
  return;
8526
- case UI_COMMANDS.TOGGLE_CHAT_VIEW:
8527
- this.toggleChatViewMode();
8528
- return;
8529
8727
  case UI_COMMANDS.CLOSE_MODAL:
8530
8728
  this.closeModal();
8531
8729
  return;