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
@@ -252,6 +252,16 @@ function normalizeProfileSettings(settings) {
252
252
  if (candidate.rightPaneMode === "canvas" || candidate.rightPaneMode === "panes") {
253
253
  normalized.rightPaneMode = candidate.rightPaneMode;
254
254
  }
255
+ // The two independent desktop columns. This normalizer WHITELISTS keys, so
256
+ // an unlisted one is dropped on the way to the server without a word —
257
+ // which is exactly how a toggle looks like it never persisted.
258
+ if (hasOwn(candidate, "desktopPanes") && candidate.desktopPanes && typeof candidate.desktopPanes === "object") {
259
+ normalized.desktopPanes = {
260
+ canvasOpen: candidate.desktopPanes.canvasOpen === true,
261
+ diagnosticsOpen: candidate.desktopPanes.diagnosticsOpen === true,
262
+ zen: candidate.desktopPanes.zen === true,
263
+ };
264
+ }
255
265
  if (hasOwn(candidate, "canvasPrefs")) {
256
266
  normalized.canvasPrefs = normalizeStoredCanvasPrefs(candidate.canvasPrefs);
257
267
  }
@@ -319,7 +329,7 @@ function hasOwn(value, key) {
319
329
  return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
320
330
  }
321
331
 
322
- function profileSettingsFromViewState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null) {
332
+ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null, preservedDesktopPanes = null) {
323
333
  return normalizeProfileSettings({
324
334
  themeId: state.themeId,
325
335
  [touchScaleKey()]: state.touchScale,
@@ -329,6 +339,9 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
329
339
  sessionPaneAdjust: state.sessionPaneAdjust,
330
340
  portalSessionColumnAdjust: state.portalSessionColumnAdjust,
331
341
  activityPaneAdjust: state.activityPaneAdjust,
342
+ canvasPaneAdjust: state.canvasPaneAdjust,
343
+ diagnosticsPaneAdjust: state.diagnosticsPaneAdjust,
344
+ diagnosticsSplitAdjust: state.diagnosticsSplitAdjust,
332
345
  },
333
346
  pinnedSessionIds: state.pinnedIds,
334
347
  sessionOrder: state.manualOrder,
@@ -339,11 +352,23 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
339
352
  // write its own value — it preserves the last-known stored one, or a
340
353
  // desktop that flipped to canvas would be flipped back by the phone's
341
354
  // next save.
355
+ //
356
+ // desktopPanes replaces it and is preserved the same way, for the same
357
+ // reason. rightPaneMode is still written so a rolled-back build reads
358
+ // something sane.
342
359
  ...(isNarrowViewport()
343
- ? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
344
- ? { rightPaneMode: preservedRightPaneMode }
345
- : {})
346
- : { rightPaneMode: state.rightPaneMode }),
360
+ ? {
361
+ ...(preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
362
+ ? { rightPaneMode: preservedRightPaneMode }
363
+ : {}),
364
+ ...(preservedDesktopPanes && typeof preservedDesktopPanes === "object"
365
+ ? { desktopPanes: preservedDesktopPanes }
366
+ : {}),
367
+ }
368
+ : {
369
+ rightPaneMode: state.canvasOpen ? "canvas" : "panes",
370
+ desktopPanes: { canvasOpen: state.canvasOpen === true, diagnosticsOpen: state.diagnosticsOpen === true, zen: state.canvasZen === true },
371
+ }),
347
372
  canvasPrefs: state.canvasPrefs,
348
373
  // setCurrentUserProfileSettings REPLACES the settings object, so the
349
374
  // other device class's slot has to be written back verbatim or saving
@@ -374,7 +399,14 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherTouchScale =
374
399
  ? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
375
400
  ? { rightPaneMode: preservedRightPaneMode }
376
401
  : {})
377
- : { rightPaneMode: state?.ui?.rightPaneMode }),
402
+ : {
403
+ rightPaneMode: state?.ui?.rightPaneMode,
404
+ desktopPanes: {
405
+ canvasOpen: state?.ui?.canvasOpen === true,
406
+ diagnosticsOpen: state?.ui?.diagnosticsOpen === true,
407
+ zen: state?.ui?.canvasZen === true,
408
+ },
409
+ }),
378
410
  canvasPrefs: state?.canvas?.prefs,
