pilotswarm 0.5.39 → 0.5.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +2 -2
  2. package/tui/src/node-sdk-transport.js +25 -0
  3. package/ui/core/src/controller.js +34 -8
  4. package/ui/core/src/history.js +5 -1
  5. package/ui/core/src/index.js +1 -0
  6. package/ui/core/src/portal-links.js +46 -0
  7. package/ui/core/src/reducer.js +88 -2
  8. package/ui/core/src/selectors.js +2 -0
  9. package/ui/react/src/web-app.js +369 -34
  10. package/web/README.md +28 -0
  11. package/web/api/canvas-plane.js +116 -0
  12. package/web/api/ws.js +120 -5
  13. package/web/config.js +60 -0
  14. package/web/dist/assets/{abnfDiagram-VRR7QNED-Hsly1fgf.js → abnfDiagram-VRR7QNED-C6FaYMB7.js} +1 -1
  15. package/web/dist/assets/{arc-DiQLL9vM.js → arc-iZBUHcPs.js} +1 -1
  16. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-B8ry8WNZ.js → architectureDiagram-ZJ3FMSHR-D4-wnpAG.js} +1 -1
  17. package/web/dist/assets/{blockDiagram-677ZJIJ3-Z4PcukIo.js → blockDiagram-677ZJIJ3-Dz-s103f.js} +1 -1
  18. package/web/dist/assets/{c4Diagram-LMCZKHZV-Cahkc0gc.js → c4Diagram-LMCZKHZV-DE7sGdHE.js} +1 -1
  19. package/web/dist/assets/channel-Bk68hLPV.js +1 -0
  20. package/web/dist/assets/{chunk-2Q5K7J3B-CtZuWH4J.js → chunk-2Q5K7J3B-DSFs_jRM.js} +1 -1
  21. package/web/dist/assets/{chunk-32BRIVSS-C0M390-k.js → chunk-32BRIVSS-8sBVtZ4f.js} +1 -1
  22. package/web/dist/assets/{chunk-5VM5RSS4-ClvX9u5j.js → chunk-5VM5RSS4-CbIX7k0G.js} +1 -1
  23. package/web/dist/assets/{chunk-EX3LRPZG-BVAet5KG.js → chunk-EX3LRPZG-ZfmPtWF8.js} +1 -1
  24. package/web/dist/assets/{chunk-JWPE2WC7-BccmZmrr.js → chunk-JWPE2WC7-DifZrqsQ.js} +1 -1
  25. package/web/dist/assets/{chunk-MOJQB5TN-BN5MKe94.js → chunk-MOJQB5TN-CEvg_lZT.js} +1 -1
  26. package/web/dist/assets/{chunk-RYQCIY6F-DfWw-ACP.js → chunk-RYQCIY6F-DmYOwk6-.js} +1 -1
  27. package/web/dist/assets/{chunk-V7JOEXUC-BgUEoLnN.js → chunk-V7JOEXUC-DOxG9Vxu.js} +1 -1
  28. package/web/dist/assets/{chunk-VR4S4FIN-k1eKfFNA.js → chunk-VR4S4FIN-ic-KB2tF.js} +1 -1
  29. package/web/dist/assets/{chunk-XXDRQBXY-DPyV10yR.js → chunk-XXDRQBXY-BqeYnem3.js} +1 -1
  30. package/web/dist/assets/classDiagram-OUVF2IWQ-arTJL-2y.js +1 -0
  31. package/web/dist/assets/classDiagram-v2-EOCWNBFH-arTJL-2y.js +1 -0
  32. package/web/dist/assets/{cose-bilkent-JH36ORCC-Bt_rpukK.js → cose-bilkent-JH36ORCC-uaIgs2-N.js} +1 -1
  33. package/web/dist/assets/{cynefin-VYW2F7L2-Z8eMzFMv.js → cynefin-VYW2F7L2-DzFeugVQ.js} +1 -1
  34. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BJSNawFi.js → cynefinDiagram-TSTJHNR4-NrAChuCW.js} +1 -1
  35. package/web/dist/assets/{dagre-VKFMJZFB-D6dNl8jc.js → dagre-VKFMJZFB-Djf9LD2t.js} +1 -1
  36. package/web/dist/assets/{diagram-FQU43EPY-Cy1nbGsU.js → diagram-FQU43EPY-BpIP2iD9.js} +1 -1
  37. package/web/dist/assets/{diagram-G47NLZAW-CmrbuA_d.js → diagram-G47NLZAW-D_DCw8Oy.js} +1 -1
  38. package/web/dist/assets/{diagram-NH7WQ7WH-PoLNQE2j.js → diagram-NH7WQ7WH-BztvRvrM.js} +1 -1
  39. package/web/dist/assets/{diagram-OA4YK3LP-BZwNFcSV.js → diagram-OA4YK3LP-BGIwSnbM.js} +1 -1
  40. package/web/dist/assets/{diagram-WEI45ONY-pKw0b_fN.js → diagram-WEI45ONY-Czj2ttCt.js} +1 -1
  41. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-UNctuQCV.js → ebnfDiagram-CCIWWBDH-0nqAhMvJ.js} +1 -1
  42. package/web/dist/assets/{erDiagram-Q63AITRT-DxIETMif.js → erDiagram-Q63AITRT-uLoGb9TG.js} +1 -1
  43. package/web/dist/assets/{flowDiagram-23GEKE2U-C06IdNKV.js → flowDiagram-23GEKE2U-Bgxy7CWO.js} +1 -1
  44. package/web/dist/assets/{ganttDiagram-NO4QXBWP-fU7aGyP1.js → ganttDiagram-NO4QXBWP-5q8zYzbu.js} +1 -1
  45. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-9UKkPJm6.js → gitGraphDiagram-IHSO6WYX-D6-i59JZ.js} +1 -1
  46. package/web/dist/assets/index-B3WlYEvi.css +1 -0
  47. package/web/dist/assets/index-BFa_e8zL.js +24 -0
  48. package/web/dist/assets/{infoDiagram-FWYZ7A6U-C6F06SQJ.js → infoDiagram-FWYZ7A6U-bdx9GrWW.js} +1 -1
  49. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CB-O9NfO.js → ishikawaDiagram-FXEZZL3T-xzL6Bssj.js} +1 -1
  50. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B-8MOf-H.js → journeyDiagram-5HDEW3XC-C1-18xOk.js} +1 -1
  51. package/web/dist/assets/{kanban-definition-HUTT4EX6-DX0XA3hI.js → kanban-definition-HUTT4EX6-BS4KDL4_.js} +1 -1
  52. package/web/dist/assets/{linear-D60PrjTW.js → linear-Dmv3ton3.js} +1 -1
  53. package/web/dist/assets/{mermaid.core-bonzwapf.js → mermaid.core-Nhzw7Fl2.js} +4 -4
  54. package/web/dist/assets/{mindmap-definition-LN4V7U3C-Y-6Dum33.js → mindmap-definition-LN4V7U3C-2DSMdlsp.js} +1 -1
  55. package/web/dist/assets/{papaparse.min-Cas1JfRG.js → papaparse.min-H4g6q6aH.js} +1 -1
  56. package/web/dist/assets/{pegDiagram-2B236MQR-_paO0yDw.js → pegDiagram-2B236MQR-CM_rgJ2s.js} +1 -1
  57. package/web/dist/assets/{pieDiagram-ENE6RG2P-xT1_Gpn4.js → pieDiagram-ENE6RG2P-Cbo8FtuT.js} +1 -1
  58. package/web/dist/assets/pilotswarm-ClLipXts.js +97 -0
  59. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-1usdLFOF.js → quadrantDiagram-ABIIQ3AL-CVsF_pZA.js} +1 -1
  60. package/web/dist/assets/{railroadDiagram-RFXS5EU6-R3lwHOz9.js → railroadDiagram-RFXS5EU6-CauKYIfy.js} +1 -1
  61. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BL8J1__Z.js → requirementDiagram-TGXJPOKE-DoXh8EL8.js} +1 -1
  62. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Dz6--0pl.js → sankeyDiagram-HTMAVEWB-gA_ej2MB.js} +1 -1
  63. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CG2Xpkct.js → sequenceDiagram-DBY2YBRQ-keYBa83Q.js} +1 -1
  64. package/web/dist/assets/{sizeCapture-X5ZJPWSS-BPbYfjxj.js → sizeCapture-X5ZJPWSS-Y6NCdqw8.js} +1 -1
  65. package/web/dist/assets/{stateDiagram-2N3HPSRC-46x_y_aA.js → stateDiagram-2N3HPSRC-MvJfZXqW.js} +1 -1
  66. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-xTZh36yB.js +1 -0
  67. package/web/dist/assets/{swimlanes-5IMT3BWC-SXdMo4zJ.js → swimlanes-5IMT3BWC-D41i7zcr.js} +2 -2
  68. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BjlBuuiD.js +8 -0
  69. package/web/dist/assets/{timeline-definition-FHXFAJF6-CGgT-b38.js → timeline-definition-FHXFAJF6-CUA2ENV8.js} +1 -1
  70. package/web/dist/assets/{vennDiagram-L72KCM5P-BGS3xBa_.js → vennDiagram-L72KCM5P-CBTp-2d6.js} +1 -1
  71. package/web/dist/assets/{wardleyDiagram-EHGQE667-m2U3wEZJ.js → wardleyDiagram-EHGQE667-BIFjtpCg.js} +1 -1
  72. package/web/dist/assets/{xychartDiagram-FW5EYKEG-DI6tQysg.js → xychartDiagram-FW5EYKEG-DiamOwOI.js} +1 -1
  73. package/web/dist/index.html +3 -3
  74. package/web/runtime.js +71 -0
  75. package/web/server.js +51 -2
  76. package/web/dist/assets/channel-BHLBPE50.js +0 -1
  77. package/web/dist/assets/classDiagram-OUVF2IWQ-rj9D2TSL.js +0 -1
  78. package/web/dist/assets/classDiagram-v2-EOCWNBFH-rj9D2TSL.js +0 -1
  79. package/web/dist/assets/index-B8nGlHwb.js +0 -24
  80. package/web/dist/assets/index-DtL0lbis.css +0 -1
  81. package/web/dist/assets/pilotswarm-BT6tOknQ.js +0 -96
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-C2tnolSo.js +0 -1
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-Dh6Sn__t.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.39",
3
+ "version": "0.5.40",
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.39",
84
+ "pilotswarm-sdk": "0.5.40",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -2111,6 +2111,31 @@ export class NodeSdkTransport {
2111
2111
  return this.mgmt.getSessionEventsBefore(sessionId, beforeSeq, limit, eventTypes);
2112
2112
  }
