pilotswarm 0.5.37 → 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 (78) hide show
  1. package/package.json +3 -3
  2. package/ui/core/src/controller.js +97 -20
  3. package/ui/core/src/reducer.js +213 -15
  4. package/ui/core/src/selectors.js +83 -3
  5. package/ui/core/src/state.js +90 -2
  6. package/ui/core/src/themes/index.js +1 -1
  7. package/ui/react/src/web-app.js +1038 -218
  8. package/web/auth/config.js +3 -0
  9. package/web/auth/index.js +8 -1
  10. package/web/auth/normalize/proxy.js +48 -0
  11. package/web/auth/providers/proxy.js +141 -0
  12. package/web/dist/assets/{abnfDiagram-VRR7QNED-DlvPH-8O.js → abnfDiagram-VRR7QNED-DrJhXhSQ.js} +1 -1
  13. package/web/dist/assets/{arc-r1liz5e5.js → arc-BEaG9DWd.js} +1 -1
  14. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Bci5dNH3.js → architectureDiagram-ZJ3FMSHR-D9fc1Imx.js} +1 -1
  15. package/web/dist/assets/{blockDiagram-677ZJIJ3-Bug32AXp.js → blockDiagram-677ZJIJ3-rS5Eikvt.js} +1 -1
  16. package/web/dist/assets/{c4Diagram-LMCZKHZV-DwxSFqgE.js → c4Diagram-LMCZKHZV-HKLlX5cR.js} +1 -1
  17. package/web/dist/assets/channel-DRy7FvyF.js +1 -0
  18. package/web/dist/assets/{chunk-2Q5K7J3B-B_dbI_xg.js → chunk-2Q5K7J3B-kq0TyLSB.js} +1 -1
  19. package/web/dist/assets/{chunk-32BRIVSS-BkF68W-R.js → chunk-32BRIVSS-O-LMarT5.js} +1 -1
  20. package/web/dist/assets/{chunk-5VM5RSS4-BSbNIbJu.js → chunk-5VM5RSS4-Di4h0CAu.js} +1 -1
  21. package/web/dist/assets/{chunk-EX3LRPZG-Blv_Iqa2.js → chunk-EX3LRPZG-C8rgqUOt.js} +1 -1
  22. package/web/dist/assets/{chunk-JWPE2WC7-Dk_dBFKd.js → chunk-JWPE2WC7-BSH41q4T.js} +1 -1
  23. package/web/dist/assets/{chunk-MOJQB5TN-DqNFBrfw.js → chunk-MOJQB5TN-BAFuQHAC.js} +1 -1
  24. package/web/dist/assets/{chunk-RYQCIY6F-Dlv7Svpb.js → chunk-RYQCIY6F-DAg6JOs7.js} +1 -1
  25. package/web/dist/assets/{chunk-V7JOEXUC-DozGikwu.js → chunk-V7JOEXUC-DbVrT8BG.js} +1 -1
  26. package/web/dist/assets/{chunk-VR4S4FIN-CmkqvGT4.js → chunk-VR4S4FIN-C6f5_Grp.js} +1 -1
  27. package/web/dist/assets/{chunk-XXDRQBXY-DyByai5N.js → chunk-XXDRQBXY-CqcCYpqA.js} +1 -1
  28. package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +1 -0
  29. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +1 -0
  30. package/web/dist/assets/{cose-bilkent-JH36ORCC-Bv-gRVvA.js → cose-bilkent-JH36ORCC-DJw04eFh.js} +1 -1
  31. package/web/dist/assets/{cynefin-VYW2F7L2-DRnAsyZU.js → cynefin-VYW2F7L2-CpjwdEIU.js} +1 -1
  32. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-B1fnUtjo.js → cynefinDiagram-TSTJHNR4-RqEIWoT0.js} +1 -1
  33. package/web/dist/assets/{dagre-VKFMJZFB-DM6-QENB.js → dagre-VKFMJZFB-C1eGQNK2.js} +1 -1
  34. package/web/dist/assets/{diagram-FQU43EPY-BPxxnLdU.js → diagram-FQU43EPY-BODLQ7dz.js} +1 -1
  35. package/web/dist/assets/{diagram-G47NLZAW-BgtJT3qF.js → diagram-G47NLZAW-DlhmukMC.js} +1 -1
  36. package/web/dist/assets/{diagram-NH7WQ7WH-uekeIzsy.js → diagram-NH7WQ7WH-Ba_jZoCN.js} +1 -1
  37. package/web/dist/assets/{diagram-OA4YK3LP-BVy3JZWE.js → diagram-OA4YK3LP-DnSiAJBG.js} +1 -1
  38. package/web/dist/assets/{diagram-WEI45ONY-BWdjUr32.js → diagram-WEI45ONY-B3KoQ7_W.js} +1 -1
  39. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DD4fwhNK.js → ebnfDiagram-CCIWWBDH-ChnFc5gf.js} +1 -1
  40. package/web/dist/assets/{erDiagram-Q63AITRT-BllD_6Kh.js → erDiagram-Q63AITRT-D2KkJXLZ.js} +1 -1
  41. package/web/dist/assets/{flowDiagram-23GEKE2U-QEYKkAUN.js → flowDiagram-23GEKE2U-CmybZufj.js} +1 -1
  42. package/web/dist/assets/{ganttDiagram-NO4QXBWP-uZcfKTSW.js → ganttDiagram-NO4QXBWP-BzPurW_v.js} +1 -1
  43. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-XPdbPaH-.js → gitGraphDiagram-IHSO6WYX-DAbXl4gO.js} +1 -1
  44. package/web/dist/assets/{index-D1itTxvB.js → index-CRY94nFp.js} +6 -6
  45. package/web/dist/assets/index-DtL0lbis.css +1 -0
  46. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DXCnQOJs.js → infoDiagram-FWYZ7A6U-C6qGjNA1.js} +1 -1
  47. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-687_4FBy.js → ishikawaDiagram-FXEZZL3T-D2d1tO-9.js} +1 -1
  48. package/web/dist/assets/{journeyDiagram-5HDEW3XC-GXlKuda8.js → journeyDiagram-5HDEW3XC-fP13w1b7.js} +1 -1
  49. package/web/dist/assets/{kanban-definition-HUTT4EX6-C38-2DvN.js → kanban-definition-HUTT4EX6-BJfWAXZ0.js} +1 -1
  50. package/web/dist/assets/{linear-lGPXiBcz.js → linear-CltApkHf.js} +1 -1
  51. package/web/dist/assets/{mermaid.core-C_neEtab.js → mermaid.core-B1Dwetk3.js} +4 -4
  52. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BaJ9tpEG.js → mindmap-definition-LN4V7U3C-CdRUD7LF.js} +1 -1
  53. package/web/dist/assets/{papaparse.min-rDwKYbBf.js → papaparse.min-C1y3wBH1.js} +1 -1
  54. package/web/dist/assets/{pegDiagram-2B236MQR-BdXJLYgY.js → pegDiagram-2B236MQR-CeQkqN6Q.js} +1 -1
  55. package/web/dist/assets/{pieDiagram-ENE6RG2P-DU19t6fM.js → pieDiagram-ENE6RG2P-Dqgc87jA.js} +1 -1
  56. package/web/dist/assets/pilotswarm-DayYdi39.js +96 -0
  57. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CBkOdPp0.js → quadrantDiagram-ABIIQ3AL-CD7f-w6m.js} +1 -1
  58. package/web/dist/assets/{railroadDiagram-RFXS5EU6-D2DMcGDw.js → railroadDiagram-RFXS5EU6-CcsAijSC.js} +1 -1
  59. package/web/dist/assets/{requirementDiagram-TGXJPOKE-Bx9tF1pr.js → requirementDiagram-TGXJPOKE-BkzF80pL.js} +1 -1
  60. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DcLlRfsi.js → sankeyDiagram-HTMAVEWB-D9XOFw7Y.js} +1 -1
  61. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-xr6UVRBO.js → sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js} +1 -1
  62. package/web/dist/assets/{sizeCapture-X5ZJPWSS-0fUkSXmg.js → sizeCapture-X5ZJPWSS-CDJV16tP.js} +1 -1
  63. package/web/dist/assets/{stateDiagram-2N3HPSRC-BWgJGGpg.js → stateDiagram-2N3HPSRC-C0E-MPXO.js} +1 -1
  64. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +1 -0
  65. package/web/dist/assets/{swimlanes-5IMT3BWC-CZKya4_i.js → swimlanes-5IMT3BWC-n5Vsv7n9.js} +2 -2
  66. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +8 -0
  67. package/web/dist/assets/{timeline-definition-FHXFAJF6-Df8BpeAu.js → timeline-definition-FHXFAJF6-DW2gV8-n.js} +1 -1
  68. package/web/dist/assets/{vennDiagram-L72KCM5P-ChX5bjT6.js → vennDiagram-L72KCM5P-D9dbDm08.js} +1 -1
  69. package/web/dist/assets/{wardleyDiagram-EHGQE667-Dr48BgaJ.js → wardleyDiagram-EHGQE667-CIcG7fiC.js} +1 -1
  70. package/web/dist/assets/{xychartDiagram-FW5EYKEG-Cia_MFfc.js → xychartDiagram-FW5EYKEG-elEIFsc9.js} +1 -1
  71. package/web/dist/index.html +3 -3
  72. package/web/dist/assets/channel-jVpSYat3.js +0 -1
  73. package/web/dist/assets/classDiagram-OUVF2IWQ-C78zSajE.js +0 -1
  74. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C78zSajE.js +0 -1
  75. package/web/dist/assets/index-BuDSoRIL.css +0 -1
  76. package/web/dist/assets/pilotswarm-BNKGG936.js +0 -96
  77. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DmAC8DdB.js +0 -1
  78. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CVYx20_X.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.37",
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.37",
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
  }