379
411
  ...(typeof preservedOtherTouchScale === "boolean"
380
412
  ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
@@ -423,6 +455,9 @@ function materializeProfileSettings(remoteSettings, defaults) {
423
455
  // clobber a local flip/badge advance the server has not persisted yet.
424
456
  // An omitted key leaves the profileSettings/apply guards preserving
425
457
  // the current value.
458
+ ...(hasOwn(normalizedRemote, "desktopPanes")
459
+ ? { desktopPanes: normalizedRemote.desktopPanes }
460
+ : {}),
426
461
  ...(hasOwn(normalizedRemote, "rightPaneMode")
427
462
  ? { rightPaneMode: normalizedRemote.rightPaneMode }
428
463
  : {}),
@@ -475,6 +510,65 @@ function clampNumber(value, min, max) {
475
510
  return Math.max(min, Math.min(max, value));
476
511
  }
477
512
 
513
+ /**
514
+ * Below this, releasing a drag CLOSES the column rather than leaving a sliver.
515
+ *
516
+ * "Resized to 0 toggles it off" is the rule, but a pointer rarely lands on
517
+ * exactly 0 — without a threshold you get a 3px column you cannot read, cannot
518
+ * grab, and did not ask for. 120px is about the narrowest either column is
519
+ * still useful at.
520
+ */
521
+ const PORTAL_COLUMN_SNAP_CLOSED_PX = 120;
522
+
523
+ /** Width of the draggable track between the canvas and diagnostics columns. */
524
+ const PORTAL_COLUMN_RESIZER_PX = 16;
525
+
526
+ /**
527
+ * The narrowest the canvas should OPEN at. Not a clamp — drag it smaller if
528
+ * you like, or drag it away entirely.
529
+ *
530
+ * A canvas is a rendered page being looked at, so it needs enough width for a
531
+ * normal document column. The inherited fr split left it near 200px.
532
+ */
533
+ const PORTAL_CANVAS_MIN_PX = 480;
534
+
535
+ /**
536
+ * Wide enough to OPEN the canvas by default on a first visit.
537
+ *
538
+ * Sessions and chat need roughly 224px + 500px to stay comfortable, and the
539
+ * canvas wants PORTAL_CANVAS_MIN_PX beside them. 1440 clears that with room to
540
+ * spare and is a real device width (a 14" MacBook, a 900p desktop), so the
541
+ * common laptop gets a canvas and a small window does not get three cramped
542
+ * columns.
543
+ */
544
+ const PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX = 1440;
545
+
546
+ /**
547
+ * Whether this profile has ever expressed a preference about the two columns.
548
+ *
549
+ * The width-based default must fire only in its absence: someone who CLOSED
550
+ * the canvas has stored `{canvasOpen: false}`, which is a choice and reads
551
+ * nothing like "unset". A legacy rightPaneMode counts too — it is the same
552
+ * preference in the older shape.
553
+ */
554
+ function hasStoredDesktopPanes(settings) {
555
+ if (!settings || typeof settings !== "object") return false;
556
+ if (settings.desktopPanes && typeof settings.desktopPanes === "object") return true;
557
+ return settings.rightPaneMode === "canvas" || settings.rightPaneMode === "panes";
558
+ }
559
+
560
+ /**
561
+ * The widths the two optional columns OPEN at, in pixels. 2:1 — the canvas is
562
+ * the thing being looked at; diagnostics is a readout beside it.
563
+ *
564
+ * Pixels, not shares of the window, because of the rule the fr model broke:
565
+ * when a column closes, the freed space goes to chat + sessions — never to
566
+ * the surviving column. A pane owns its width; chat flexes.
567
+ */
568
+ const PORTAL_CANVAS_COL_DEFAULT_PX = 640;
569
+ const PORTAL_DIAG_COL_DEFAULT_PX = 320;
570
+
571
+
478
572
  function portalSessionColumnBounds(width) {
479
573
  const safeWidth = Math.max(0, Number(width) || 0);
480
574
  const availableWidth = Math.max(
@@ -4056,6 +4150,7 @@ function SessionRowContent({ row, theme, structured = false, showInlineDetail =
4056
4150
  return React.createElement(React.Fragment, null,
4057
4151
  plain.statusColor ? React.createElement(StatusDot, { color: plain.statusColor, node: row.depth > 0 }) : null,
4058
4152
  React.createElement(OwnerAvatar, { badge: row.ownerBadge }),
4153
+ React.createElement(SessionCanvasMark, { mark: row.canvasMark }),
4059
4154
  React.createElement("span", { className: "ps-session-row-title__text" },
4060
4155
  React.createElement(Runs, { runs: plain.rest, theme })));
4061
4156
  }
@@ -4078,6 +4173,7 @@ function SessionRowContent({ row, theme, structured = false, showInlineDetail =
4078
4173
  React.createElement("div", { className: "ps-session-row-title" },
4079
4174
  title.statusColor ? React.createElement(StatusDot, { color: title.statusColor, node: row.depth > 0 }) : null,
4080
4175
  React.createElement(OwnerAvatar, { badge: row.ownerBadge }),
4176
+ React.createElement(SessionCanvasMark, { mark: row.canvasMark }),
4081
4177
  // Runs wrapped in ONE element: the title is a flex row so the
4082
4178
  // avatar centres against the text, and the runs keep normal
4083
4179
  // inline flow inside the wrapper. Flexing the runs directly
@@ -4233,6 +4329,12 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4233
4329
  // so the reconstruction below must carry it or the click does nothing
4234
4330
  // visible (the same omission that blinded the Node Map).
4235
4331
  listDeselected: Boolean(state.sessions.listDeselected),
4332
+ // The canvas markers read these. This synthetic state is exactly the
4333
+ // trap the branding comment below describes: omit a slice here and
4334
+ // every row computes as if it were empty — the markers were null on
4335
+ // every row until these two lines existed.
4336
+ canvasBySessionId: state.canvas?.bySessionId,
4337
+ canvasPrefs: state.canvas?.prefs,
4236
4338
  }), shallowEqualObject);
4237
4339
  const computedRows = React.useMemo(() => selectSessionRows({
4238
4340
  sessions: {
@@ -4256,7 +4358,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4256
4358
  // deployment, while the controller's own calls (which pass real state)
4257
4359
  // produced the branded name.
4258
4360
  branding: viewState.branding,
4259
- }), [viewState.activeSessionId, viewState.auth, viewState.branding, viewState.connectionMode, viewState.filterQuery, viewState.listDeselected, viewState.ownerFilter, viewState.pinnedIds, viewState.manualOrder, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
4361
+ canvas: {
4362
+ bySessionId: viewState.canvasBySessionId,
4363
+ prefs: viewState.canvasPrefs,
4364
+ },
4365
+ }), [viewState.activeSessionId, viewState.auth, viewState.branding, viewState.canvasBySessionId, viewState.canvasPrefs, viewState.connectionMode, viewState.filterQuery, viewState.listDeselected, viewState.ownerFilter, viewState.pinnedIds, viewState.manualOrder, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
4260
4366
  // Hold the previous rows when a poll produced identical output.
4261
4367
  const rows = useStableValue(computedRows);
4262
4368
  const activeSession = viewState.activeSessionId
@@ -5941,6 +6047,49 @@ function ArtifactTakeoverPane({ controller, onClose }) {
5941
6047
  })));
5942
6048
  }
5943
6049
 
6050
+ /**
6051
+ * The phone's canvas: a LAYER over the content region, not a full screen.
6052
+ *
6053
+ * It used to be a fixed, full-viewport overlay — the portal header and the
6054
+ * toolbar disappeared behind it too, so looking at a drawing meant leaving the
6055
+ * app and finding your way back. It now fills exactly the region the Main,
6056
+ * Activity and Inspector panes share: top edge under the toolbar, bottom edge
6057
+ * at the viewport, edge to edge, no inset. The header and the toolbar (with
6058
+ * its Canvas button, the only way in or out — there is no ✕ here) stay put.
6059
+ *
6060
+ * It is rendered as a SIBLING of that region's content, not inside any pane,
6061
+ * because the frame has to outlive every pane: the canvas runs interactive
6062
+ * pages, and switching to Inspector and back must not reset a running game.
6063
+ *
6064
+ * Maximized, it goes truly full-screen: the same element flips from absolute
6065
+ * inside the region to fixed over the whole viewport, covering the portal
6066
+ * header and the toolbar too. That is a CLASS change on one div and nothing
6067
+ * else — the frame is never moved in the DOM, so a game survives the toggle.
6068
+ * The thin strip persists in both states; only the right-hand button flips.
6069
+ */
6070
+ function MobileCanvasLayer({ controller, visible = true }) {
6071
+ const [maximized, setMaximized] = React.useState(false);
6072
+ const toggleMaximized = React.useCallback(() => setMaximized((current) => !current), []);
6073
+ // Leaving the canvas drops full-screen, so coming back lands in the inset
6074
+ // presentation rather than a takeover the user has to undo before they can
6075
+ // reach the toolbar again.
6076
+ React.useEffect(() => {
6077
+ if (!visible) setMaximized(false);
6078
+ }, [visible]);
6079
+ // Kept MOUNTED while hidden: unmounting destroys the sandboxed document,
6080
+ // and with it everything the user typed and scrolled. The hidden state is
6081
+ // an off-screen transform (see .ps-mobile-canvas-layer.is-hidden), so the
6082
+ // frame keeps a real, unchanged box and no settle-reload fires on re-show.
6083
+ return React.createElement("div", {
6084
+ className: `ps-mobile-canvas-layer${maximized ? " is-maximized" : ""}${visible ? "" : " is-hidden"}`,
6085
+ ...(visible ? {} : { inert: true, "aria-hidden": "true" }),
6086
+ },
6087
+ React.createElement(CanvasPane, {
6088
+ controller, mobile: true, visible, focusOnPromote: true,
6089
+ maximized, onToggleMaximized: toggleMaximized,
6090
+ }));
6091
+ }
6092
+
5944
6093
  /**
5945
6094
  * Mobile artifact viewer — a genuine full-viewport overlay, not a pane.
5946
6095
  *
@@ -5949,19 +6098,6 @@ function ArtifactTakeoverPane({ controller, onClose }) {
5949
6098
  * phone should feel like pushing a detail screen, the way a native app does,
5950
6099
  * not like shrinking content into one more box. Desktop never mounts this.
5951
6100
  */
5952
- /**
5953
- * The phone's canvas: FULL-SCREEN, opened by the same toolbar toggle the
5954
- * desktop has, closed by the pane's ✕ back to whatever was on screen. The
5955
- * former bottom-nav tab is gone — one affordance on every device class.
5956
- */
5957
- function MobileCanvasOverlay({ controller, onClose, visible = true }) {
5958
- // Kept MOUNTED while hidden: unmounting destroys the sandboxed document,
5959
- // and with it everything the user typed and scrolled. visibility (not
5960
- // display) keeps layout real so no resize-reload fires on re-show.
5961
- return React.createElement("div", { className: `ps-artifact-overlay ps-canvas-overlay${visible ? "" : " is-hidden"}`, ...(visible ? {} : { inert: true, "aria-hidden": "true" }) },
5962
- React.createElement(CanvasPane, { controller, mobile: true, onClose, visible, focusOnPromote: true }));
5963
- }
5964
-
5965
6101
  function MobileArtifactOverlay({ controller }) {
5966
6102
  const view = useControllerSelector(controller, (state) => {
5967
6103
  const id = state.files.selectedArtifactId || "";
@@ -6066,6 +6202,114 @@ function CanvasGlyph() {
6066
6202
  React.createElement("path", { d: "M7 17.5h6M10 14.5v3", ...stroke }));
6067
6203
  }
6068
6204
 
6205
+ /**
6206
+ * Diagnostics: a bug, for "debug".
6207
+ *
6208
+ * Not a cog — the admin console owns that — and not sliders, which the filter
6209
+ * funnel is already close enough to.
6210
+ */
6211
+ function DiagnosticsGlyph() {
6212
+ // Bigger than its neighbours on purpose, and drawn heavier. A bug is a
6213
+ // busier shape than a funnel or a cog: at 16px with hairline legs it
6214
+ // collapsed into a smudge, so it takes 20px and a thicker stroke to read
6215
+ // as the same visual weight.
6216
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" };
6217
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 20, height: 20, "aria-hidden": "true" },
6218
+ // Shell, with the seam that says beetle rather than pill.
6219
+ React.createElement("path", { d: "M6.2 8.6a3.8 3.8 0 0 1 7.6 0v2.9a3.8 3.8 0 0 1-7.6 0z", ...stroke }),
6220
+ React.createElement("path", { d: "M6.2 10.3h7.6", ...stroke }),
6221
+ // Head and antennae.
6222
+ React.createElement("path", { d: "M7.6 6.5a2.4 2.4 0 0 1 4.8 0", ...stroke }),
6223
+ React.createElement("path", { d: "M8 4.4 6.9 2.9M12 4.4l1.1-1.5", ...stroke }),
6224
+ // Legs: three a side, the outer two angled so the silhouette reads.
6225
+ React.createElement("path", { d: "M6.3 8.6 3.8 7.4M6.2 10.6H3.6M6.3 12.6 3.8 13.9", ...stroke }),
6226
+ React.createElement("path", { d: "M13.7 8.6 16.2 7.4M13.8 10.6h2.6M13.7 12.6l2.5 1.3", ...stroke }));
6227
+ }
6228
+
6229
+ /**
6230
+ * The canvas marker on a session row.
6231
+ *
6232
+ * Two states, and they must be tellable apart at a glance in a dense list:
6233
+ * "canvas" outline only — a canvas exists here
6234
+ * "unseen" outline plus a filled dot — it changed since you last looked
6235
+ *
6236
+ * Shape carries the meaning, not colour alone: the dot is visible in either
6237
+ * theme and to a reader who cannot separate the two hues.
6238
+ */
6239
+ function SessionCanvasMark({ mark }) {
6240
+ if (mark !== "canvas" && mark !== "unseen") return null;
6241
+ const unseen = mark === "unseen";
6242
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
6243
+ return React.createElement("span", {
6244
+ className: `ps-session-canvas-mark${unseen ? " is-unseen" : ""}`,
6245
+ title: unseen ? "Canvas updated since you last viewed it" : "This session has a canvas",
6246
+ "aria-label": unseen ? "Canvas updated" : "Has a canvas",
6247
+ role: "img",
6248
+ },
6249
+ React.createElement("svg", { viewBox: "0 0 16 16", width: 12, height: 12, "aria-hidden": "true" },
6250
+ React.createElement("rect", { x: 1.6, y: 2.6, width: 12.8, height: 9.2, rx: 1.4, ...stroke }),
6251
+ React.createElement("path", { d: "M6 14.4h4", ...stroke }),
6252
+ unseen
6253
+ ? React.createElement("circle", { cx: 12.4, cy: 4.6, r: 2.4, fill: "currentColor", stroke: "none" })
6254
+ : null));
6255
+ }
6256
+
6257
+ /**
6258
+ * The canvas slot controls: a dropdown when the session has more than one
6259
+ * drawn canvas, plus prev/next arrows. Hidden entirely with one canvas —
6260
+ * the common case stays exactly as it always looked. Slot state is
6261
+ * ui.canvasSlot (in-memory; the selector clamps to a drawn slot).
6262
+ */
6263
+ function CanvasSlotControls({ controller, view, compact = false }) {
6264
+ const slots = Array.isArray(view.slots) ? view.slots : [];
6265
+ if (slots.length <= 1) return null;
6266
+ const order = slots.map((s2) => s2.slot);
6267
+ const at = Math.max(0, order.indexOf(view.slot));
6268
+ const go = (delta) => {
6269
+ const next = order[(at + delta + order.length) % order.length];
6270
+ controller.dispatch({ type: "ui/canvasSlot", slot: next });
6271
+ };
6272
+ const labelFor = (s2) => (s2.name ? s2.name : `canvas ${s2.slot}`) + (s2.unseen ? " •" : "");
6273
+ return React.createElement("span", { className: `ps-canvas-slot-controls${compact ? " is-compact" : ""}` },
6274
+ React.createElement("button", {
6275
+ type: "button", className: "ps-artifact-pane-btn",
6276
+ onClick: () => go(-1), title: "Previous canvas", "aria-label": "Previous canvas",
6277
+ }, "\u2039"),
6278
+ React.createElement("select", {
6279
+ className: "ps-canvas-slot-select",
6280
+ value: view.slot,
6281
+ onChange: (e) => controller.dispatch({ type: "ui/canvasSlot", slot: Number(e.target.value) }),
6282
+ "aria-label": "Choose canvas",
6283
+ title: "Choose canvas",
6284
+ }, slots.map((s2) => React.createElement("option", { key: s2.slot, value: s2.slot }, labelFor(s2)))),
6285
+ React.createElement("button", {
6286
+ type: "button", className: "ps-artifact-pane-btn",
6287
+ onClick: () => go(1), title: "Next canvas", "aria-label": "Next canvas",
6288
+ }, "\u203a"));
6289
+ }
6290
+
6291
+ /** Zen: a narrow chat rail beside a wide canvas. */
6292
+ function ZenGlyph() {
6293
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" };
6294
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 15, height: 15, "aria-hidden": "true" },
6295
+ React.createElement("rect", { x: 2.2, y: 3.4, width: 3.6, height: 13.2, rx: 1, ...stroke }),
6296
+ React.createElement("rect", { x: 8.2, y: 3.4, width: 9.6, height: 13.2, rx: 1, ...stroke }));
6297
+ }
6298
+
6299
+ /** Corners pushing OUT — take the whole screen. */
6300
+ function MaximizeGlyph() {
6301
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
6302
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 13, height: 13, "aria-hidden": "true" },
6303
+ React.createElement("path", { d: "M8 3H3v5M12 3h5v5M12 17h5v-5M8 17H3v-5", ...stroke }));
6304
+ }
6305
+
6306
+ /** Corners pulling IN — back to the canvas inset in the content region. */
6307
+ function RestoreGlyph() {
6308
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
6309
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 13, height: 13, "aria-hidden": "true" },
6310
+ React.createElement("path", { d: "M3 8h5V3M17 8h-5V3M17 12h-5v5M3 12h5v5", ...stroke }));
6311
+ }
6312
+
6069
6313
  /**
6070
6314
  * Double-buffered canvas frame with PER-REVISION blob URLs.
6071
6315
  *
@@ -6076,7 +6320,7 @@ function CanvasGlyph() {
6076
6320
  * loads hidden-but-painted behind the live frame and is promoted on load, so
6077
6321
  * a live redraw is a composite swap of something already drawn.
6078
6322
  */
6079
- function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null }) {
6323
+ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null }) {
6080
6324
  const [live, setLive] = React.useState(null); // { url, rev }
6081
6325
  const [staging, setStaging] = React.useState(null); // { url, rev }
6082
6326
  const [loadError, setLoadError] = React.useState(null); // { rev }
@@ -6131,7 +6375,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6131
6375
  if (!controller || !sessionId || !latestRev) return undefined;
6132
6376
  let cancelled = false;
6133
6377
  let retryTimer = null;
6134
- fetchArtifactHtmlObjectUrl(controller, sessionId, "canvas.html")
6378
+ fetchArtifactHtmlObjectUrl(controller, sessionId, slot <= 1 ? "canvas.html" : `canvas${slot}.html`)
6135
6379
  .then((url) => {
6136
6380
  if (cancelled) { URL.revokeObjectURL(url); return; }
6137
6381
  // A staging revision that never loaded is superseded here —
@@ -6159,7 +6403,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6159
6403
  cancelled = true;
6160
6404
  if (retryTimer) clearTimeout(retryTimer);
6161
6405
  };
6162
- }, [controller, sessionId, latestRev, retryTick]);
6406
+ }, [controller, sessionId, slot, latestRev, retryTick]);
6163
6407
 
6164
6408
  // Session switch / unmount: drop both frames — a different session is a
6165
6409
  // different document, and showing the old canvas under a new session is a
@@ -6201,7 +6445,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6201
6445
  // toggle) still promotes new revisions in the background — those are
6202
6446
  // NOT viewed; the badge must light until the user actually looks.
6203
6447
  if (controller && sessionId && visibleRef.current) {
6204
- controller.dispatch({ type: "canvas/viewed", sessionId, rev: frame.rev });
6448
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, rev: frame.rev });
6205
6449
  }
6206
6450
  // iOS: a programmatic focus into the freshly promoted frame is the