2113
2113
 
2114
+ async getCanvasLive(sessionId) {
2115
+ if (typeof this.mgmt.getCanvasLive !== "function") return [];
2116
+ return this.mgmt.getCanvasLive(sessionId);
2117
+ }
2118
+
2119
+ async getCanvasShareLink(sessionId, slot) {
2120
+ if (typeof this.mgmt.getCanvasShareLink !== "function") return { exists: false };
2121
+ return this.mgmt.getCanvasShareLink(sessionId, slot);
2122
+ }
2123
+
2124
+ async resetCanvasShareLink(sessionId, slot, createdBy) {
2125
+ if (typeof this.mgmt.resetCanvasShareLink !== "function") throw new Error("canvas share links unavailable");
2126
+ return this.mgmt.resetCanvasShareLink(sessionId, slot, createdBy);
2127
+ }
2128
+
2129
+ async resolveCanvasShareTokenHash(tokenHash) {
2130
+ if (typeof this.mgmt.resolveCanvasShareTokenHash !== "function") return null;
2131
+ return this.mgmt.resolveCanvasShareTokenHash(tokenHash);
2132
+ }
2133
+
2134
+ async removeCanvasShareLink(sessionId, slot) {
2135
+ if (typeof this.mgmt.removeCanvasShareLink !== "function") return { removed: false };
2136
+ return this.mgmt.removeCanvasShareLink(sessionId, slot);
2137
+ }
2138
+
2114
2139
  emitLogEntry(entry) {
2115
2140
  if (!this._logBatch) this._logBatch = [];
2116
2141
  this._logBatch.push(entry);
@@ -4499,8 +4499,41 @@ export class PilotSwarmUiController {
4499
4499
  return true;
4500
4500
  }
4501
4501
 
4502
+ applyCanvasDataEvent(sessionId, event) {
4503
+ this.dispatch({
4504
+ type: "canvas/data",
4505
+ sessionId,
4506
+ slot: Number(event?.data?.slot) || 1,
4507
+ dataRev: Number(event?.data?.dataRev),
4508
+ payload: event?.data?.payload,
4509
+ // Plane-fed ticks (the canvas data plane's live mirror) carry
4510
+ // their own seq lineage and the patch that produced them — the
4511
+ // reducer orders plane ticks by planeSeq and hands the patch to
4512
+ // pages that opt into targeted updates.
4513
+ ...(Number.isFinite(Number(event?.data?.planeSeq)) ? { planeSeq: Number(event.data.planeSeq) } : {}),
4514
+ ...(event?.data?.patch && typeof event.data.patch === "object" ? { patch: event.data.patch } : {}),
4515
+ note: typeof event?.data?.note === "string" ? event.data.note : "",
4516
+ });
4517
+ }
4518
+
4502
4519
  mergeSessionEvent(sessionId, event) {
4503
4520
  if (!sessionId || !event) return false;
4521
+ // Plane-synthesized canvas events are TRANSIENT: they update canvas
4522
+ // state and nothing else. They carry no seq — letting them into
4523
+ // history would corrupt the replay cursor — and they must never
4524
+ // appear in the transcript.
4525
+ if (event.transient === true) {
4526
+ if (event.eventType === "session.canvas_data") {
4527
+ this.applyCanvasDataEvent(sessionId, event);
4528
+ }
4529
+ if (event.eventType === "session.canvas_plane_released") {
4530
+ // The plane died for this session (server error/rollback).
4531
+ // Clear the reducer's takeover so legacy durable ticks — the
4532
+ // fallback path — apply again instead of being dropped.
4533
+ this.dispatch({ type: "canvas/planeReleased", sessionId });
4534
+ }
4535
+ return false;
4536
+ }
4504
4537
  const state = this.getState();
4505
4538
  const existing = state.history.bySessionId.get(sessionId) || { chat: [], activity: [], lastSeq: 0 };
4506
4539
  if (event.seq <= (existing.lastSeq || 0)) return false;
@@ -4561,14 +4594,7 @@ export class PilotSwarmUiController {
4561
4594
  this.applyCanvasPresented(sessionId, event);
4562
4595
  }
4563
4596
  if (event.eventType === "session.canvas_data") {
4564
- this.dispatch({
4565
- type: "canvas/data",
4566
- sessionId,
4567
- slot: Number(event?.data?.slot) || 1,
4568
- dataRev: Number(event?.data?.dataRev),
4569
- payload: event?.data?.payload,
4570
- note: typeof event?.data?.note === "string" ? event.data.note : "",
4571
- });
4597
+ this.applyCanvasDataEvent(sessionId, event);
4572
4598
  }
4573
4599
  this.reconcileOutboxAgainstEvent(sessionId, event);
4574
4600
  const derivedModel = extractSessionModelFromEvent(event);
@@ -1123,7 +1123,11 @@ export function appendEventToHistory(history, event) {
1123
1123
  chat: clampHistoryItems(history?.chat || [], loadedEventLimit),
1124
1124
  activity: clampHistoryItems(history?.activity || [], loadedEventLimit),
1125
1125
  events: nextEvents,
1126
- lastSeq: event.seq,
1126
+ // The replay cursor. A seq-less event (plane-synthesized canvas
1127
+ // ticks are transient and carry none) must never poison it —
1128
+ // lastSeq=undefined reads as afterSeq 0 on the next sync and the
1129
+ // whole transcript re-merges, duplicated.
1130
+ lastSeq: Number.isFinite(Number(event.seq)) ? event.seq : (history?.lastSeq || 0),
1127
1131
  loadedEventLimit,
1128
1132
  loadedEventCount: Math.max(Number(history?.loadedEventCount || 0), nextEvents.length),
1129
1133
  hasOlderEvents: Boolean(history?.hasOlderEvents),
@@ -8,6 +8,7 @@ export * from "./repo-import.js";
8
8
  export * from "./session-tree.js";
9
9
  export * from "./state.js";
10
10
  export * from "./reducer.js";
11
+ export * from "./portal-links.js";
11
12
  export * from "./store.js";
12
13
  export * from "./selectors.js";
13
14
  export * from "./controller.js";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Multi-origin portal links.
3
+ *
4
+ * A deployment can be reachable through several entry points at once — a
5
+ * public edge and a private/VPN hostname, say — and a link built from the
6
+ * sender's window.location.origin only works for recipients on the SAME
7
+ * network path. When the portal is configured with named link origins,
8
+ * every link-producing surface offers one variant per origin, labeled.
9
+ *
10
+ * This replaces waldemort's deploy-time monkey patch
11
+ * (patch-pilotswarm-dual-session-links.mjs), which anchor-matched the
12
+ * minified bundle and broke on every release. The plug-in surface is one
13
+ * env var on the portal server: PORTAL_LINK_ORIGINS.
14
+ */
15
+
16
+ /**
17
+ * Rebase one produced URL onto each configured origin.
18
+ * @param {string} url - a full URL produced against the current origin
19
+ * @param {Array<{label: string, origin: string}>} linkOrigins
20
+ * @returns {Array<{label: string|null, url: string}>} one entry per origin;
21
+ * with no (or one) configured origin, a single unlabeled entry with the
22
+ * original URL — zero behavior change for plain deployments.
23
+ */
24
+ export function buildPortalLinks(url, linkOrigins) {
25
+ const origins = Array.isArray(linkOrigins) ? linkOrigins.filter((o) => o?.origin && o?.label) : [];
26
+ if (origins.length < 2) return [{ label: null, url: String(url || "") }];
27
+ let suffix;
28
+ try {
29
+ const parsed = new URL(String(url || ""));
30
+ suffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
31
+ } catch {
32
+ return [{ label: null, url: String(url || "") }];
33
+ }
34
+ return origins.map((o) => ({ label: String(o.label), url: `${o.origin}${suffix}` }));
35
+ }
36
+
37
+ /**
38
+ * Clipboard shape: a bare URL for the single-origin case; one labeled line
39
+ * per origin otherwise, so the sender pastes both and the recipient picks
40
+ * the one their network can reach.
41
+ */
42
+ export function formatPortalLinksForCopy(links) {
43
+ const list = Array.isArray(links) ? links : [];
44
+ if (list.length <= 1) return String(list[0]?.url || "");
45
+ return list.map((l) => `${l.label}: ${l.url}`).join("\n");
46
+ }
@@ -1570,6 +1570,39 @@ export function appReducer(state, action) {
1570
1570
  if (previousActiveId && previousActiveId !== action.sessionId) {
1571
1571
  savedChatScroll[previousActiveId] = Number(state.ui.scroll?.chat) || 0;
1572
1572
  }
1573
+ // Per-session prompt drafts: a half-written message belongs to the
1574
+ // session it was written in. Stash the outgoing draft (text +
1575
+ // staged attachments) and restore the incoming one, exactly like
1576
+ // the chat-scroll memory above. Exception: while editing a pending
1577
+ // outbox message the composer text lives in that outbox item, not
1578
+ // in a draft — leave the item alone, drop the edit latch, and give
1579
+ // the incoming session its own draft back.
1580
+ const switchingSession = previousActiveId !== action.sessionId;
1581
+ const savedDrafts = { ...(state.ui.promptDraftBySession || {}) };
1582
+ let nextPrompt = state.ui.prompt;
1583
+ let nextPromptCursor = state.ui.promptCursor;
1584
+ let nextPromptAttachments = state.ui.promptAttachments;
1585
+ let nextPromptEdit = state.ui.promptEdit ?? null;
1586
+ if (switchingSession) {
1587
+ const editingPending = Boolean(state.ui.promptEdit);
1588
+ if (!editingPending && previousActiveId) {
1589
+ const outgoing = {
1590
+ prompt: String(state.ui.prompt || ""),
1591
+ attachments: Array.isArray(state.ui.promptAttachments) ? state.ui.promptAttachments : [],
1592
+ };
1593
+ if (outgoing.prompt || outgoing.attachments.length > 0) {
1594
+ savedDrafts[previousActiveId] = outgoing;
1595
+ } else {
1596
+ delete savedDrafts[previousActiveId];
1597
+ }
1598
+ }
1599
+ const incoming = action.sessionId ? savedDrafts[action.sessionId] : null;
1600
+ delete savedDrafts[action.sessionId];
1601
+ nextPrompt = incoming?.prompt || "";
1602
+ nextPromptCursor = nextPrompt.length;
1603
+ nextPromptAttachments = Array.isArray(incoming?.attachments) ? incoming.attachments : [];
1604
+ nextPromptEdit = null;
1605
+ }
1573
1606
  // Manual navigation to a different session releases the deep-link
1574
1607
  // latch and its transient filter exception.
1575
1608
  const releasesNavigationLatch = Boolean(
@@ -1588,6 +1621,12 @@ export function appReducer(state, action) {
1588
1621
  ui: {
1589
1622
  ...state.ui,
1590
1623
  chatScrollBySession: savedChatScroll,
1624
+ promptDraftBySession: savedDrafts,
1625
+ prompt: nextPrompt,
1626
+ promptCursor: nextPromptCursor,
1627
+ promptRows: getPromptInputRows(nextPrompt),
1628
+ promptAttachments: nextPromptAttachments,
1629
+ promptEdit: nextPromptEdit,
1591
1630
  // Status notices are per-moment, usually per-session (send
1592
1631
  // refusals, access hints) — a stale one must not follow the
1593
1632
  // user to the next session.
@@ -1861,6 +1900,8 @@ export function appReducer(state, action) {
1861
1900
  for (const id of ids) delete nextOutbox[id];
1862
1901
  const nextChatScroll = { ...(state.ui.chatScrollBySession || {}) };
1863
1902
  for (const id of ids) delete nextChatScroll[id];
1903
+ const nextDrafts = { ...(state.ui.promptDraftBySession || {}) };
1904
+ for (const id of ids) delete nextDrafts[id];
1864
1905
  return {
1865
1906
  ...state,
1866
1907
  history: {
@@ -1874,6 +1915,7 @@ export function appReducer(state, action) {
1874
1915
  ui: {
1875
1916
  ...state.ui,
1876
1917
  chatScrollBySession: nextChatScroll,
1918
+ promptDraftBySession: nextDrafts,
1877
1919
  },
1878
1920
  };
1879
1921
  }
@@ -2953,7 +2995,20 @@ export function appReducer(state, action) {
2953
2995
  const dataRev = Number(action.dataRev);
2954
2996
  if (!sessionId || !Number.isFinite(dataRev) || dataRev <= 0) return state;
2955
2997
  const existing = state.canvas.bySessionId[key] || {};
2956
- if ((existing.latestDataRev || 0) >= dataRev) return state;
2998
+ const planeSeq = Number(action.planeSeq);
2999
+ const isPlane = Number.isFinite(planeSeq) && planeSeq > 0;
3000
+ // Plane takeover: once a slot has seen ONE plane-fed tick, that
3001
+ // lineage owns the slot. Plane ticks order by planeSeq (their
3002
+ // numbering restarts relative to legacy dataRev, so the numeric
3003
+ // guard below would wrongly starve them); legacy dual-written
3004
+ // events for a plane-owned slot are strictly staler copies of
3005
+ // state the plane already delivered — drop them.
3006
+ if (isPlane) {
3007
+ if ((existing.planeSeq || 0) >= planeSeq) return state;
3008
+ } else {
3009
+ if (existing.planeSeq) return state;
3010
+ if ((existing.latestDataRev || 0) >= dataRev) return state;
3011
+ }
2957
3012
  return {
2958
3013
  ...state,
2959
3014
  canvas: {
@@ -2962,14 +3017,45 @@ export function appReducer(state, action) {
2962
3017
  ...state.canvas.bySessionId,
2963
3018
  [key]: {
2964
3019
  ...existing,
2965
- latestDataRev: dataRev,
3020
+ // The DISPLAYED tick number stays monotonic across
3021
+ // the takeover: viewed-marking and badges compare
3022
+ // against it, and a smaller number would replay
3023
+ // as "already seen".
3024
+ latestDataRev: Math.max(dataRev, existing.latestDataRev || 0),
3025
+ ...(isPlane ? { planeSeq } : {}),
2966
3026
  dataPayload: action.payload && typeof action.payload === "object" ? action.payload : null,
3027
+ // The patch that produced this state, for pages
3028
+ // that opt into targeted updates. Whole-state
3029
+ // ticks clear it — there is no delta to hand out.
3030
+ dataPatch: isPlane && action.patch && typeof action.patch === "object" ? action.patch : null,
2967
3031
  },
2968
3032
  },
2969
3033
  },
2970
3034
  };
2971
3035
  }
2972
3036
 
3037
+ case "canvas/planeReleased": {
3038
+ // The plane died for this session: release the takeover on every
3039
+ // slot so legacy durable ticks resume applying. Displayed tick
3040
+ // numbers (latestDataRev) survive, so stale legacy dupes still
3041
+ // drop by ordinary ordering while fresh ones flow.
3042
+ const sessionId = String(action.sessionId || "").trim();
3043
+ if (!sessionId) return state;
3044
+ let changed = false;
3045
+ const nextBySession = { ...state.canvas.bySessionId };
3046
+ for (let slot = 1; slot <= 5; slot++) {
3047
+ const key = canvasKey(sessionId, slot);
3048
+ const entry = nextBySession[key];
3049
+ if (entry && entry.planeSeq) {
3050
+ const { planeSeq: _released, ...rest } = entry;
3051
+ nextBySession[key] = rest;
3052
+ changed = true;
3053
+ }
3054
+ }
3055
+ if (!changed) return state;
3056
+ return { ...state, canvas: { ...state.canvas, bySessionId: nextBySession } };
3057
+ }
3058
+
2973
3059
  case "canvas/snapshot": {
2974
3060
  const sessionId = String(action.sessionId || "").trim();
2975
3061
  if (!sessionId) return state;
@@ -2769,6 +2769,8 @@ export function selectCanvasView(state) {
2769
2769
  latestDataRev,
2770
2770
  lastViewedDataRev,
2771
2771
  dataPayload: entry?.dataPayload || null,
2772
+ dataPatch: entry?.dataPatch || null,
2773
+ planeSeq: entry?.planeSeq || 0,
2772
2774
  unseen: exists && (latestRev > lastViewedRev || latestDataRev > lastViewedDataRev) && mode !== "canvas",
2773
2775
  };
2774
2776
  }