@@ -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 {
@@ -2414,6 +2415,17 @@ export class PilotSwarmUiController {
2414
2415
  if (pendingGroupRows) {
2415
2416
  this.dispatch({ type: "sessions/groupsLoaded", groups: pendingGroupRows, sessions });
2416
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 });
2417
2429
  this.dispatch({ type: "sessions/loaded", sessions });
2418
2430
  this.refreshOpenSessionOwnerFilterModal();
2419
2431
  const selected = this.getState().sessions.activeSessionId;
@@ -4254,9 +4266,13 @@ export class PilotSwarmUiController {
4254
4266
  applyCanvasUpdate(sessionId, event) {
4255
4267
  const rev = Number(event?.data?.rev);
4256
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;
4257
4271
  this.dispatch({
4258
4272
  type: "canvas/updated",
4259
4273
  sessionId,
4274
+ slot,
4275
+ ...(typeof event?.data?.name === "string" ? { name: event.data.name } : {}),
4260
4276
  rev,
4261
4277
  note: typeof event?.data?.note === "string" ? event.data.note : "",
4262
4278
  sizeBytes: event?.data?.sizeBytes,
@@ -4265,7 +4281,9 @@ export class PilotSwarmUiController {
4265
4281
 
4266
4282
  const state = this.getState();
4267
4283
  if (sessionId !== state.sessions.activeSessionId) return true;
4268
- if (state.canvas.prefs[sessionId]?.optedOut) 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;
4269
4287
  const createdAt = event?.createdAt ? Date.parse(event.createdAt) : Number.NaN;
4270
4288
  if (Number.isFinite(createdAt) && Date.now() - createdAt > PRESENTED_ARTIFACT_FRESHNESS_MS) {
4271
4289
  return true;
@@ -4274,7 +4292,7 @@ export class PilotSwarmUiController {
4274
4292
  // when the mode is already "canvas", because a phone can be off its
4275
4293
  // canvas tab while the mode state still says canvas — the tick is
4276
4294
  // what brings the tab back. On desktop the repeat dispatch is a no-op.
4277
- this.dispatch({ type: "canvas/flip", sessionId });
4295
+ this.dispatch({ type: "canvas/flip", sessionId, slot });
4278
4296
  return true;
4279
4297
  }
4280
4298
 
@@ -4337,29 +4355,55 @@ export class PilotSwarmUiController {
4337
4355
  return;
4338
4356
  }
4339
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.
4340
4361
  const rows = await this.transport.getSessionEventsBefore(
4341
- sessionId, Number.MAX_SAFE_INTEGER, 1, ["session.canvas_updated"],
4362
+ sessionId, Number.MAX_SAFE_INTEGER, 30, ["session.canvas_updated"],
4342
4363
  );
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) {
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) {
4355
4392
  const dataRows = await this.transport.getSessionEventsBefore(
4356
- sessionId, Number.MAX_SAFE_INTEGER, 1, ["session.canvas_data"],
4393
+ sessionId, Number.MAX_SAFE_INTEGER, 30, ["session.canvas_data"],
4357
4394
  ).catch(() => []);
4358
- const tick = Array.isArray(dataRows) ? dataRows[0] : null;
4359
- if (tick?.data?.dataRev) {
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;
4360
4403
  this.dispatch({
4361
4404
  type: "canvas/data",
4362
4405
  sessionId,
4406
+ slot,
4363
4407
  dataRev: Number(tick.data.dataRev),
4364
4408
  payload: tick.data.payload,
4365
4409
  note: typeof tick.data.note === "string" ? tick.data.note : "",
@@ -4371,6 +4415,27 @@ export class PilotSwarmUiController {
4371
4415
  }
4372
4416
  }
4373
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
+
4374
4439
  mergeSessionEvent(sessionId, event) {
4375
4440
  if (!sessionId || !event) return false;
4376
4441
  const state = this.getState();
@@ -4429,10 +4494,14 @@ export class PilotSwarmUiController {
4429
4494
  if (event.eventType === "session.canvas_updated") {
4430
4495
  this.applyCanvasUpdate(sessionId, event);
4431
4496
  }
4497
+ if (event.eventType === "session.canvas_presented") {
4498
+ this.applyCanvasPresented(sessionId, event);
4499
+ }
4432
4500
  if (event.eventType === "session.canvas_data") {
4433
4501
  this.dispatch({
4434
4502
  type: "canvas/data",
4435
4503
  sessionId,
4504
+ slot: Number(event?.data?.slot) || 1,
4436
4505
  dataRev: Number(event?.data?.dataRev),
4437
4506
  payload: event?.data?.payload,
4438
4507
  note: typeof event?.data?.note === "string" ? event.data.note : "",
@@ -7449,7 +7518,15 @@ export class PilotSwarmUiController {
7449
7518
 
7450
7519
  applyPaneVisualScrollOffset(pane, offset, options = {}, state = this.getState()) {
7451
7520
  const maxOffset = this.getPaneMaxScrollOffset(pane, state);
7452
- 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));
7453
7530
  if (this.paneUsesStickyBottomFollow(pane, state)) {
7454
7531
  const followBottom = options.followBottom !== undefined
7455
7532
  ? Boolean(options.followBottom)
@@ -7468,7 +7545,7 @@ export class PilotSwarmUiController {
7468
7545
  updatePaneScrollFromViewport(pane, offset, options = {}) {
7469
7546
  const state = this.getState();
7470
7547
  if (this.paneUsesStickyBottomFollow(pane, state)) {
7471
- this.applyPaneVisualScrollOffset(pane, offset, options, state);
7548
+ this.applyPaneVisualScrollOffset(pane, offset, { ...options, fromViewport: true }, state);
7472
7549
  return;
7473
7550
  }
7474
7551
  this.scrollPaneTo(pane, offset);
@@ -9,7 +9,10 @@ import {
9
9
  normalizeSessionOwnerFilter,
10
10
  normalizeStoredActiveSessionId,
11
11
  normalizeStoredCanvasPrefs,
12
+ canvasKey,
13
+ parseCanvasKey,
12
14
  normalizeStoredCollapsedSessionIds,
15
+ normalizeStoredDesktopPanes,
13
16
  normalizeStoredLayoutAdjustments,
14
17
  normalizeStoredPinnedSessionIds,
15
18
  normalizeStoredSessionOrder,
@@ -112,10 +115,15 @@ function mergeCanvasPrefs(local, stored, byId) {
112
115
  optedOut: storedEntry ? storedEntry.optedOut : Boolean(localEntry?.optedOut),
113
116
  lastViewedRev: Math.max(storedEntry?.lastViewedRev || 0, localEntry?.lastViewedRev || 0),
114
117
  lastViewedDataRev: Math.max(storedEntry?.lastViewedDataRev || 0, localEntry?.lastViewedDataRev || 0),
118
+ // Latest write wins is unknowable here; the local value is the one
119
+ // this window just dragged, so it outranks the poll.
120
+ ...((localEntry?.zenRailPx || storedEntry?.zenRailPx)
121
+ ? { zenRailPx: localEntry?.zenRailPx || storedEntry?.zenRailPx }
122
+ : {}),
115
123
  };
116
- if (!merged.optedOut && merged.lastViewedRev === 0 && merged.lastViewedDataRev === 0) continue;
117
- const session = byId?.[sessionId];
118
- if (session && (session.parentSessionId || session.isGroup)) continue;
124
+ if (!merged.optedOut && merged.lastViewedRev === 0 && merged.lastViewedDataRev === 0 && !merged.zenRailPx) continue;
125
+ const session = byId?.[parseCanvasKey(sessionId).sessionId];
126
+ if (session && session.isGroup) continue;
119
127
  out[sessionId] = merged;
120
128
  }
121
129
  return out;
@@ -808,6 +816,17 @@ export function appReducer(state, action) {
808
816
  && typeof settings.touchScale === "boolean";
809
817
  const hasRightPaneMode = Object.prototype.hasOwnProperty.call(settings, "rightPaneMode")
810
818
  && (settings.rightPaneMode === "canvas" || settings.rightPaneMode === "panes");
819
+ // The two independent columns. A profile written by a build that
820
+ // predates them carries only rightPaneMode, so fall back to
821
+ // migrating that — see normalizeStoredDesktopPanes.
822
+ const hasDesktopPanes = Object.prototype.hasOwnProperty.call(settings, "desktopPanes")
823
+ && settings.desktopPanes && typeof settings.desktopPanes === "object";
824
+ const nextDesktopPanes = (hasDesktopPanes || hasRightPaneMode)
825
+ ? normalizeStoredDesktopPanes(
826
+ hasDesktopPanes ? settings.desktopPanes : null,
827
+ hasRightPaneMode ? settings.rightPaneMode : null,
828
+ )
829
+ : null;
811
830
  const hasCanvasPrefs = Object.prototype.hasOwnProperty.call(settings, "canvasPrefs");
812
831
  const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
813
832
  const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
@@ -872,6 +891,9 @@ export function appReducer(state, action) {
872
891
  themeId: hasTheme ? settings.themeId.trim() : selection.ui.themeId,
873
892
  touchScale: hasTouchScale ? settings.touchScale : selection.ui.touchScale,
874
893
  rightPaneMode: hasRightPaneMode ? settings.rightPaneMode : selection.ui.rightPaneMode,
894
+ ...(nextDesktopPanes
895
+ ? { canvasOpen: nextDesktopPanes.canvasOpen, diagnosticsOpen: nextDesktopPanes.diagnosticsOpen, canvasZen: nextDesktopPanes.zen === true }
896
+ : {}),
875
897
  layout: nextLayout,
876
898
  },
877
899
  canvas: hasCanvasPrefs
@@ -946,6 +968,46 @@ export function appReducer(state, action) {
946
968
  },
947
969
  };
948
970
 
971
+ // Where the canvas/diagnostics seam sits when both columns are open.
972
+ // A pixel delta from an even split of the right side, like the other
973
+ // adjusts. Closing a column is a separate action — see
974
+ // CanvasDiagnosticsResizeHandle, which decides that on release.
975
+ case "ui/canvasPaneAdjust":
976
+ return {
977
+ ...state,
978
+ ui: {
979
+ ...state.ui,
980
+ layout: {
981
+ ...(state.ui.layout || {}),
982
+ canvasPaneAdjust: Number(action.canvasPaneAdjust) || 0,
983
+ },
984
+ },
985
+ };
986
+
987
+ case "ui/diagnosticsSplitAdjust":
988
+ return {
989
+ ...state,
990
+ ui: {
991
+ ...state.ui,
992
+ layout: {
993
+ ...(state.ui.layout || {}),
994
+ diagnosticsSplitAdjust: Number(action.diagnosticsSplitAdjust) || 0,
995
+ },
996
+ },
997
+ };
998
+
999
+ case "ui/diagnosticsPaneAdjust":
1000
+ return {
1001
+ ...state,
1002
+ ui: {
1003
+ ...state.ui,
1004
+ layout: {
1005
+ ...(state.ui.layout || {}),
1006
+ diagnosticsPaneAdjust: Number(action.diagnosticsPaneAdjust) || 0,
1007
+ },
1008
+ },
1009
+ };
1010
+
949
1011
  case "ui/activityPaneAdjust":
950
1012
  return {
951
1013
  ...state,
@@ -2638,24 +2700,114 @@ export function appReducer(state, action) {
2638
2700
  }
2639
2701
  return {
2640
2702
  ...state,
2641
- ui: { ...state.ui, rightPaneMode: mode },
2703
+ ui: { ...state.ui, rightPaneMode: mode, canvasOpen: mode === "canvas" },
2704
+ canvas: { ...state.canvas, prefs },
2705
+ };
2706
+ }
2707
+
2708
+ // Canvas as a column of its own. Same opt-out bookkeeping as the old
2709
+ // mode switch above — closing by hand still says "do not flip me back",
2710
+ // opening by hand still withdraws that — but closing the canvas no
2711
+ // longer implies showing anything else, because Diagnostics is now a
2712
+ // separate column with its own toggle.
2713
+ //
2714
+ // `open` may be omitted to mean "toggle".
2715
+ case "ui/canvasOpen": {
2716
+ const open = typeof action.open === "boolean" ? action.open : !state.ui.canvasOpen;
2717
+ const sessionId = action.sessionId || state.sessions.activeSessionId || null;
2718
+ let prefs = state.canvas.prefs;
2719
+ if (sessionId) {
2720
+ const prev = prefs[sessionId] || { optedOut: false, lastViewedRev: 0 };
2721
+ if (open && prev.optedOut) {
2722
+ prefs = { ...prefs, [sessionId]: { ...prev, optedOut: false } };
2723
+ } else if (!open && action.manual && !prev.optedOut) {
2724
+ prefs = { ...prefs, [sessionId]: { ...prev, optedOut: true } };
2725
+ }
2726
+ }
2727
+ return {
2728
+ ...state,
2729
+ // rightPaneMode is kept in step for anything still reading it.
2730
+ // Closing always drops full screen: otherwise the next open
2731
+ // comes back covering the workspace with no warning.
2732
+ ui: {
2733
+ ...state.ui,
2734
+ canvasOpen: open,
2735
+ rightPaneMode: open ? "canvas" : "panes",
2736
+ ...(open ? {} : { canvasMaximized: false, canvasZen: false }),
2737
+ },
2642
2738
  canvas: { ...state.canvas, prefs },
2643
2739
  };
2644
2740
  }
2645
2741
 
2742
+ // Diagnostics — today's Inspector + Activity, travelling as one column.
2743
+ // No canvas bookkeeping: it has nothing to do with the canvas, which is
2744
+ // the whole point of splitting the old enum in two.
2745
+ // Zen: sessions hidden, chat a narrow rail, canvas the workbench.
2746
+ // Turning it on opens the canvas; it means nothing without one.
2747
+ case "ui/canvasZen": {
2748
+ const on = typeof action.on === "boolean" ? action.on : !state.ui.canvasZen;
2749
+ if (on === Boolean(state.ui.canvasZen)) return state;
2750
+ return {
2751
+ ...state,
2752
+ ui: { ...state.ui, canvasZen: on, ...(on ? { canvasOpen: true, rightPaneMode: "canvas" } : {}) },
2753
+ };
2754
+ }
2755
+
2756
+ // The zen chat-rail width for one session. Clamped so a wild drag can
2757
+ // never wedge the rail off screen or crush the canvas.
2758
+ case "canvas/zenRail": {
2759
+ const sessionId = String(action.sessionId || "").trim();
2760
+ const px = Number(action.px);
2761
+ if (!sessionId || !Number.isFinite(px)) return state;
2762
+ const prev = state.canvas.prefs[sessionId] || { optedOut: false, lastViewedRev: 0 };
2763
+ const clamped = Math.max(260, Math.min(720, Math.round(px)));
2764
+ return {
2765
+ ...state,
2766
+ canvas: { ...state.canvas, prefs: { ...state.canvas.prefs, [sessionId]: { ...prev, zenRailPx: clamped } } },
2767
+ };
2768
+ }
2769
+
2770
+ // Full-screen canvas. The header stays put and the canvas covers the
2771
+ // workspace below it, so there is always a way back.
2772
+ case "ui/canvasMaximized": {
2773
+ const on = typeof action.on === "boolean" ? action.on : !state.ui.canvasMaximized;
2774
+ if (on === Boolean(state.ui.canvasMaximized)) return state;
2775
+ return { ...state, ui: { ...state.ui, canvasMaximized: on } };
2776
+ }
2777
+
2778
+ case "ui/diagnosticsOpen": {
2779
+ const open = typeof action.open === "boolean" ? action.open : !state.ui.diagnosticsOpen;
2780
+ if (open === state.ui.diagnosticsOpen) return state;
2781
+ return { ...state, ui: { ...state.ui, diagnosticsOpen: open } };
2782
+ }
2783
+
2646
2784
  // The agent-driven flip. The guards (active session, freshness,
2647
2785
  // opt-out) live in the controller; by the time this dispatches the
2648
2786
  // flip is decided. flipSeq ticks even when the mode is already
2649
2787
  // "canvas" — a phone can be off the canvas tab while the persisted
2650
2788
  // mode still says canvas, and the tick is what brings its tab back.
2651
2789
  case "canvas/flip": {
2790
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2791
+ ? Number(action.slot) : 1;
2652
2792
  return {
2653
2793
  ...state,
2654
- ui: { ...state.ui, rightPaneMode: "canvas" },
2794
+ // Opens the canvas column ON THE SLOT THAT DREW; leaves
2795
+ // Diagnostics exactly as the user left it. An agent drawing
2796
+ // must not close their panels.
2797
+ ui: { ...state.ui, rightPaneMode: "canvas", canvasOpen: true, canvasSlot: slot },
2655
2798
  canvas: { ...state.canvas, flipSeq: (state.canvas.flipSeq || 0) + 1 },
2656
2799
  };
2657
2800
  }
2658
2801
 
2802
+ // Which of the active session's canvases the pane shows. In-memory
2803
+ // only: a reload lands on slot 1 (or wherever the next flip points).
2804
+ case "ui/canvasSlot": {
2805
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2806
+ ? Number(action.slot) : 1;
2807
+ if (slot === (state.ui.canvasSlot || 1)) return state;
2808
+ return { ...state, ui: { ...state.ui, canvasSlot: slot } };
2809
+ }
2810
+
2659
2811
  // The surface's receipt: revision `rev` (a draw) and/or `dataRev` (a
2660
2812
  // tick) actually rendered on screen. Both feed the unseen badge.
2661
2813
  case "canvas/viewed": {
@@ -2663,7 +2815,8 @@ export function appReducer(state, action) {
2663
2815
  if (!sessionId) return state;
2664
2816
  const rev = Number(action.rev);
2665
2817
  const dataRev = Number(action.dataRev);
2666
- const prev = state.canvas.prefs[sessionId] || { optedOut: false, lastViewedRev: 0, lastViewedDataRev: 0 };
2818
+ const key = canvasKey(sessionId, action.slot);
2819
+ const prev = state.canvas.prefs[key] || { optedOut: false, lastViewedRev: 0, lastViewedDataRev: 0 };
2667
2820
  const nextRev = Number.isFinite(rev) && rev > (prev.lastViewedRev || 0) ? rev : (prev.lastViewedRev || 0);
2668
2821
  const nextDataRev = Number.isFinite(dataRev) && dataRev > (prev.lastViewedDataRev || 0) ? dataRev : (prev.lastViewedDataRev || 0);
2669
2822
  if (nextRev === (prev.lastViewedRev || 0) && nextDataRev === (prev.lastViewedDataRev || 0)) return state;
@@ -2671,16 +2824,49 @@ export function appReducer(state, action) {
2671
2824
  ...state,
2672
2825
  canvas: {
2673
2826
  ...state.canvas,
2674
- prefs: { ...state.canvas.prefs, [sessionId]: { ...prev, lastViewedRev: nextRev, lastViewedDataRev: nextDataRev } },
2675
- },
2676
- };
2827
+ prefs: { ...state.canvas.prefs, [key]: { ...prev, lastViewedRev: nextRev, lastViewedDataRev: nextDataRev } },
2828
+ },
2829
+ };
2830
+ }
2831
+
2832
+ // The sessions-list seed: rev+name per slot from session_canvases,
2833
+ // merged under the live entries. Never regresses a rev a live event
2834
+ // already delivered, never sets snapshotLoaded (the full snapshot —
2835
+ // note, contract, data replay — still loads on selection).
2836
+ case "canvas/seed": {
2837
+ const entries = Array.isArray(action.entries) ? action.entries : [];
2838
+ if (!entries.length) return state;
2839
+ let next = null;
2840
+ for (const e of entries) {
2841
+ const sessionId = String(e?.sessionId || "").trim();
2842
+ const rev = Number(e?.latestRev);
2843
+ const slotN = Number(e?.slot);
2844
+ if (!sessionId || !Number.isFinite(rev) || rev <= 0) continue;
2845
+ if (!Number.isInteger(slotN) || slotN < 1 || slotN > 5) continue;
2846
+ const key = canvasKey(sessionId, slotN);
2847
+ const existing = (next || state.canvas.bySessionId)[key] || {};
2848
+ if ((existing.latestRev || 0) >= rev && existing.name !== undefined) continue;
2849
+ next = next || { ...state.canvas.bySessionId };
2850
+ next[key] = {
2851
+ ...existing,
2852
+ slot: slotN,
2853
+ latestRev: Math.max(existing.latestRev || 0, rev),
2854
+ ...(typeof e.name === "string" && existing.name === undefined ? { name: e.name } : {}),
2855
+ ...(existing.sizeBytes === undefined && e.sizeBytes !== undefined ? { sizeBytes: e.sizeBytes } : {}),
2856
+ };
2857
+ }
2858
+ if (!next) return state;
2859
+ return { ...state, canvas: { ...state.canvas, bySessionId: next } };
2677
2860
  }
2678
2861
 
2679
2862
  case "canvas/updated": {
2680
2863
  const sessionId = String(action.sessionId || "").trim();
2681
2864
  const rev = Number(action.rev);
2682
2865
  if (!sessionId || !Number.isFinite(rev) || rev <= 0) return state;
2683
- const existing = state.canvas.bySessionId[sessionId] || {};
2866
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2867
+ ? Number(action.slot) : 1;
2868
+ const key = canvasKey(sessionId, slot);
2869
+ const existing = state.canvas.bySessionId[key] || {};
2684
2870
  // Monotonic: replays and out-of-order merges must never regress
2685
2871
  // the known revision (freshness gates the FLIP, never the data).
2686
2872
  if ((existing.latestRev || 0) >= rev) return state;
@@ -2690,8 +2876,10 @@ export function appReducer(state, action) {
2690
2876
  ...state.canvas,
2691
2877
  bySessionId: {
2692
2878
  ...state.canvas.bySessionId,
2693
- [sessionId]: {
2879
+ [key]: {
2694
2880
  ...existing,
2881
+ slot,
2882
+ ...(typeof action.name === "string" ? { name: action.name } : {}),
2695
2883
  latestRev: rev,
2696
2884
  note: typeof action.note === "string" ? action.note : "",
2697
2885
  sizeBytes: Number.isFinite(Number(action.sizeBytes)) ? Number(action.sizeBytes) : null,
@@ -2712,9 +2900,12 @@ export function appReducer(state, action) {
2712
2900
  // latestDataRev vs lastViewedDataRev. Monotonic like the draw rev.
2713
2901
  case "canvas/data": {
2714
2902
  const sessionId = String(action.sessionId || "").trim();
2903
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2904
+ ? Number(action.slot) : 1;
2905
+ const key = canvasKey(sessionId, slot);
2715
2906
  const dataRev = Number(action.dataRev);
2716
2907
  if (!sessionId || !Number.isFinite(dataRev) || dataRev <= 0) return state;
2717
- const existing = state.canvas.bySessionId[sessionId] || {};
2908
+ const existing = state.canvas.bySessionId[key] || {};
2718
2909
  if ((existing.latestDataRev || 0) >= dataRev) return state;
2719
2910
  return {
2720
2911
  ...state,
@@ -2722,7 +2913,7 @@ export function appReducer(state, action) {
2722
2913
  ...state.canvas,
2723
2914
  bySessionId: {
2724
2915
  ...state.canvas.bySessionId,
2725
- [sessionId]: {
2916
+ [key]: {
2726
2917
  ...existing,
2727
2918
  latestDataRev: dataRev,
2728
2919
  dataPayload: action.payload && typeof action.payload === "object" ? action.payload : null,
@@ -2735,7 +2926,10 @@ export function appReducer(state, action) {
2735
2926
  case "canvas/snapshot": {
2736
2927
  const sessionId = String(action.sessionId || "").trim();
2737
2928
  if (!sessionId) return state;
2738
- const existing = state.canvas.bySessionId[sessionId] || {};
2929
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2930
+ ? Number(action.slot) : 1;
2931
+ const key = canvasKey(sessionId, slot);
2932
+ const existing = state.canvas.bySessionId[key] || {};
2739
2933
  const rev = Number(action.rev) || 0;
2740
2934
  return {
2741
2935
  ...state,
@@ -2743,8 +2937,10 @@ export function appReducer(state, action) {
2743
2937
  ...state.canvas,
2744
2938
  bySessionId: {
2745
2939
  ...state.canvas.bySessionId,
2746
- [sessionId]: {
2940
+ [key]: {
2747
2941
  ...existing,
2942
+ slot,
2943
+ ...(typeof action.name === "string" && ((existing.latestRev || 0) <= rev) ? { name: action.name } : {}),
2748
2944
  // Events may have beaten the snapshot; never regress.
2749
2945
  latestRev: Math.max(existing.latestRev || 0, rev),
2750
2946
  note: (existing.latestRev || 0) > rev ? existing.note : (typeof action.note === "string" ? action.note : existing.note || ""),
@@ -2765,6 +2961,8 @@ export function appReducer(state, action) {
2765
2961
  const sessionId = String(action.sessionId || "").trim();
2766
2962
  const existing = state.canvas.bySessionId[sessionId];
2767
2963
  if (!sessionId || !existing?.snapshotLoaded) return state;
2964
+ // Slots 2-5 of the same session invalidate with slot 1: the flag
2965
+ // that gates re-take lives on the slot-1 entry.
2768
2966
  return {
2769
2967
  ...state,
2770
2968
  canvas: {