6207
6451
  // synchronous interaction that registers its scroller — the code
@@ -6229,7 +6473,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6229
6473
  // On-screen injection = seen; hidden injection leaves the badge lit
6230
6474
  // until the user actually looks (the catch-up below files it then).
6231
6475
  if (controller && sessionId && visibleRef.current) {
6232
- controller.dispatch({ type: "canvas/viewed", sessionId, dataRev });
6476
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, dataRev });
6233
6477
  }
6234
6478
  }, [live, dataRev, dataPayload, controller, sessionId]);
6235
6479
 
@@ -6237,7 +6481,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6237
6481
  // viewing it — this is what clears a badge earned while hidden.
6238
6482
  React.useEffect(() => {
6239
6483
  if (visible && live && controller && sessionId) {
6240
- controller.dispatch({ type: "canvas/viewed", sessionId, rev: live.rev, ...(dataRev ? { dataRev } : {}) });
6484
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, rev: live.rev, ...(dataRev ? { dataRev } : {}) });
6241
6485
  if (focusOnPromote) {
6242
6486
  try { liveIframeElRef.current?.focus({ preventScroll: true }); } catch { /* best effort */ }
6243
6487
  }
@@ -6327,12 +6571,20 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6327
6571
  }
6328
6572
 
6329
6573
  /**
6330
- * Canvas mode for the right column: the session's standing display, rendered
6331
- * from the reserved canvas.html artifact and converging live on
6332
- * canvas_updated events. Replaces the inspector/activity split while active;
6333
- * the artifact reader still takes precedence while open and returns here.
6574
+ * The session's standing display: rendered from the reserved canvas.html
6575
+ * artifact and converging live on canvas_updated events.
6576
+ *
6577
+ * Desktop mounts it in the right column, where it replaces the
6578
+ * inspector/activity split while active (the artifact reader still takes
6579
+ * precedence while open and returns here). The phone mounts it as a layer over
6580
+ * the content region — see MobileCanvasLayer — and gets a stripped header: the
6581
+ * revision on one thin line with a maximize toggle, no zoom control and no ✕,
6582
+ * because there the toolbar's Canvas button is the only way in or out.
6583
+ *
6584
+ * maximized / onToggleMaximized are the phone's full-screen state, owned by
6585
+ * MobileCanvasLayer. Passing no handler simply omits the button.
6334
6586
  */
6335
- function CanvasPane({ controller, mobile = false, onClose = null, visible = true, focusOnPromote = false }) {
6587
+ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote = false, maximized = false, onToggleMaximized = null }) {
6336
6588
  const view = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
6337
6589
  const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
6338
6590
  const setZoom = React.useCallback((next) => {
@@ -6346,38 +6598,127 @@ function CanvasPane({ controller, mobile = false, onClose = null, visible = true
6346
6598
  if (view.sessionId) controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
6347
6599
  }, [controller, view.sessionId]);
6348
6600
 
6349
- const backToPanes = React.useCallback(() => {
6350
- controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: view.sessionId, manual: true });
6601
+ const zenOn = useControllerSelector(controller, (s) => s?.ui?.canvasZen === true);
6602
+ const [toolbarSlot, setToolbarSlot] = React.useState(null);
6603
+ React.useEffect(() => {
6604
+ if (!maximized) { setToolbarSlot(null); return undefined; }
6605
+ // The slot is rendered by the Toolbar in the SAME commit that flips
6606
+ // maximized, so poll one frame rather than racing it.
6607
+ let raf = 0;
6608
+ const find = () => {
6609
+ const el = document.getElementById("ps-toolbar-canvas-slot");
6610
+ if (el) setToolbarSlot(el);
6611
+ else raf = requestAnimationFrame(find);
6612
+ };
6613
+ find();
6614
+ return () => cancelAnimationFrame(raf);
6615
+ }, [maximized]);
6616
+ const closeCanvas = React.useCallback(() => {
6617
+ controller.dispatch({ type: "ui/canvasOpen", open: false, sessionId: view.sessionId, manual: true });
6351
6618
  }, [controller, view.sessionId]);
6352
- const close = onClose || (!mobile ? backToPanes : null);
6619
+
6620
+ // The phone's header is ONE thin line: `rev 4` on the left, the
6621
+ // maximize/restore toggle hard right. Nothing else — no ✕ (the toolbar's
6622
+ // Canvas button is the way out) and no zoom control, because every pixel
6623
+ // of height here is canvas the user can no longer see.
6624
+ //
6625
+ // The strip renders whenever there is a revision to name OR the canvas is
6626
+ // maximized. That second half is not cosmetic: maximized covers the whole
6627
+ // viewport including the toolbar, so if a cleared canvas (sizeBytes 0)
6628
+ // dropped the strip it would take the only way back with it.
6629
+ const showRevStrip = mobile && (view.exists || maximized);
6630
+ const header = mobile
6631
+ ? (showRevStrip
6632
+ ? React.createElement("header", { className: "ps-artifact-pane-bar is-rev-strip" },
6633
+ React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6634
+ view.exists ? `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}` : ""),
6635
+ React.createElement(CanvasSlotControls, { controller, view, compact: true }),
6636
+ onToggleMaximized
6637
+ ? React.createElement("button", {
6638
+ type: "button",
6639
+ className: "ps-canvas-max-btn",
6640
+ onClick: onToggleMaximized,
6641
+ title: maximized ? "Back to the inset canvas" : "Maximize — cover the whole screen",
6642
+ "aria-label": maximized ? "Restore canvas" : "Maximize canvas",
6643
+ }, React.createElement(maximized ? RestoreGlyph : MaximizeGlyph))
6644
+ : null)
6645
+ : null)
6646
+ : (maximized ? (toolbarSlot ? createPortal(React.createElement(React.Fragment, null,
6647
+ view.exists
6648
+ ? React.createElement("span", { className: "ps-toolbar-canvas-rev" }, `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}`)
6649
+ : null,
6650
+ React.createElement(CanvasSlotControls, { controller, view, compact: true }),
6651
+ view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6652
+ React.createElement("button", {
6653
+ type: "button",
6654
+ className: `ps-artifact-pane-btn${zenOn ? " is-active" : ""}`,
6655
+ // Full screen covers the workspace, so a bare zen toggle
6656
+ // changes state UNDER the layer and looks dead. From here zen
6657
+ // means "step down into zen": drop full screen in the same
6658
+ // click so the change is visible.
6659
+ onClick: () => {
6660
+ controller.dispatch({ type: "ui/canvasMaximized", on: false });
6661
+ controller.dispatch({ type: "ui/canvasZen", on: !zenOn });
6662
+ },
6663
+ title: zenOn ? "Leave zen — bring back sessions and panels" : "Zen — chat rail + canvas only",
6664
+ "aria-label": zenOn ? "Leave zen mode" : "Zen mode",
6665
+ }, React.createElement(ZenGlyph)),
6666
+ React.createElement("button", {
6667
+ type: "button",
6668
+ className: "ps-artifact-pane-btn ps-toolbar-canvas-restore",
6669
+ onClick: () => controller.dispatch({ type: "ui/canvasMaximized", on: false }),
6670
+ title: "Back to the workspace",
6671
+ "aria-label": "Restore canvas",
6672
+ }, React.createElement(RestoreGlyph))), toolbarSlot) : null)
6673
+ : React.createElement("header", { className: "ps-artifact-pane-bar" },
6674
+ React.createElement("span", { className: "ps-artifact-pane-heading" },
6675
+ React.createElement("span", { className: "ps-artifact-pane-name ps-canvas-pane-glyph", title: view.note || "Canvas" },
6676
+ React.createElement(CanvasGlyph)),
6677
+ view.exists
6678
+ ? React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6679
+ `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}`)
6680
+ : null,
6681
+ React.createElement(CanvasSlotControls, { controller, view })),
6682
+ React.createElement("span", { className: "ps-artifact-pane-actions" },
6683
+ view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6684
+ // Zen: sessions step away, chat becomes a rail, the canvas is
6685
+ // the workbench. The mode for "the work happens on the canvas,
6686
+ // chat is for steering".
6687
+ React.createElement("button", {
6688
+ type: "button",
6689
+ className: `ps-artifact-pane-btn${zenOn ? " is-active" : ""}`,
6690
+ onClick: () => controller.dispatch({ type: "ui/canvasZen" }),
6691
+ title: zenOn ? "Leave zen — bring back sessions and panels" : "Zen — chat rail + canvas only",
6692
+ "aria-label": zenOn ? "Leave zen mode" : "Zen mode",
6693
+ }, React.createElement(ZenGlyph)),
6694
+ // Full screen. No ✕ beside it: the toolbar's Canvas button is
6695
+ // the way out, and a second close control in a pane that also
6696
+ // has a toggle is one affordance too many.
6697
+ //
6698
+ // While full screen this whole bar is hidden and the rev and
6699
+ // the way back are promoted into the portal header, so the
6700
+ // control never disappears with the pane that drew it.
6701
+ React.createElement("button", {
6702
+ type: "button",
6703
+ className: "ps-artifact-pane-btn",
6704
+ onClick: () => controller.dispatch({ type: "ui/canvasMaximized", on: true }),
6705
+ title: "Full screen — hide chat and sessions",
6706
+ "aria-label": "Full screen canvas",
6707
+ }, React.createElement(MaximizeGlyph)))));
6353
6708
 
6354
6709
  return React.createElement("section", {
6355
6710
  className: "ps-artifact-pane ps-canvas-pane",
6356
6711
  role: "region",
6357
6712
  "aria-label": "Session canvas",
6358
6713
  },
6359
- React.createElement("header", { className: "ps-artifact-pane-bar" },
6360
- React.createElement("span", { className: "ps-artifact-pane-heading" },
6361
- React.createElement("span", { className: "ps-artifact-pane-name ps-canvas-pane-glyph", title: view.note || "Canvas" },
6362
- React.createElement(CanvasGlyph)),
6363
- view.exists
6364
- ? React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6365
- `rev ${view.latestRev}`)
6366
- : null),
6367
- React.createElement("span", { className: "ps-artifact-pane-actions" },
6368
- view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6369
- close ? React.createElement("button", {
6370
- type: "button",
6371
- className: "ps-artifact-pane-btn",
6372
- onClick: close,
6373
- title: mobile ? "Close the canvas" : "Back to the inspector and activity panes",
6374
- "aria-label": mobile ? "Close canvas" : "Back to panes",
6375
- }, "✕") : null)),
6714
+ header,
6376
6715
  React.createElement("div", { className: "ps-artifact-pane-body" },
6377
6716
  view.exists
6378
6717
  ? React.createElement(CanvasFrame, {
6718
+ key: `slot:${view.slot}`,
6379
6719
  controller,
6380
6720
  sessionId: view.sessionId,
6721
+ slot: view.slot,
6381
6722
  latestRev: view.latestRev,
6382
6723
  zoom,
6383
6724
  visible,
@@ -7659,7 +8000,7 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
7659
8000
  tooltipNode);
7660
8001
  }
7661
8002
 
7662
- function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvasOverlay = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
8003
+ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPane = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
7663
8004
  const [headerSlot, setHeaderSlot] = React.useState(null);
7664
8005
  React.useEffect(() => {
7665
8006
  if (typeof document === "undefined") return;
@@ -7671,6 +8012,12 @@ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvas
7671
8012
  }, [mobile]);
7672
8013
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
7673
8014
  const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
8015
+ const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
8016
+ const canvasMaximized = useControllerSelector(controller, (s) => s?.ui?.canvasMaximized === true);
8017
+ // The artifact reader takes over the right side; while it is up, the
8018
+ // surface on screen is NOT the canvas, so the canvas toggle must not
8019
+ // read as active (its next press still shows the canvas).
8020
+ const artifactReaderOpen = useControllerSelector(controller, (s) => Boolean(s?.files?.paneOpen && s?.files?.selectedArtifactId));
7674
8021
 
7675
8022
  // Icon-first toolbar: the glyph is the affordance, the label rides a
7676
8023
  // tooltip (desktop hover via title; mobile long-press via IconButton).
@@ -7698,37 +8045,54 @@ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvas
7698
8045
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
7699
8046
  },
7700
8047
  // Canvas toggle, both device classes: the desktop flips the right
7701
- // column, the phone toggles a full-screen overlay — one affordance,
7702
- // one badge. While the canvas
7703
- // is hidden, an undisplayed revision lights the badge.
8048
+ // column, the phone lays the canvas over the chat transcript — one
8049
+ // affordance, one badge, and on the phone the ONLY way in or out (the
8050
+ // canvas itself carries no ✕ there). While the canvas is hidden, an
8051
+ // undisplayed revision lights the badge.
7704
8052
  {
7705
8053
  key: "canvas",
7706
8054
  icon: React.createElement(CanvasGlyph),
7707
- label: (mobile ? canvasOverlayOpen : canvasView.mode === "canvas")
7708
- ? (mobile ? "Close the canvas" : "Show inspector and activity")
8055
+ label: (mobile ? canvasPaneOpen : (canvasView.mode === "canvas" && !artifactReaderOpen))
8056
+ ? "Hide the canvas"
7709
8057
  : "Show canvas",
7710
8058
  onClick: () => {
7711
8059
  if (mobile) {
7712
- if (onToggleCanvasOverlay) onToggleCanvasOverlay();
8060
+ if (onToggleCanvasPane) onToggleCanvasPane();
7713
8061
  return;
7714
8062
  }
7715
- if (canvasView.mode === "canvas") {
7716
- controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: canvasView.sessionId, manual: true });
7717
- } else {
7718
- controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasView.sessionId });
7719
- }
8063
+ // Desktop: the canvas is a column of its own now. Closing it
8064
+ // shows nothing in its place — Diagnostics has its own toggle.
8065
+ controller.dispatch({
8066
+ type: "ui/canvasOpen",
8067
+ open: canvasView.mode !== "canvas",
8068
+ sessionId: canvasView.sessionId,
8069
+ manual: true,
8070
+ });
7720
8071
  },
7721
- active: mobile ? canvasOverlayOpen : canvasView.mode === "canvas",
8072
+ active: mobile ? canvasPaneOpen : (canvasView.mode === "canvas" && !artifactReaderOpen),
7722
8073
  badge: mobile
7723
8074
  ? (canvasView.exists
7724
8075
  && (canvasView.latestRev > (canvasView.lastViewedRev || 0)
7725
8076
  || (canvasView.latestDataRev || 0) > (canvasView.lastViewedDataRev || 0))
7726
- && !canvasOverlayOpen)
8077
+ && !canvasPaneOpen)
7727
8078
  : canvasView.unseen,
7728
8079
  // Three states: plain outline (empty), green dot bottom-left
7729
8080
  // (loaded), green + yellow (loaded with unseen changes).
7730
8081
  loadedDot: canvasView.exists,
7731
8082
  },
8083
+ // Diagnostics — Inspector + Activity as one column, desktop only.
8084
+ //
8085
+ // They used to be tabs inside the chat pane and were always present.
8086
+ // Off by default now, and behind this toggle: they are instrumentation,
8087
+ // not part of reading a conversation. The phone keeps them as nav tabs,
8088
+ // where there is no column to put them in.
8089
+ ...(mobile ? [] : [{
8090
+ key: "diagnostics",
8091
+ icon: React.createElement(DiagnosticsGlyph),
8092
+ label: diagnosticsOpen ? "Hide diagnostics" : "Show diagnostics (inspector and activity)",
8093
+ onClick: () => controller.dispatch({ type: "ui/diagnosticsOpen" }),
8094
+ active: diagnosticsOpen,
8095
+ }]),
7732
8096
  // Admin console is desktop-only: its settings tree, package detail
7733
8097
  // and file preview need width the phone layout cannot give them, so
7734
8098
  // the button is omitted rather than shipped half-working.
@@ -7773,20 +8137,34 @@ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvas
7773
8137
  chat: "Main — chat only (tap for sessions only)",
7774
8138
  sessions: "Main — sessions only (tap for both)",
7775
8139
  };
8140
+ // Two tabs, not three: the row was out of space, and Inspector +
8141
+ // Activity are one idea on the desktop already (Diagnostics, the
8142
+ // bug). The bug button opens the inspector; tapping it again while
8143
+ // inside diagnostics flips inspector <-> activity; the main button is
8144
+ // always the way back.
8145
+ const onDiagnostics = mobilePane === "inspector" || mobilePane === "activity";
8146
+ const nextDiagnosticsPane = mobilePane === "inspector" ? "activity" : "inspector";
7776
8147
  const paneDefs = [
7777
8148
  { id: "workspace", icon: MainLayoutGlyph, label: mainLabels[mobileMainLayout] || mainLabels.split, focus: "chat" },
7778
- { id: "activity", icon: ActivityPaneGlyph, label: "Activity", focus: "activity" },
7779
- { id: "inspector", icon: InspectorPaneGlyph, label: "Inspector", focus: "inspector" },
8149
+ {
8150
+ id: nextDiagnosticsPane,
8151
+ icon: DiagnosticsGlyph,
8152
+ active: onDiagnostics,
8153
+ label: onDiagnostics
8154
+ ? (mobilePane === "inspector" ? "Diagnostics — inspector (tap for activity)" : "Diagnostics — activity (tap for inspector)")
8155
+ : "Diagnostics — inspector and activity",
8156
+ focus: nextDiagnosticsPane,
8157
+ },
7780
8158
  ];
7781
8159
  return React.createElement("div", { className: "ps-toolbar is-mobile" },
7782
8160
  React.createElement("div", { className: "ps-toolbar-row ps-toolbar-row-primary" },
7783
8161
  React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton)),
7784
8162
  React.createElement("div", { className: "ps-toolbar-row-actions is-panes" },
7785
8163
  paneDefs.map((def) => React.createElement(IconButton, {
7786
- key: def.id,
8164
+ key: def.id === "workspace" ? "workspace" : "diagnostics",
7787
8165
  icon: React.createElement(def.icon),
7788
8166
  label: def.label,
7789
- active: mobilePane === def.id,
8167
+ active: def.active ?? (mobilePane === def.id),
7790
8168
  onClick: () => {
7791
8169
  if (onSelectMobilePane) onSelectMobilePane(def.id, def.focus);
7792
8170
  },
@@ -7794,8 +8172,53 @@ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvas
7794
8172
  );
7795
8173
  }
7796
8174
 
8175
+ // Two groups. Diagnostics and the admin console are TOOLS — they act on
8176
+ // the app rather than on the conversation — so they sit apart from the
8177
+ // session controls, hard right. Everything else keeps its order.
8178
+ const RIGHT_GROUP = new Set(["diagnostics", "admin"]);
8179
+
8180
+ // While the canvas is full screen, ANY other button first drops full
8181
+ // screen and then does its own job. Pressing Filter and watching nothing
8182
+ // happen behind a canvas — or watching a panel open where you cannot see
8183
+ // it — is the confusing half of every full-screen mode. The Canvas toggle
8184
+ // is exempt: it already means "put the canvas away", and closing drops the
8185
+ // flag in the reducer.
8186
+ const withRestore = (def) => (!canvasMaximized || def.key === "canvas" ? def : {
8187
+ ...def,
8188
+ onClick: (...args) => {
8189
+ controller.dispatch({ type: "ui/canvasMaximized", on: false });
8190
+ return def.onClick?.(...args);
8191
+ },
8192
+ });
8193
+
8194
+ const leftButtons = buttonDefs.filter((d) => !RIGHT_GROUP.has(d.key)).map(withRestore);
8195
+ const rightButtons = buttonDefs.filter((d) => RIGHT_GROUP.has(d.key)).map(withRestore);
7797
8196
  const toolbar = React.createElement("div", { className: "ps-toolbar" },
7798
- React.createElement("div", { className: "ps-toolbar-actions" }, buttonDefs.map(renderButton)),
8197
+ // Three columns: spacer | main controls | right rail. The main group
8198
+ // sits in the CENTER column, so it stays dead-centre in every mode —
8199
+ // an auto margin only centres in leftover space, and the full-screen
8200
+ // strip was shoving it sideways.
8201
+ React.createElement("div", { className: "ps-toolbar-side is-left", "aria-hidden": "true" }),
8202
+ React.createElement("div", { className: "ps-toolbar-actions" }, leftButtons.map(renderButton)),
8203
+ React.createElement("div", { className: "ps-toolbar-side is-right" },
8204
+ // The portal header parks its version/status meta here, LEFT of
8205
+ // the tool buttons, and its sign-out glyph in the slot after
8206
+ // them — one right-aligned cluster: meta · bug · settings · out.
8207
+ React.createElement("span", { className: "ps-toolbar-meta-slot", id: "ps-toolbar-meta-slot" }),
8208
+ // While the canvas is full screen its header controls (rev, zoom,
8209
+ // zen, restore) portal INTO this slot from CanvasPane — zoom state
8210
+ // lives there, so the controls come to the toolbar rather than
8211
+ // their state moving out.
8212
+ canvasMaximized
8213
+ ? React.createElement("span", { className: "ps-toolbar-canvas-slot", id: "ps-toolbar-canvas-slot" })
8214
+ : null,
8215
+ canvasMaximized
8216
+ ? React.createElement("span", { className: "ps-toolbar-divider", "aria-hidden": "true" })
8217
+ : null,
8218
+ rightButtons.length
8219
+ ? React.createElement("div", { className: "ps-toolbar-actions is-tools" }, rightButtons.map(renderButton))
8220
+ : null,
8221
+ React.createElement("span", { className: "ps-toolbar-signout-slot", id: "ps-toolbar-signout-slot" })),
7799
8222
  );
7800
8223
 
7801
8224
  // Rich desktop UI: the toolbar lives IN the portal header (one top bar)
@@ -8084,112 +8507,303 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
8084
8507
  React.createElement("span", { className: "ps-column-resizer-dot" })));
8085
8508
  }
8086
8509
 
8510
+
8087
8511
  /**
8088
- * How many pixels the inspector/activity seam actually moves per row of adjust.
8512
+ * The seam between the canvas and diagnostics columns.
8089
8513
  *
8090
- * The split is stored in character ROWS, but the column renders them as `fr`
8091
- * — so one row of adjustment moves the seam by (column height / total rows)
8092
- * pixels, whatever that happens to be. Dividing the pointer delta by a fixed
8093
- * GRID_CELL_HEIGHT only tracks if the column is exactly rows × 19px tall, and
8094
- * it is not: the column excludes the header and toolbar above it, and the row
8095
- * height moves with the type scale. So the seam ran ahead of the pointer or
8096
- * trailed behind it, and the error grew with the drag.
8514
+ * Only rendered when both are open — with one column the outer chat/right
8515
+ * handle is already the only split there is.
8097
8516
  *
8098
- * Both operands are fixed for the duration of a drag — the split moves, the
8099
- * total does not — so this is measured once at pointerdown rather than on
8100
- * every pointermove.
8517
+ * Drag either column below PORTAL_COLUMN_SNAP_CLOSED_PX and releasing CLOSES
8518
+ * it: the user asked for "resized to 0 means toggled off, and it has to be
8519
+ * toggled back on". The adjust is reset at the same time, so re-opening from
8520
+ * the toolbar comes back at the even split rather than at the sliver that
8521
+ * closed it.
8101
8522
  */
8102
- function measureActivityRowPx(controller, handleEl) {
8103
- try {
8104
- const layout = controller.getCurrentLayout?.();
8105
- const rows = (layout?.inspectorPaneHeight || 0) + (layout?.activityPaneHeight || 0);
8106
- const column = handleEl?.closest?.(".ps-workspace-column");
8107
- if (!column || rows <= 0) return GRID_CELL_HEIGHT;
8108
- // The resizer's own track is not part of either pane's share.
8109
- const usable = column.clientHeight - (handleEl.getBoundingClientRect().height || 0);
8110
- const pxPerRow = usable / rows;
8111
- return Number.isFinite(pxPerRow) && pxPerRow > 0 ? pxPerRow : GRID_CELL_HEIGHT;
8112
- } catch {
8113
- return GRID_CELL_HEIGHT;
8114
- }
8115
- }
8116
-
8117
- function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
8523
+ function CanvasDiagnosticsResizeHandle({ controller, canvasPaneAdjust = 0, diagnosticsPaneAdjust = 0 }) {
8118
8524
  const dragStateRef = React.useRef(null);
8119
8525
  const [dragging, setDragging] = React.useState(false);
8120
8526
 
8527
+ const widths = (ca, da) => ({
8528
+ canvas: PORTAL_CANVAS_COL_DEFAULT_PX + (Number(ca) || 0),
8529
+ diag: PORTAL_DIAG_COL_DEFAULT_PX + (Number(da) || 0),
8530
+ });
8531
+
8532
+ // Moving the seam trades pixels between the two columns; the total — and
8533
+ // therefore chat — does not move. Below the snap on release, the starved
8534
+ // column CLOSES and both widths reset, so reopening is always usable.
8535
+ const commit = (ca, da) => {
8536
+ const w = widths(ca, da);
8537
+ if (w.canvas < PORTAL_COLUMN_SNAP_CLOSED_PX || w.diag < PORTAL_COLUMN_SNAP_CLOSED_PX) {
8538
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: 0 });
8539
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: 0 });
8540
+ controller.dispatch(w.canvas < PORTAL_COLUMN_SNAP_CLOSED_PX
8541
+ ? { type: "ui/canvasOpen", open: false, manual: true }
8542
+ : { type: "ui/diagnosticsOpen", open: false });
8543
+ return;
8544
+ }
8545
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: ca });
8546
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: da });
8547
+ };
8548
+
8121
8549
  React.useEffect(() => {
8122
8550
  if (!dragging) return undefined;
8551
+ const onMove = (event) => {
8552
+ const st = dragStateRef.current;
8553
+ if (!st) return;
8554
+ const dx = event.clientX - st.startX;
8555
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: st.startCanvas + dx });
8556
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: st.startDiag - dx });
8557
+ };
8558
+ const onUp = () => {
8559
+ const st = dragStateRef.current;
8560
+ dragStateRef.current = null;
8561
+ setDragging(false);
8562
+ document.body.classList.remove("is-resizing-pane-x");
8563
+ if (st) {
8564
+ const ui = controller.getState()?.ui?.layout || {};
8565
+ commit(ui.canvasPaneAdjust ?? 0, ui.diagnosticsPaneAdjust ?? 0);
8566
+ }
8567
+ };
8568
+ window.addEventListener("pointermove", onMove);
8569
+ window.addEventListener("pointerup", onUp);
8570
+ window.addEventListener("pointercancel", onUp);
8571
+ return () => {
8572
+ window.removeEventListener("pointermove", onMove);
8573
+ window.removeEventListener("pointerup", onUp);
8574
+ window.removeEventListener("pointercancel", onUp);
8575
+ };
8576
+ }, [controller, dragging]);
8123
8577
 
8124
- const stopDragging = () => {
8578
+ return React.createElement("button", {
8579
+ type: "button",
8580
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8581
+ title: "Drag to trade width between the canvas and diagnostics. Drag one away to close it. Double-click to reset.",
8582
+ "aria-label": "Resize canvas and diagnostics columns",
8583
+ onPointerDown: (event) => {
8584
+ if (event.button !== 0) return;
8585
+ event.preventDefault();
8586
+ capturePointerForDrag(event);
8587
+ dragStateRef.current = {
8588
+ startX: event.clientX,
8589
+ startCanvas: Number(canvasPaneAdjust) || 0,
8590
+ startDiag: Number(diagnosticsPaneAdjust) || 0,
8591
+ };
8592
+ setDragging(true);
8593
+ document.body.classList.add("is-resizing-pane-x");
8594
+ },
8595
+ onDoubleClick: () => {
8596
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: 0 });
8597
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: 0 });
8598
+ },
8599
+ onKeyDown: (event) => {
8600
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8601
+ event.preventDefault();
8602
+ const dx = event.key === "ArrowRight" ? 24 : -24;
8603
+ commit((Number(canvasPaneAdjust) || 0) + dx, (Number(diagnosticsPaneAdjust) || 0) - dx);
8604
+ },
8605
+ },
8606
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8607
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8608
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8609
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8610
+ }
8611
+
8612
+ /** The zen seam: drags the chat rail, remembered per session. */
8613
+ function ZenRailResizeHandle({ controller, sessionId, railPx = 380 }) {
8614
+ const dragStateRef = React.useRef(null);
8615
+ const [dragging, setDragging] = React.useState(false);
8616
+ React.useEffect(() => {
8617
+ if (!dragging) return undefined;
8618
+ const onMove = (event) => {
8619
+ const st = dragStateRef.current;
8620
+ if (!st) return;
8621
+ controller.dispatch({ type: "canvas/zenRail", sessionId, px: st.start + (event.clientX - st.startX) });
8622
+ };
8623
+ const onUp = () => {
8125
8624
  dragStateRef.current = null;
8126
8625
  setDragging(false);
8127
- document.body.classList.remove("is-resizing-pane-y");
8626
+ document.body.classList.remove("is-resizing-pane-x");
8128
8627
  };
8628
+ window.addEventListener("pointermove", onMove);
8629
+ window.addEventListener("pointerup", onUp);
8630
+ window.addEventListener("pointercancel", onUp);
8631
+ return () => {
8632
+ window.removeEventListener("pointermove", onMove);
8633
+ window.removeEventListener("pointerup", onUp);
8634
+ window.removeEventListener("pointercancel", onUp);
8635
+ };
8636
+ }, [controller, dragging, sessionId]);
8637
+ return React.createElement("button", {
8638
+ type: "button",
8639
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8640
+ title: "Drag to resize chat against the canvas (remembered for this session)",
8641
+ "aria-label": "Resize the zen chat rail",
8642
+ onPointerDown: (event) => {
8643
+ if (event.button !== 0) return;
8644
+ event.preventDefault();
8645
+ capturePointerForDrag(event);
8646
+ dragStateRef.current = { startX: event.clientX, start: railPx };
8647
+ setDragging(true);
8648
+ document.body.classList.add("is-resizing-pane-x");
8649
+ },
8650
+ onKeyDown: (event) => {
8651
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8652
+ event.preventDefault();
8653
+ controller.dispatch({ type: "canvas/zenRail", sessionId, px: railPx + (event.key === "ArrowRight" ? 24 : -24) });
8654
+ },
8655
+ },
8656
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8657
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8658
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8659
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8660
+ }
8129
8661
 
8130
- const onPointerMove = (event) => {
8131
- const dragState = dragStateRef.current;
8132
- if (!dragState) return;
8133
- const deltaCells = Math.round((event.clientY - dragState.startY) / dragState.pxPerRow);
8134
- const deltaIncrement = deltaCells - dragState.appliedCells;
8135
- if (!deltaIncrement) return;
8136
- controller.adjustActivityPaneSplit(-deltaIncrement);
8137
- dragState.appliedCells = deltaCells;
8662
+ /**
8663
+ * The seam between chat and the right block. Chat flexes, so this drags the
8664
+ * width of the column beside it — canvas when open, else diagnostics. The
8665
+ * other column never moves: growing chat shrinks only its neighbour.
8666
+ */
8667
+ function ChatRightResizeHandle({ controller, target, adjust = 0 }) {
8668
+ const dragStateRef = React.useRef(null);
8669
+ const [dragging, setDragging] = React.useState(false);
8670
+ const actionType = target === "canvas" ? "ui/canvasPaneAdjust" : "ui/diagnosticsPaneAdjust";
8671
+ const key = target === "canvas" ? "canvasPaneAdjust" : "diagnosticsPaneAdjust";
8672
+ const base = target === "canvas" ? PORTAL_CANVAS_COL_DEFAULT_PX : PORTAL_DIAG_COL_DEFAULT_PX;
8673
+
8674
+ const commit = (next) => {
8675
+ if (base + next < PORTAL_COLUMN_SNAP_CLOSED_PX) {
8676
+ controller.dispatch({ type: actionType, [key]: 0 });
8677
+ controller.dispatch(target === "canvas"
8678
+ ? { type: "ui/canvasOpen", open: false, manual: true }
8679
+ : { type: "ui/diagnosticsOpen", open: false });
8680
+ return;
8681
+ }
8682
+ controller.dispatch({ type: actionType, [key]: next });
8683
+ };
8684
+
8685
+ React.useEffect(() => {
8686
+ if (!dragging) return undefined;
8687
+ const onMove = (event) => {
8688
+ const st = dragStateRef.current;
8689
+ if (!st) return;
8690
+ // Pointer right = chat grows = the neighbour gives up the pixels.
8691
+ controller.dispatch({ type: actionType, [key]: st.startAdjust - (event.clientX - st.startX) });
8692
+ };
8693
+ const onUp = () => {
8694
+ const st = dragStateRef.current;
8695
+ dragStateRef.current = null;
8696
+ setDragging(false);
8697
+ document.body.classList.remove("is-resizing-pane-x");
8698
+ if (st) commit((controller.getState()?.ui?.layout || {})[key] ?? 0);
8138
8699
  };
8700
+ window.addEventListener("pointermove", onMove);
8701
+ window.addEventListener("pointerup", onUp);
8702
+ window.addEventListener("pointercancel", onUp);
8703
+ return () => {
8704
+ window.removeEventListener("pointermove", onMove);
8705
+ window.removeEventListener("pointerup", onUp);
8706
+ window.removeEventListener("pointercancel", onUp);
8707
+ };
8708
+ }, [controller, dragging, actionType, key]);
8139
8709
 
8140
- window.addEventListener("pointermove", onPointerMove);
8141
- window.addEventListener("pointerup", stopDragging);
8142
- window.addEventListener("pointercancel", stopDragging);
8710
+ return React.createElement("button", {
8711
+ type: "button",
8712
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8713
+ title: "Drag to resize. Drag the column away to close it. Double-click to reset.",
8714
+ "aria-label": "Resize the right column",
8715
+ onPointerDown: (event) => {
8716
+ if (event.button !== 0) return;
8717
+ event.preventDefault();
8718
+ capturePointerForDrag(event);
8719
+ dragStateRef.current = { startX: event.clientX, startAdjust: Number(adjust) || 0 };
8720
+ setDragging(true);
8721
+ document.body.classList.add("is-resizing-pane-x");
8722
+ },
8723
+ onDoubleClick: () => controller.dispatch({ type: actionType, [key]: 0 }),
8724
+ onKeyDown: (event) => {
8725
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8726
+ event.preventDefault();
8727
+ commit((Number(adjust) || 0) - (event.key === "ArrowRight" ? 24 : -24));
8728
+ },
8729
+ },
8730
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8731
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8732
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8733
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8734
+ }
8143
8735
 
8736
+ /**
8737
+ * The inspector/activity seam, in continuous pixels — it follows the pointer
8738
+ * 1:1. Replaces the row-quantized ActivityRowResizeHandle for the portal:
8739
+ * that one converted drags to whole terminal rows (~20px notches) against
8740
+ * geometry computed from the whole viewport rather than this column, and the
8741
+ * legacy layout could snap-collapse a pane mid-drag. The grid's minmax does
8742
+ * all the clamping now.
8743
+ */
8744
+ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
8745
+ const dragStateRef = React.useRef(null);
8746
+ const [dragging, setDragging] = React.useState(false);
8747
+ React.useEffect(() => {
8748
+ if (!dragging) return undefined;
8749
+ const onMove = (event) => {
8750
+ const st = dragStateRef.current;
8751
+ if (!st) return;
8752
+ // Clamped to the half-height each side can actually give. Without
8753
+ // this the stored value keeps counting past the edge while the
8754
+ // bar stops — and the pointer leads the seam by the overshoot on
8755
+ // the way back.
8756
+ const next = Math.max(-st.half, Math.min(st.half, st.start + (event.clientY - st.startY)));
8757
+ controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: next });
8758
+ };
8759
+ const onUp = () => {
8760
+ dragStateRef.current = null;
8761
+ setDragging(false);
8762
+ document.body.classList.remove("is-resizing-pane-y");
8763
+ };
8764
+ window.addEventListener("pointermove", onMove);
8765
+ window.addEventListener("pointerup", onUp);
8766
+ window.addEventListener("pointercancel", onUp);
8144
8767
  return () => {
8145
- window.removeEventListener("pointermove", onPointerMove);
8146
- window.removeEventListener("pointerup", stopDragging);
8147
- window.removeEventListener("pointercancel", stopDragging);
8768
+ window.removeEventListener("pointermove", onMove);
8769
+ window.removeEventListener("pointerup", onUp);
8770
+ window.removeEventListener("pointercancel", onUp);
8148
8771
  document.body.classList.remove("is-resizing-pane-y");
8149
8772
  };
8150
8773
  }, [controller, dragging]);
8151
-
8152
8774
  return React.createElement("button", {
8153
8775
  type: "button",
8154
8776
  className: `ps-row-resizer${dragging ? " is-dragging" : ""}`,
8155
- title: "Drag to resize the activity pane. Double-click to reset.",
8156
- "aria-label": "Resize activity pane",
8777
+ title: "Drag to resize the inspector and activity panes. Double-click to reset.",
8778
+ "aria-label": "Resize the inspector and activity panes",
8157
8779
  onPointerDown: (event) => {
8158
8780
  if (event.button !== 0) return;
8159
8781
  event.preventDefault();
8160
8782
  capturePointerForDrag(event);
8161
- dragStateRef.current = {
8162
- startY: event.clientY,
8163
- appliedCells: 0,
8164
- // MEASURED, not assumed — see measureActivityRowPx.
8165
- pxPerRow: measureActivityRowPx(controller, event.currentTarget),
8166
- };
8783
+ const col = event.currentTarget.closest(".ps-workspace-column");
8784
+ const half = Math.max(0, ((col?.getBoundingClientRect?.().height || 0) - 16) / 2);
8785
+ dragStateRef.current = { startY: event.clientY, start: Number(splitAdjust) || 0, half };
8167
8786
  setDragging(true);
8168
8787
  document.body.classList.add("is-resizing-pane-y");
8169
8788
  },
8170
- onDoubleClick: () => {
8171
- if (!activityPaneAdjust) return;
8172
- controller.adjustActivityPaneSplit(-activityPaneAdjust);
8173
- },
8789
+ onDoubleClick: () => controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: 0 }),
8174
8790
  onKeyDown: (event) => {
8175
- if (event.key === "ArrowUp") {
8176
- event.preventDefault();
8177
- controller.adjustActivityPaneSplit(1);
8178
- return;
8179
- }
8180
- if (event.key === "ArrowDown") {
8181
- event.preventDefault();
8182
- controller.adjustActivityPaneSplit(-1);
8183
- }
8791
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
8792
+ event.preventDefault();
8793
+ controller.dispatch({
8794
+ type: "ui/diagnosticsSplitAdjust",
8795
+ diagnosticsSplitAdjust: (Number(splitAdjust) || 0) + (event.key === "ArrowDown" ? 16 : -16),
8796
+ });
8184
8797
  },
8185
8798
  },
8186
8799
  React.createElement("span", { className: "ps-row-resizer-handle", "aria-hidden": "true" },
8187
- React.createElement("span", { className: "ps-row-resizer-dot" }),
8188
- React.createElement("span", { className: "ps-row-resizer-dot" }),
8189
- React.createElement("span", { className: "ps-row-resizer-dot" })));
8800
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8801
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8802
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8190
8803
  }
8191
8804
 
8192
8805
 
8806
+
8193
8807
  function ModalLayer({ controller }) {
8194
8808
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
8195
8809
  const touchScale = useControllerSelector(controller, (state) => Boolean(state.ui.touchScale));
@@ -9225,7 +9839,13 @@ function AdminConsolePanel({ controller, mobile = false }) {
9225
9839
  const header = React.createElement("header", { className: "ps-admin-console__header" },
9226
9840
  React.createElement("h2", null, "Admin Console"),
9227
9841
  React.createElement("span", { className: "ps-admin-console__who" }, principalLabel),
9228
- React.createElement("button", { type: "button", className: "ps-mini-button", onClick: onClose }, "Close"));
9842
+ React.createElement("button", {
9843
+ type: "button",
9844
+ className: "ps-mini-button is-icon",
9845
+ onClick: onClose,
9846
+ title: "Close the admin console",
9847
+ "aria-label": "Close the admin console",
9848
+ }, "\u2715"));
9229
9849
 
9230
9850
  const ghcpSection = React.createElement(AdminGhcpSection, {
9231
9851
  view, draftRef, onBeginEdit, onCancelEdit, onClear, onSubmit, onDraftChange, onRefresh, controller,
@@ -9312,10 +9932,15 @@ function AdminWorkersPane({ controller, view }) {
9312
9932
  `${workers.summaryText}${counts.pools > 1 ? ` · ${counts.pools} pools` : ""}${counts.draining ? ` · ${counts.draining} draining` : ""}`)
9313
9933
  : null,
9314
9934
  React.createElement("button", {
9315
- type: "button", className: "ps-mini-button",
9316
- disabled: Boolean(workers.loading),
9935
+ type: "button",
9936
+ className: "ps-mini-button is-icon",
9317
9937
  onClick: () => controller.refreshAdminWorkers(),
9318
- }, workers.loading ? "Refreshing…" : "Refresh")),
9938
+ disabled: Boolean(workers.loading),
9939
+ title: workers.loading ? "Refreshing…" : "Refresh the worker list",
9940
+ "aria-label": "Refresh the worker list",
9941
+ }, React.createElement("svg", { viewBox: "0 0 20 20", width: 15, height: 15, fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
9942
+ React.createElement("path", { d: "M16.2 8.2A6.4 6.4 0 0 0 4.6 6.4M3.8 11.8a6.4 6.4 0 0 0 11.6 1.8" }),
9943
+ React.createElement("path", { d: "M16.4 3.6v4.6h-4.6M3.6 16.4v-4.6h4.6" })))),
9319
9944
  workers.error
9320
9945
  ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, workers.error)
9321
9946
  : null,
@@ -9951,6 +10576,11 @@ export function PilotSwarmWebApp({ controller }) {
9951
10576
  activeSessionId: rootState.sessions.activeSessionId || null,
9952
10577
  promptRows: getStatePromptRows(rootState),
9953
10578
  rightPaneMode: rootState.ui.rightPaneMode || "panes",
10579
+ // The two optional desktop columns, independent of each other.
10580
+ canvasOpen: rootState.ui.canvasOpen === true,
10581
+ diagnosticsOpen: rootState.ui.diagnosticsOpen === true,
10582
+ canvasMaximized: rootState.ui.canvasMaximized === true,
10583
+ canvasZen: rootState.ui.canvasZen === true,
9954
10584
  // Live reference — reducer updates replace the object, so
9955
10585
  // shallow-equal sees changes and the save effect fires.
9956
10586
  canvasPrefs: rootState.canvas?.prefs,
@@ -9959,6 +10589,9 @@ export function PilotSwarmWebApp({ controller }) {
9959
10589
  sessionPaneAdjust: rootState.ui.layout?.sessionPaneAdjust ?? 0,
9960
10590
  portalSessionColumnAdjust: rootState.ui.layout?.portalSessionColumnAdjust ?? 0,
9961
10591
  activityPaneAdjust: rootState.ui.layout?.activityPaneAdjust ?? 0,
10592
+ canvasPaneAdjust: rootState.ui.layout?.canvasPaneAdjust ?? 0,
10593
+ diagnosticsPaneAdjust: rootState.ui.layout?.diagnosticsPaneAdjust ?? 0,
10594
+ diagnosticsSplitAdjust: rootState.ui.layout?.diagnosticsSplitAdjust ?? 0,
9962
10595
  focusRegion: rootState.ui.focusRegion,
9963
10596
  inspectorTab: rootState.ui.inspectorTab,
9964
10597
  filesFullscreen: Boolean(rootState.files.fullscreen),
@@ -9975,6 +10608,9 @@ export function PilotSwarmWebApp({ controller }) {
9975
10608
  // Last-known stored rightPaneMode — the desktop-owned slot a phone must
9976
10609
  // carry through its saves untouched (see profileSettingsFromViewState).
9977
10610
  const desktopRightPaneModeRef = React.useRef(null);
10611
+ // Same preservation contract as desktopRightPaneModeRef: a phone must not
10612
+ // overwrite the desktop's column toggles with its own (absent) ones.
10613
+ const desktopPanesRef = React.useRef(null);
9978
10614
  const profileSettingsSaveTimerRef = React.useRef(null);
9979
10615
  const profileSettingsPollTimerRef = React.useRef(null);
9980
10616
  const profileSettingsPollInFlightRef = React.useRef(false);
@@ -9993,44 +10629,54 @@ export function PilotSwarmWebApp({ controller }) {
9993
10629
  // so it follows the flip TICK instead (which fires even when the mode was
9994
10630
  // already "canvas" — e.g. a persisted mode with the user on Main).
9995
10631
  const canvasShellView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
9996
- const [mobileCanvasOpen, setMobileCanvasOpen] = React.useState(false);
10632
+ // "canvas" is a fourth value of mobilePane, alongside workspace, activity
10633
+ // and inspector — the canvas is one more thing that can be showing in the
10634
+ // content region, not a screen layered over the app.
10635
+ const mobileCanvasOpen = mobilePane === "canvas";
10636
+ // Which pane the canvas was opened FROM. Toggling it off puts that back —
10637
+ // chat, activity or inspector — instead of dropping the user on Main.
10638
+ const paneBeforeCanvasRef = React.useRef("workspace");
9997
10639
  // Once opened, the canvas stays MOUNTED (hidden) for the rest of the
9998
10640
  // session selection — page state (typed input, scroll, game progress)
9999
10641
  // survives toggling. A session switch remounts fresh via the key below.
10000
10642
  const [mobileCanvasEverOpened, setMobileCanvasEverOpened] = React.useState(false);
10001
10643
  const [desktopCanvasEverOpened, setDesktopCanvasEverOpened] = React.useState(false);
10002
10644
  React.useEffect(() => {
10003
- setMobileCanvasOpen(false);
10645
+ setMobilePane((current) => (current === "canvas" ? (paneBeforeCanvasRef.current || "workspace") : current));
10004
10646
  setMobileCanvasEverOpened(false);
10005
10647
  setDesktopCanvasEverOpened(false);
10006
10648
  }, [canvasShellView.sessionId]);
10007
- // iOS scroll-chaining: while the overlay is up, a pan that Safari decides
10649
+ // iOS scroll-chaining: while the canvas is up, a pan that Safari decides
10008
10650
  // is not the iframe's must not scroll the app shell behind it.
10009
10651
  React.useEffect(() => {
10010
10652
  if (typeof document === "undefined") return undefined;
10011
10653
  document.body.classList.toggle("ps-canvas-lock", Boolean(mobile && mobileCanvasOpen));
10012
10654
  return () => document.body.classList.remove("ps-canvas-lock");
10013
10655
  }, [mobile, mobileCanvasOpen]);
10014
- const canvasFlipSeqRef = React.useRef(canvasShellView.flipSeq);
10015
- React.useEffect(() => {
10016
- if (canvasShellView.flipSeq === canvasFlipSeqRef.current) return;
10017
- canvasFlipSeqRef.current = canvasShellView.flipSeq;
10018
- if (mobile) { setMobileCanvasOpen(true); setMobileCanvasEverOpened(true); }
10019
- }, [canvasShellView.flipSeq, mobile]);
10656
+ const openMobileCanvas = React.useCallback(() => {
10657
+ if (mobilePane !== "canvas") paneBeforeCanvasRef.current = mobilePane;
10658
+ setMobilePane("canvas");
10659
+ setMobileCanvasEverOpened(true);
10660
+ // Entering by hand un-opts-out, same as the desktop toggle.
10661
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasShellView.sessionId });
10662
+ }, [controller, mobilePane, canvasShellView.sessionId]);
10020
10663
  const closeMobileCanvas = React.useCallback(() => {
10021
- setMobileCanvasOpen(false);
10664
+ setMobilePane(paneBeforeCanvasRef.current || "workspace");
10022
10665
  // Mirror the desktop ✕ exactly: a deliberate close is the opt-out
10023
10666
  // gesture — later draws badge the toggle instead of re-covering the
10024
- // screen, and reopening clears the opt-out.
10667
+ // transcript, and reopening clears the opt-out.
10025
10668
  controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: canvasShellView.sessionId, manual: true });
10026
10669
  }, [controller, canvasShellView.sessionId]);
10670
+ const canvasFlipSeqRef = React.useRef(canvasShellView.flipSeq);
10671
+ React.useEffect(() => {
10672
+ if (canvasShellView.flipSeq === canvasFlipSeqRef.current) return;
10673
+ canvasFlipSeqRef.current = canvasShellView.flipSeq;
10674
+ if (mobile) openMobileCanvas();
10675
+ }, [canvasShellView.flipSeq, mobile, openMobileCanvas]);
10027
10676
  const toggleMobileCanvas = React.useCallback(() => {
10028
10677
  if (mobileCanvasOpen) { closeMobileCanvas(); return; }
10029
- setMobileCanvasOpen(true);
10030
- setMobileCanvasEverOpened(true);
10031
- // Entering by hand un-opts-out, same as the desktop toggle.
10032
- controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasShellView.sessionId });
10033
- }, [mobileCanvasOpen, closeMobileCanvas, controller, canvasShellView.sessionId]);
10678
+ openMobileCanvas();
10679
+ }, [mobileCanvasOpen, closeMobileCanvas, openMobileCanvas]);
10034
10680
 
10035
10681
  // gridViewport used to be PUBLISHED here via controller.setViewport — a
10036
10682
  // second writer to ui.layout that derived columns from the hard-coded
@@ -10083,16 +10729,40 @@ export function PilotSwarmWebApp({ controller }) {
10083
10729
  if (remoteNormalized.rightPaneMode === "canvas" || remoteNormalized.rightPaneMode === "panes") {
10084
10730
  desktopRightPaneModeRef.current = remoteNormalized.rightPaneMode;
10085
10731
  }
10732
+ // Its own check: a profile written by this build carries
10733
+ // desktopPanes and need not carry the legacy enum at all.
10734
+ if (remoteNormalized.desktopPanes) {
10735
+ desktopPanesRef.current = remoteNormalized.desktopPanes;
10736
+ }
10086
10737
  defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
10087
- controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
10738
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
10088
10739
  );
10740
+ // First visit on a desktop wide enough to hold it: open the
10741
+ // canvas. A workspace that opens as chat alone hides the thing
10742
+ // most sessions are FOR, and the canvas needs real width to be
10743
+ // worth showing — below the breakpoint chat alone is better.
10744
+ //
10745
+ // Folded into the settings rather than dispatched separately.
10746
+ // A separate dispatch raced the re-baseline at the end of this
10747
+ // same poll: the new value was recorded as "already saved",
10748
+ // never written, and the next poll put it back. Going through
10749
+ // the settings means the normal apply-and-save path carries it.
10750
+ //
10751
+ // Only when NOTHING is stored. An explicit choice, including
10752
+ // closing the canvas, is a preference from then on. Not
10753
+ // live-responsive either: narrowing the window never closes a
10754
+ // canvas the user opened on purpose.
10755
+ const wantsDefaultCanvas = !hasStoredDesktopPanes(remoteNormalized)
10756
+ && !isNarrowViewport()
10757
+ && window.innerWidth >= PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX;
10089
10758
  const settings = materializeProfileSettings(
10090
10759
  profile?.profileSettings,
10091
10760
  defaultProfileSettingsRef.current,
10092
10761
  );
10762
+ if (wantsDefaultCanvas) settings.desktopPanes = { canvasOpen: true, diagnosticsOpen: false };
10093
10763
  const settingsJson = JSON.stringify(settings);
10094
10764
  const currentSettingsBeforeApply = profileSettingsFromViewState(
10095
- controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
10765
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
10096
10766
  );
10097
10767
  const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
10098
10768
  const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
@@ -10106,6 +10776,22 @@ export function PilotSwarmWebApp({ controller }) {
10106
10776
  appliedProfileSettingsJsonRef.current = settingsJson;
10107
10777
  }
10108
10778
 
10779
+ // First run on this account, on a desktop wide enough to hold
10780
+ // it: open the canvas. A workspace that opens as chat alone
10781
+ // hides the thing most sessions are FOR, and the canvas needs
10782
+ // real width to be worth showing — below the breakpoint chat
10783
+ // alone is the better default.
10784
+ //
10785
+ // Once only, and only when nothing is stored. An explicit
10786
+ // choice, including closing it, is a stored preference from
10787
+ // then on and is never second-guessed. Not live-responsive
10788
+ // either: narrowing the window does not close a canvas the
10789
+ // user opened on purpose.
10790
+ // remoteNormalized, NOT `settings`: the merged object always
10791
+ // carries desktopPanes because the defaults are synthesized
10792
+ // from current state, so it can never say "unset" and the
10793
+ // default below would never fire.
10794
+
10109
10795
  // lastProfileSettingsJson is what the SAVE effect diffs against
10110
10796
  // to decide whether anything needs writing. Refreshing it on
10111
10797
  // every poll made the poll adopt UNSAVED local state as if it
@@ -10119,7 +10805,7 @@ export function PilotSwarmWebApp({ controller }) {
10119
10805
  // remote state by definition and there is nothing to lose).
10120
10806
  if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
10121
10807
  const currentSettings = profileSettingsFromViewState(
10122
- controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
10808
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
10123
10809
  );
10124
10810
  lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
10125
10811
  }
@@ -10153,7 +10839,7 @@ export function PilotSwarmWebApp({ controller }) {
10153
10839
 
10154
10840
  React.useEffect(() => {
10155
10841
  if (!profileSettingsHydratedRef.current) return undefined;
10156
- const settings = profileSettingsFromViewState(state, otherTouchScaleRef.current, desktopRightPaneModeRef.current);
10842
+ const settings = profileSettingsFromViewState(state, otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current);
10157
10843
  const settingsJson = JSON.stringify(settings);
10158
10844
  if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
10159
10845
  lastProfileSettingsJsonRef.current = settingsJson;
@@ -10230,11 +10916,66 @@ export function PilotSwarmWebApp({ controller }) {
10230
10916
  // precedence while open, and its ✕ leaves rightPaneMode untouched — so
10231
10917
  // closing a preview returns to the canvas, restore-what-was-displaced.
10232
10918
  const canvasModeActive = !mobile
10233
- && state.rightPaneMode === "canvas"
10919
+ && state.canvasOpen
10234
10920
  && !filesFullscreenActive;
10921
+ // sessionId is a dep ON PURPOSE, not just canvasModeActive. The reset
10922
+ // effect above wipes the gate whenever the session settles or switches —
10923
+ // and on a cold load the settings poll applies canvasOpen BEFORE the
10924
+ // sessions list has loaded, so activeSessionId lands in a LATER commit.
10925
+ // Keyed on canvasModeActive alone this never re-fired (the flag had not
10926
+ // changed), the gate stayed false, and a canvas that was open by stored
10927
+ // preference or by the wide-screen default simply never rendered.
10928
+ // Effects run in declaration order, so within one commit the reset runs
10929
+ // first and this one wins: active canvas => mounted canvas, always.
10235
10930
  React.useEffect(() => {
10236
10931
  if (canvasModeActive) setDesktopCanvasEverOpened(true);
10237
- }, [canvasModeActive]);
10932
+ }, [canvasModeActive, canvasShellView.sessionId]);
10933
+
10934
+ // Diagnostics — Inspector + Activity as one column, beside the canvas
10935
+ // rather than instead of it. The artifact reader still takes the whole
10936
+ // right side while it is open, as it always did.
10937
+ const diagnosticsActive = !mobile
10938
+ && state.diagnosticsOpen
10939
+ && !filesFullscreenActive
10940
+ && !artifactPaneActive
10941
+ // Zen is chat + canvas only; diagnostics keeps its stored toggle and
10942
+ // returns when zen ends.
10943
+ && !(state.canvasZen && state.canvasOpen);
10944
+ // Whether the right side of the workspace exists at all. Both columns off
10945
+ // is the default: sessions and chat, nothing else. An absent column takes
10946
+ // no width and grows no resizer — off means gone, not collapsed.
10947
+ const rightSideActive = artifactPaneActive || canvasModeActive || diagnosticsActive;
10948
+
10949
+ // In the STORE, not local state: the header owns the way out (the rev
10950
+ // strip is promoted up there while full screen), and every other toolbar
10951
+ // button has to be able to drop it before doing its own job.
10952
+ const canvasMaximized = state.canvasMaximized && canvasModeActive;
10953
+ // Zen: chat rail + canvas workbench. Diagnostics steps aside but keeps
10954
+ // its stored toggle; the session list hides entirely.
10955
+ const zenActive = state.canvasZen && canvasModeActive && !artifactPaneActive;
10956
+ const zenRailPx = Math.max(260, Math.min(720,
10957
+ Number(state.canvasPrefs?.[state.activeSessionId]?.zenRailPx) || 380));
10958
+ // Escape steps down one rung: full screen -> zen -> normal workspace.
10959
+ React.useEffect(() => {
10960
+ if (!canvasMaximized && !zenActive) return undefined;
10961
+ const onKey = (e) => {
10962
+ if (e.key !== "Escape") return;
10963
+ controller.dispatch(canvasMaximized
10964
+ ? { type: "ui/canvasMaximized", on: false }
10965
+ : { type: "ui/canvasZen", on: false });
10966
+ };
10967
+ window.addEventListener("keydown", onKey);
10968
+ return () => window.removeEventListener("keydown", onKey);
10969
+ }, [controller, canvasMaximized, zenActive]);
10970
+
10971
+ // The right block is exactly as wide as the columns in it. Pixels, not a
10972
+ // share of the window: when a column closes, its pixels go to chat and
10973
+ // sessions — never to the surviving column. That is the whole model.
10974
+ const canvasColPx = Math.max(0, PORTAL_CANVAS_COL_DEFAULT_PX + (Number(state.canvasPaneAdjust) || 0));
10975
+ const diagColPx = Math.max(0, PORTAL_DIAG_COL_DEFAULT_PX + (Number(state.diagnosticsPaneAdjust) || 0));
10976
+ const rightSidePx = (canvasModeActive ? canvasColPx : 0)
10977
+ + (canvasModeActive && diagnosticsActive ? 1 : 0) // the hairline seam
10978
+ + (diagnosticsActive ? diagColPx : 0);
10238
10979
 
10239
10980
  // Closing hands the column back — or collapses it again, if the user had
10240
10981
  // already resized it away before opening the pane. Both cases live in the
@@ -10246,15 +10987,34 @@ export function PilotSwarmWebApp({ controller }) {
10246
10987
  const estimatedMainGridWidth = Math.max(0, (viewport.width || 0) * 0.68);
10247
10988
  const measuredMainGridWidth = mainGridViewport.width || estimatedMainGridWidth;
10248
10989
  const sessionColumnWidth = portalSessionColumnWidth(measuredMainGridWidth, state.portalSessionColumnAdjust);
10249
- const sessionColumnMode = portalSessionColumnMode(sessionColumnWidth);
10990
+ const sessionColumnMode = zenActive ? "hidden" : portalSessionColumnMode(sessionColumnWidth);
10250
10991
  const sessionColumnTrack = sessionColumnMode === "hidden"
10251
10992
  ? "0px"
10252
10993
  : `clamp(0px, calc(${PORTAL_SESSION_COLUMN_RATIO * 100}% + ${Number(state.portalSessionColumnAdjust) || 0}px), max(0px, calc(100% - 14rem - 32px)))`;
10253
10994
 
10254
10995
  const desktopWorkspace = React.createElement("div", {
10255
- className: "ps-workspace-grid",
10996
+ className: `ps-workspace-grid${rightSideActive ? "" : " is-right-hidden"}`,
10256
10997
  style: {
10257
- gridTemplateColumns: `minmax(0, ${layout.leftWidth}fr) var(--ps-resizer-track, 16px) minmax(0, ${layout.rightWidth}fr)`,
10998
+ // With both optional columns off there is no right side and no
10999
+ // resizer for one: chat takes the whole width. Leaving a 0fr track
11000
+ // and its 16px resizer behind would put a drag handle against the
11001
+ // window edge that reveals nothing.
11002
+ //
11003
+ // With the canvas up the right track gets a pixel FLOOR. The fr
11004
+ // ratio comes from computeLegacyLayout, which was tuned when the
11005
+ // right column only ever held the inspector/activity split — it
11006
+ // left the canvas around 200px, too narrow to read a page in.
11007
+ // Capped at 55% so the floor can never crush chat on a narrow
11008
+ // window; below that the user drags, or closes a column.
11009
+ gridTemplateColumns: artifactPaneActive
11010
+ ? `minmax(0, ${layout.leftWidth}fr) var(--ps-resizer-track, 16px) minmax(0, ${layout.rightWidth}fr)`
11011
+ : (zenActive
11012
+ // Zen: chat is a rail, the canvas is the workbench. The
11013
+ // rail width is remembered for THIS session only.
11014
+ ? `${zenRailPx}px var(--ps-resizer-track, 16px) minmax(0, 1fr)`
11015
+ : (rightSideActive
11016
+ ? `minmax(0, 1fr) var(--ps-resizer-track, 16px) min(${rightSidePx}px, 60%)`
11017
+ : "minmax(0, 1fr)")),
10258
11018
  },
10259
11019
  },
10260
11020
  React.createElement("div", {
@@ -10282,53 +11042,102 @@ export function PilotSwarmWebApp({ controller }) {
10282
11042
  style: { gridColumn: "3" },
10283
11043
  },
10284
11044
  React.createElement(ChatPane, { controller }))),
10285
- React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust }),
10286
- // The artifact reader takes the WHOLE right column — inspector, resizer and
10287
- // activity all step aside. The column keeps its width and resizer, so the
10288
- // chat/reader split is dragged exactly like the chat/inspector one was.
10289
- React.createElement("div", { className: "ps-workspace-column-host" },
11045
+ rightSideActive
11046
+ ? (artifactPaneActive
11047
+ ? React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust })
11048
+ : (zenActive
11049
+ ? React.createElement(ZenRailResizeHandle, { controller, sessionId: state.activeSessionId, railPx: zenRailPx })
11050
+ : React.createElement(ChatRightResizeHandle, {
11051
+ controller,
11052
+ target: canvasModeActive ? "canvas" : "diagnostics",
11053
+ adjust: canvasModeActive ? state.canvasPaneAdjust : state.diagnosticsPaneAdjust,
11054
+ })))
11055
+ : null,
11056
+ // The right side of the workspace: canvas and diagnostics, side by side
11057
+ // and independent. Either, both, or neither.
11058
+ //
11059
+ // A flex ROW rather than more grid tracks. The outer grid's third track is
11060
+ // still sized against chat by the handle above, so that drag keeps meaning
11061
+ // exactly what it always meant: chat versus everything to its right.
11062
+ //
11063
+ // DOM order is the visual order: canvas, then diagnostics. Diagnostics is
11064
+ // always rightmost, whether or not the canvas is up.
11065
+ //
11066
+ // The artifact reader still takes the WHOLE right side while it is open —
11067
+ // canvas, inspector and activity all step aside — which is why
11068
+ // diagnosticsActive already excludes it.
11069
+ React.createElement("div", { className: `ps-workspace-column-host is-split${canvasMaximized ? " has-maximized" : ""}` },
10290
11070
  artifactPaneActive
10291
11071
  ? React.createElement("div", { className: "ps-workspace-column is-artifact" },
10292
11072
  React.createElement(ArtifactTakeoverPane, { controller, onClose: closeArtifactPane }))
10293
- : React.createElement("div", {
10294
- className: "ps-workspace-column",
10295
- style: { gridTemplateRows: `${layout.inspectorPaneHeight}fr var(--ps-resizer-track, 16px) ${layout.activityPaneHeight}fr` },
10296
- },
10297
- React.createElement("div", {
10298
- className: "ps-workspace-pane-slot",
10299
- style: { gridRow: "1" },
10300
- },
10301
- !layout.inspectorHidden ? React.createElement(InspectorPane, { controller, mobile: false }) : null),
10302
- React.createElement("div", {
10303
- style: {
10304
- gridRow: "2",
10305
- minHeight: 0,
10306
- display: "flex",
10307
- flexDirection: "column",
10308
- },
10309
- },
10310
- React.createElement(ActivityRowResizeHandle, { controller, activityPaneAdjust: state.activityPaneAdjust })),
10311
- React.createElement("div", {
10312
- className: "ps-workspace-pane-slot",
10313
- style: { gridRow: "3" },
10314
- },
10315
- // The artifact preview takes over the activity slot while an artifact
10316
- // is selected, so it inherits the row resizer and can be sized freely.
10317
- // It yields back to Activity the moment the selection clears.
10318
- artifactPreviewDetached
10319
- ? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
10320
- : (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null))),
10321
- // The canvas keeps its DOCUMENT alive across toggles: once opened for
10322
- // this session it stays mounted in a hidden layer — unmounting destroys
10323
- // typed input, scroll position, and any running page. Session switches
10324
- // remount fresh via the key.
11073
+ : null,
11074
+ // The canvas keeps its DOCUMENT alive across toggles: once opened for this
11075
+ // session it stays mounted in a hidden layer — unmounting destroys typed
11076
+ // input, scroll position, and any running page. Session switches remount
11077
+ // fresh via the key. It is FIRST in the DOM so it sits left of
11078
+ // diagnostics; while hidden it is display:none and takes no width.
10325
11079
  desktopCanvasEverOpened
10326
11080
  ? React.createElement("div", {
10327
11081
  key: `canvas:${state.activeSessionId || ""}`,
10328
- className: `ps-workspace-column is-artifact ps-canvas-layer${canvasModeActive && !artifactPaneActive ? "" : " is-hidden"}`,
11082
+ className: `ps-workspace-column is-artifact ps-canvas-layer${canvasModeActive && !artifactPaneActive ? "" : " is-hidden"}${canvasMaximized && canvasModeActive && !artifactPaneActive ? " is-maximized" : ""}`,
11083
+ // Sharing the right side: canvas takes a fixed slice and
11084
+ // diagnostics absorbs the rest, so the handle between them moves
11085
+ // one number. Alone, or full screen, it simply fills its box.
11086
+ style: canvasModeActive && !artifactPaneActive && diagnosticsActive && !canvasMaximized
11087
+ ? { flex: `0 0 ${canvasColPx}px`, minWidth: 0 }
11088
+ : undefined,
10329
11089
  ...(canvasModeActive && !artifactPaneActive ? {} : { inert: true, "aria-hidden": "true" }),
10330
11090
  },
10331
- React.createElement(CanvasPane, { controller, visible: canvasModeActive && !artifactPaneActive }))
11091
+ React.createElement(CanvasPane, {
11092
+ controller,
11093
+ visible: canvasModeActive && !artifactPaneActive,
11094
+ maximized: canvasMaximized,
11095
+ }))
11096
+ : null,
11097
+ // Only when BOTH are up. With one column the outer chat/right handle is
11098
+ // already the only split there is, and a second handle would resize
11099
+ // nothing.
11100
+ canvasModeActive && diagnosticsActive
11101
+ ? React.createElement(CanvasDiagnosticsResizeHandle, { controller, canvasPaneAdjust: state.canvasPaneAdjust, diagnosticsPaneAdjust: state.diagnosticsPaneAdjust })
11102
+ : null,
11103
+ // Diagnostics: yesterday's inspector-over-activity column, unchanged
11104
+ // inside, now behind its own toggle and rightmost.
11105
+ diagnosticsActive
11106
+ ? React.createElement("div", {
11107
+ className: "ps-workspace-column",
11108
+ style: {
11109
+ // Continuous PIXEL split, minmax-clamped so neither pane can
11110
+ // be dragged away or collapse behind your back. The old
11111
+ // `${rows}fr` template rode the TUI's row-quantized layout —
11112
+ // ~20px notches and threshold-triggered collapse flips.
11113
+ // minmax(0,...): either pane may be dragged fully shut and
11114
+ // pulled back open with the same seam. The handle clamps the
11115
+ // adjust to the column's real height at drag time, so the bar
11116
+ // tracks the pointer 1:1 instead of detaching at a floor.
11117
+ gridTemplateRows: `minmax(0px, calc(50% + ${Math.round(Number(state.diagnosticsSplitAdjust) || 0)}px)) var(--ps-resizer-track, 16px) minmax(0px, 1fr)`,
11118
+ ...(canvasModeActive ? { flex: "1 1 0%", minWidth: 0 } : null),
11119
+ },
11120
+ },
11121
+ React.createElement("div", {
11122
+ className: "ps-workspace-pane-slot",
11123
+ style: { gridRow: "1" },
11124
+ },
11125
+ React.createElement(InspectorPane, { controller, mobile: false })),
11126
+ React.createElement("div", {
11127
+ style: { gridRow: "2", minHeight: 0, display: "flex", flexDirection: "column" },
11128
+ },
11129
+ React.createElement(DiagnosticsSplitResizeHandle, { controller, splitAdjust: state.diagnosticsSplitAdjust })),
11130
+ React.createElement("div", {
11131
+ className: "ps-workspace-pane-slot",
11132
+ style: { gridRow: "3" },
11133
+ },
11134
+ // The artifact preview takes over the activity slot while an
11135
+ // artifact is selected, so it inherits the row resizer and can
11136
+ // be sized freely. It yields back to Activity the moment the
11137
+ // selection clears.
11138
+ artifactPreviewDetached
11139
+ ? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
11140
+ : React.createElement(ActivityPane, { controller })))
10332
11141
  : null));
10333
11142
  const fullscreenWorkspace = React.createElement("div", { className: "ps-workspace-full" },
10334
11143
  React.createElement(InspectorPane, { controller, mobile: false }));
@@ -10352,8 +11161,25 @@ export function PilotSwarmWebApp({ controller }) {
10352
11161
  React.createElement(InspectorPane, { controller, mobile: true }));
10353
11162
  else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
10354
11163
  React.createElement(ActivityPane, { controller }));
11164
+ // The canvas fills the whole region, so there is nothing to render under
11165
+ // it — the layer below is a SIBLING of this content and covers it. Drawing
11166
+ // a pane here would only be invisible work.
11167
+ else if (mobilePane === "canvas") mobileContent = null;
10355
11168
  else mobileContent = React.createElement(MobileWorkspace, { controller, layout: mobileMainLayout });
10356
11169
 
11170
+ // The phone's canvas layer: a sibling of the content region's pane, NOT a
11171
+ // child of any pane. That is deliberate — panes mount and unmount as the
11172
+ // toolbar switches between Main, Activity and Inspector, and the canvas
11173
+ // frame has to survive all of it. Only a session change (which rekeys it)
11174
+ // or a reload may take a running page down.
11175
+ const mobileCanvasLayer = mobile && mobileCanvasEverOpened
11176
+ ? React.createElement(MobileCanvasLayer, {
11177
+ key: `canvas:${canvasShellView.sessionId || ""}`,
11178
+ controller,
11179
+ visible: mobileCanvasOpen,
11180
+ })
11181
+ : null;
11182
+
10357
11183
  return React.createElement(ControllerContext.Provider, { value: controller },
10358
11184
  React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
10359
11185
  // The phone's toolbar carries its NAVIGATION (the three view modes,
@@ -10362,13 +11188,14 @@ export function PilotSwarmWebApp({ controller }) {
10362
11188
  React.createElement(Toolbar, {
10363
11189
  controller,
10364
11190
  mobile,
10365
- canvasOverlayOpen: mobileCanvasOpen,
10366
- onToggleCanvasOverlay: toggleMobileCanvas,
11191
+ canvasPaneOpen: mobileCanvasOpen,
11192
+ onToggleCanvasPane: toggleMobileCanvas,
10367
11193
  mobilePane,
10368
11194
  mobileMainLayout,
10369
11195
  onSelectMobilePane: (paneId, focus) => {
10370
11196
  // Tapping Main while already on Main cycles its layout; from
10371
- // another pane it just returns you to Main, unchanged.
11197
+ // another pane (the canvas included) it just returns you to
11198
+ // Main, unchanged.
10372
11199
  if (paneId === "workspace" && mobilePane === "workspace") {
10373
11200
  setMobileMainLayout((current) => (
10374
11201
  current === "split" ? "chat" : current === "chat" ? "sessions" : "split"
@@ -10383,17 +11210,10 @@ export function PilotSwarmWebApp({ controller }) {
10383
11210
  ? React.createElement(AdminConsolePanel, { controller, mobile })
10384
11211
  : (filesFullscreenActive
10385
11212
  ? fullscreenWorkspace
10386
- : (mobile ? mobileContent : desktopWorkspace))),
11213
+ : (mobile ? mobileContent : desktopWorkspace)),
11214
+ mobileCanvasLayer),
10387
11215
  mobile && filesFullscreenActive
10388
11216
  ? React.createElement(MobileArtifactOverlay, { controller })
10389
11217
  : null,
10390
- mobile && mobileCanvasEverOpened
10391
- ? React.createElement(MobileCanvasOverlay, {
10392
- key: `canvas:${canvasShellView.sessionId || ""}`,
10393
- controller,
10394
- onClose: closeMobileCanvas,
10395
- visible: mobileCanvasOpen,
10396
- })
10397
- : null,
10398
11218
  React.createElement(ModalLayer, { controller })));
10399
11219
  }