pilotswarm 0.5.37 → 0.5.39

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 (85) hide show
  1. package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
  2. package/mcp/dist/src/tools/agent-packages.js +34 -14
  3. package/mcp/dist/src/tools/agent-packages.js.map +1 -1
  4. package/package.json +3 -3
  5. package/tui/src/node-sdk-transport.js +113 -18
  6. package/ui/core/src/controller.js +196 -49
  7. package/ui/core/src/layout.js +10 -0
  8. package/ui/core/src/reducer.js +265 -20
  9. package/ui/core/src/selectors.js +132 -10
  10. package/ui/core/src/state.js +90 -2
  11. package/ui/core/src/themes/index.js +1 -1
  12. package/ui/react/src/web-app.js +1097 -220
  13. package/web/auth/config.js +3 -0
  14. package/web/auth/index.js +8 -1
  15. package/web/auth/normalize/proxy.js +48 -0
  16. package/web/auth/providers/proxy.js +141 -0
  17. package/web/dist/assets/{abnfDiagram-VRR7QNED-DlvPH-8O.js → abnfDiagram-VRR7QNED-Hsly1fgf.js} +1 -1
  18. package/web/dist/assets/{arc-r1liz5e5.js → arc-DiQLL9vM.js} +1 -1
  19. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Bci5dNH3.js → architectureDiagram-ZJ3FMSHR-B8ry8WNZ.js} +1 -1
  20. package/web/dist/assets/{blockDiagram-677ZJIJ3-Bug32AXp.js → blockDiagram-677ZJIJ3-Z4PcukIo.js} +1 -1
  21. package/web/dist/assets/{c4Diagram-LMCZKHZV-DwxSFqgE.js → c4Diagram-LMCZKHZV-Cahkc0gc.js} +1 -1
  22. package/web/dist/assets/channel-BHLBPE50.js +1 -0
  23. package/web/dist/assets/{chunk-2Q5K7J3B-B_dbI_xg.js → chunk-2Q5K7J3B-CtZuWH4J.js} +1 -1
  24. package/web/dist/assets/{chunk-32BRIVSS-BkF68W-R.js → chunk-32BRIVSS-C0M390-k.js} +1 -1
  25. package/web/dist/assets/{chunk-5VM5RSS4-BSbNIbJu.js → chunk-5VM5RSS4-ClvX9u5j.js} +1 -1
  26. package/web/dist/assets/{chunk-EX3LRPZG-Blv_Iqa2.js → chunk-EX3LRPZG-BVAet5KG.js} +1 -1
  27. package/web/dist/assets/{chunk-JWPE2WC7-Dk_dBFKd.js → chunk-JWPE2WC7-BccmZmrr.js} +1 -1
  28. package/web/dist/assets/{chunk-MOJQB5TN-DqNFBrfw.js → chunk-MOJQB5TN-BN5MKe94.js} +1 -1
  29. package/web/dist/assets/{chunk-RYQCIY6F-Dlv7Svpb.js → chunk-RYQCIY6F-DfWw-ACP.js} +1 -1
  30. package/web/dist/assets/{chunk-V7JOEXUC-DozGikwu.js → chunk-V7JOEXUC-BgUEoLnN.js} +1 -1
  31. package/web/dist/assets/{chunk-VR4S4FIN-CmkqvGT4.js → chunk-VR4S4FIN-k1eKfFNA.js} +1 -1
  32. package/web/dist/assets/{chunk-XXDRQBXY-DyByai5N.js → chunk-XXDRQBXY-DPyV10yR.js} +1 -1
  33. package/web/dist/assets/classDiagram-OUVF2IWQ-rj9D2TSL.js +1 -0
  34. package/web/dist/assets/classDiagram-v2-EOCWNBFH-rj9D2TSL.js +1 -0
  35. package/web/dist/assets/{cose-bilkent-JH36ORCC-Bv-gRVvA.js → cose-bilkent-JH36ORCC-Bt_rpukK.js} +1 -1
  36. package/web/dist/assets/{cynefin-VYW2F7L2-DRnAsyZU.js → cynefin-VYW2F7L2-Z8eMzFMv.js} +1 -1
  37. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-B1fnUtjo.js → cynefinDiagram-TSTJHNR4-BJSNawFi.js} +1 -1
  38. package/web/dist/assets/{dagre-VKFMJZFB-DM6-QENB.js → dagre-VKFMJZFB-D6dNl8jc.js} +1 -1
  39. package/web/dist/assets/{diagram-FQU43EPY-BPxxnLdU.js → diagram-FQU43EPY-Cy1nbGsU.js} +1 -1
  40. package/web/dist/assets/{diagram-G47NLZAW-BgtJT3qF.js → diagram-G47NLZAW-CmrbuA_d.js} +1 -1
  41. package/web/dist/assets/{diagram-NH7WQ7WH-uekeIzsy.js → diagram-NH7WQ7WH-PoLNQE2j.js} +1 -1
  42. package/web/dist/assets/{diagram-OA4YK3LP-BVy3JZWE.js → diagram-OA4YK3LP-BZwNFcSV.js} +1 -1
  43. package/web/dist/assets/{diagram-WEI45ONY-BWdjUr32.js → diagram-WEI45ONY-pKw0b_fN.js} +1 -1
  44. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DD4fwhNK.js → ebnfDiagram-CCIWWBDH-UNctuQCV.js} +1 -1
  45. package/web/dist/assets/{erDiagram-Q63AITRT-BllD_6Kh.js → erDiagram-Q63AITRT-DxIETMif.js} +1 -1
  46. package/web/dist/assets/{flowDiagram-23GEKE2U-QEYKkAUN.js → flowDiagram-23GEKE2U-C06IdNKV.js} +1 -1
  47. package/web/dist/assets/{ganttDiagram-NO4QXBWP-uZcfKTSW.js → ganttDiagram-NO4QXBWP-fU7aGyP1.js} +1 -1
  48. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-XPdbPaH-.js → gitGraphDiagram-IHSO6WYX-9UKkPJm6.js} +1 -1
  49. package/web/dist/assets/index-B8nGlHwb.js +24 -0
  50. package/web/dist/assets/index-DtL0lbis.css +1 -0
  51. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DXCnQOJs.js → infoDiagram-FWYZ7A6U-C6F06SQJ.js} +1 -1
  52. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-687_4FBy.js → ishikawaDiagram-FXEZZL3T-CB-O9NfO.js} +1 -1
  53. package/web/dist/assets/{journeyDiagram-5HDEW3XC-GXlKuda8.js → journeyDiagram-5HDEW3XC-B-8MOf-H.js} +1 -1
  54. package/web/dist/assets/{kanban-definition-HUTT4EX6-C38-2DvN.js → kanban-definition-HUTT4EX6-DX0XA3hI.js} +1 -1
  55. package/web/dist/assets/{linear-lGPXiBcz.js → linear-D60PrjTW.js} +1 -1
  56. package/web/dist/assets/{mermaid.core-C_neEtab.js → mermaid.core-bonzwapf.js} +4 -4
  57. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BaJ9tpEG.js → mindmap-definition-LN4V7U3C-Y-6Dum33.js} +1 -1
  58. package/web/dist/assets/{papaparse.min-rDwKYbBf.js → papaparse.min-Cas1JfRG.js} +1 -1
  59. package/web/dist/assets/{pegDiagram-2B236MQR-BdXJLYgY.js → pegDiagram-2B236MQR-_paO0yDw.js} +1 -1
  60. package/web/dist/assets/{pieDiagram-ENE6RG2P-DU19t6fM.js → pieDiagram-ENE6RG2P-xT1_Gpn4.js} +1 -1
  61. package/web/dist/assets/pilotswarm-BT6tOknQ.js +96 -0
  62. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CBkOdPp0.js → quadrantDiagram-ABIIQ3AL-1usdLFOF.js} +1 -1
  63. package/web/dist/assets/{railroadDiagram-RFXS5EU6-D2DMcGDw.js → railroadDiagram-RFXS5EU6-R3lwHOz9.js} +1 -1
  64. package/web/dist/assets/{requirementDiagram-TGXJPOKE-Bx9tF1pr.js → requirementDiagram-TGXJPOKE-BL8J1__Z.js} +1 -1
  65. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DcLlRfsi.js → sankeyDiagram-HTMAVEWB-Dz6--0pl.js} +1 -1
  66. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-xr6UVRBO.js → sequenceDiagram-DBY2YBRQ-CG2Xpkct.js} +1 -1
  67. package/web/dist/assets/{sizeCapture-X5ZJPWSS-0fUkSXmg.js → sizeCapture-X5ZJPWSS-BPbYfjxj.js} +1 -1
  68. package/web/dist/assets/{stateDiagram-2N3HPSRC-BWgJGGpg.js → stateDiagram-2N3HPSRC-46x_y_aA.js} +1 -1
  69. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-C2tnolSo.js +1 -0
  70. package/web/dist/assets/{swimlanes-5IMT3BWC-CZKya4_i.js → swimlanes-5IMT3BWC-SXdMo4zJ.js} +2 -2
  71. package/web/dist/assets/swimlanesDiagram-G3AALYLV-Dh6Sn__t.js +8 -0
  72. package/web/dist/assets/{timeline-definition-FHXFAJF6-Df8BpeAu.js → timeline-definition-FHXFAJF6-CGgT-b38.js} +1 -1
  73. package/web/dist/assets/{vennDiagram-L72KCM5P-ChX5bjT6.js → vennDiagram-L72KCM5P-BGS3xBa_.js} +1 -1
  74. package/web/dist/assets/{wardleyDiagram-EHGQE667-Dr48BgaJ.js → wardleyDiagram-EHGQE667-m2U3wEZJ.js} +1 -1
  75. package/web/dist/assets/{xychartDiagram-FW5EYKEG-Cia_MFfc.js → xychartDiagram-FW5EYKEG-DI6tQysg.js} +1 -1
  76. package/web/dist/index.html +3 -3
  77. package/web/runtime.js +30 -7
  78. package/web/dist/assets/channel-jVpSYat3.js +0 -1
  79. package/web/dist/assets/classDiagram-OUVF2IWQ-C78zSajE.js +0 -1
  80. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C78zSajE.js +0 -1
  81. package/web/dist/assets/index-BuDSoRIL.css +0 -1
  82. package/web/dist/assets/index-D1itTxvB.js +0 -24
  83. package/web/dist/assets/pilotswarm-BNKGG936.js +0 -96
  84. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DmAC8DdB.js +0 -1
  85. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CVYx20_X.js +0 -8
@@ -17,6 +17,7 @@ import {
17
17
  formatCompactNumber,
18
18
  formatCronTimestampForClient,
19
19
  formatHumanDurationSeconds,
20
+ getDiagnosticsSplitAdjustBounds,
20
21
  getPromptInputRows,
21
22
  getTheme,
22
23
  isThemeLight,
@@ -252,6 +253,16 @@ function normalizeProfileSettings(settings) {
252
253
  if (candidate.rightPaneMode === "canvas" || candidate.rightPaneMode === "panes") {
253
254
  normalized.rightPaneMode = candidate.rightPaneMode;
254
255
  }
256
+ // The two independent desktop columns. This normalizer WHITELISTS keys, so
257
+ // an unlisted one is dropped on the way to the server without a word —
258
+ // which is exactly how a toggle looks like it never persisted.
259
+ if (hasOwn(candidate, "desktopPanes") && candidate.desktopPanes && typeof candidate.desktopPanes === "object") {
260
+ normalized.desktopPanes = {
261
+ canvasOpen: candidate.desktopPanes.canvasOpen === true,
262
+ diagnosticsOpen: candidate.desktopPanes.diagnosticsOpen === true,
263
+ zen: candidate.desktopPanes.zen === true,
264
+ };
265
+ }
255
266
  if (hasOwn(candidate, "canvasPrefs")) {
256
267
  normalized.canvasPrefs = normalizeStoredCanvasPrefs(candidate.canvasPrefs);
257
268
  }
@@ -319,7 +330,7 @@ function hasOwn(value, key) {
319
330
  return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
320
331
  }
321
332
 
322
- function profileSettingsFromViewState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null) {
333
+ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null, preservedDesktopPanes = null) {
323
334
  return normalizeProfileSettings({
324
335
  themeId: state.themeId,
325
336
  [touchScaleKey()]: state.touchScale,
@@ -329,6 +340,9 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
329
340
  sessionPaneAdjust: state.sessionPaneAdjust,
330
341
  portalSessionColumnAdjust: state.portalSessionColumnAdjust,
331
342
  activityPaneAdjust: state.activityPaneAdjust,
343
+ canvasPaneAdjust: state.canvasPaneAdjust,
344
+ diagnosticsPaneAdjust: state.diagnosticsPaneAdjust,
345
+ diagnosticsSplitAdjust: state.diagnosticsSplitAdjust,
332
346
  },
333
347
  pinnedSessionIds: state.pinnedIds,
334
348
  sessionOrder: state.manualOrder,
@@ -339,11 +353,23 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
339
353
  // write its own value — it preserves the last-known stored one, or a
340
354
  // desktop that flipped to canvas would be flipped back by the phone's
341
355
  // next save.
356
+ //
357
+ // desktopPanes replaces it and is preserved the same way, for the same
358
+ // reason. rightPaneMode is still written so a rolled-back build reads
359
+ // something sane.
342
360
  ...(isNarrowViewport()
343
- ? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
344
- ? { rightPaneMode: preservedRightPaneMode }
345
- : {})
346
- : { rightPaneMode: state.rightPaneMode }),
361
+ ? {
362
+ ...(preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
363
+ ? { rightPaneMode: preservedRightPaneMode }
364
+ : {}),
365
+ ...(preservedDesktopPanes && typeof preservedDesktopPanes === "object"
366
+ ? { desktopPanes: preservedDesktopPanes }
367
+ : {}),
368
+ }
369
+ : {
370
+ rightPaneMode: state.canvasOpen ? "canvas" : "panes",
371
+ desktopPanes: { canvasOpen: state.canvasOpen === true, diagnosticsOpen: state.diagnosticsOpen === true, zen: state.canvasZen === true },
372
+ }),
347
373
  canvasPrefs: state.canvasPrefs,
348
374
  // setCurrentUserProfileSettings REPLACES the settings object, so the
349
375
  // other device class's slot has to be written back verbatim or saving
@@ -374,7 +400,14 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherTouchScale =
374
400
  ? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
375
401
  ? { rightPaneMode: preservedRightPaneMode }
376
402
  : {})
377
- : { rightPaneMode: state?.ui?.rightPaneMode }),
403
+ : {
404
+ rightPaneMode: state?.ui?.rightPaneMode,
405
+ desktopPanes: {
406
+ canvasOpen: state?.ui?.canvasOpen === true,
407
+ diagnosticsOpen: state?.ui?.diagnosticsOpen === true,
408
+ zen: state?.ui?.canvasZen === true,
409
+ },
410
+ }),
378
411
  canvasPrefs: state?.canvas?.prefs,
379
412
  ...(typeof preservedOtherTouchScale === "boolean"
380
413
  ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
@@ -423,6 +456,9 @@ function materializeProfileSettings(remoteSettings, defaults) {
423
456
  // clobber a local flip/badge advance the server has not persisted yet.
424
457
  // An omitted key leaves the profileSettings/apply guards preserving
425
458
  // the current value.
459
+ ...(hasOwn(normalizedRemote, "desktopPanes")
460
+ ? { desktopPanes: normalizedRemote.desktopPanes }
461
+ : {}),
426
462
  ...(hasOwn(normalizedRemote, "rightPaneMode")
427
463
  ? { rightPaneMode: normalizedRemote.rightPaneMode }
428
464
  : {}),
@@ -475,6 +511,65 @@ function clampNumber(value, min, max) {
475
511
  return Math.max(min, Math.min(max, value));
476
512
  }
477
513
 
514
+ /**
515
+ * Below this, releasing a drag CLOSES the column rather than leaving a sliver.
516
+ *
517
+ * "Resized to 0 toggles it off" is the rule, but a pointer rarely lands on
518
+ * exactly 0 — without a threshold you get a 3px column you cannot read, cannot
519
+ * grab, and did not ask for. 120px is about the narrowest either column is
520
+ * still useful at.
521
+ */
522
+ const PORTAL_COLUMN_SNAP_CLOSED_PX = 120;
523
+
524
+ /** Width of the draggable track between the canvas and diagnostics columns. */
525
+ const PORTAL_COLUMN_RESIZER_PX = 16;
526
+
527
+ /**
528
+ * The narrowest the canvas should OPEN at. Not a clamp — drag it smaller if
529
+ * you like, or drag it away entirely.
530
+ *
531
+ * A canvas is a rendered page being looked at, so it needs enough width for a
532
+ * normal document column. The inherited fr split left it near 200px.
533
+ */
534
+ const PORTAL_CANVAS_MIN_PX = 480;
535
+
536
+ /**
537
+ * Wide enough to OPEN the canvas by default on a first visit.
538
+ *
539
+ * Sessions and chat need roughly 224px + 500px to stay comfortable, and the
540
+ * canvas wants PORTAL_CANVAS_MIN_PX beside them. 1440 clears that with room to
541
+ * spare and is a real device width (a 14" MacBook, a 900p desktop), so the
542
+ * common laptop gets a canvas and a small window does not get three cramped
543
+ * columns.
544
+ */
545
+ const PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX = 1440;
546
+
547
+ /**
548
+ * Whether this profile has ever expressed a preference about the two columns.
549
+ *
550
+ * The width-based default must fire only in its absence: someone who CLOSED
551
+ * the canvas has stored `{canvasOpen: false}`, which is a choice and reads
552
+ * nothing like "unset". A legacy rightPaneMode counts too — it is the same
553
+ * preference in the older shape.
554
+ */
555
+ function hasStoredDesktopPanes(settings) {
556
+ if (!settings || typeof settings !== "object") return false;
557
+ if (settings.desktopPanes && typeof settings.desktopPanes === "object") return true;
558
+ return settings.rightPaneMode === "canvas" || settings.rightPaneMode === "panes";
559
+ }
560
+
561
+ /**
562
+ * The widths the two optional columns OPEN at, in pixels. 2:1 — the canvas is
563
+ * the thing being looked at; diagnostics is a readout beside it.
564
+ *
565
+ * Pixels, not shares of the window, because of the rule the fr model broke:
566
+ * when a column closes, the freed space goes to chat + sessions — never to
567
+ * the surviving column. A pane owns its width; chat flexes.
568
+ */
569
+ const PORTAL_CANVAS_COL_DEFAULT_PX = 640;
570
+ const PORTAL_DIAG_COL_DEFAULT_PX = 320;
571
+
572
+
478
573
  function portalSessionColumnBounds(width) {
479
574
  const safeWidth = Math.max(0, Number(width) || 0);
480
575
  const availableWidth = Math.max(
@@ -3831,6 +3926,7 @@ function SessionDetailBox({ session, childCount = 0 }) {
3831
3926
  // used, so the box height still cannot move with the selection.
3832
3927
  field("Title", session?.title, "is-title"),
3833
3928
  field("ID", session?.sessionId, "is-id"),
3929
+ field("Owner", session?.owner ? formatAdminPrincipalLabel(session.owner) : null),
3834
3930
  field("Model", model),
3835
3931
  field("Context", context, percent != null && percent >= 85 ? "is-hot" : percent != null && percent >= 70 ? "is-warm" : ""),
3836
3932
  field("Cron", cron, session?.cronActive === true ? "is-armed" : ""),
@@ -4056,6 +4152,7 @@ function SessionRowContent({ row, theme, structured = false, showInlineDetail =
4056
4152
  return React.createElement(React.Fragment, null,
4057
4153
  plain.statusColor ? React.createElement(StatusDot, { color: plain.statusColor, node: row.depth > 0 }) : null,
4058
4154
  React.createElement(OwnerAvatar, { badge: row.ownerBadge }),
4155
+ React.createElement(SessionCanvasMark, { mark: row.canvasMark }),
4059
4156
  React.createElement("span", { className: "ps-session-row-title__text" },
4060
4157
  React.createElement(Runs, { runs: plain.rest, theme })));
4061
4158
  }
@@ -4078,6 +4175,7 @@ function SessionRowContent({ row, theme, structured = false, showInlineDetail =
4078
4175
  React.createElement("div", { className: "ps-session-row-title" },
4079
4176
  title.statusColor ? React.createElement(StatusDot, { color: title.statusColor, node: row.depth > 0 }) : null,
4080
4177
  React.createElement(OwnerAvatar, { badge: row.ownerBadge }),
4178
+ React.createElement(SessionCanvasMark, { mark: row.canvasMark }),
4081
4179
  // Runs wrapped in ONE element: the title is a flex row so the
4082
4180
  // avatar centres against the text, and the runs keep normal
4083
4181
  // inline flow inside the wrapper. Flexing the runs directly
@@ -4233,6 +4331,12 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4233
4331
  // so the reconstruction below must carry it or the click does nothing
4234
4332
  // visible (the same omission that blinded the Node Map).
4235
4333
  listDeselected: Boolean(state.sessions.listDeselected),
4334
+ // The canvas markers read these. This synthetic state is exactly the
4335
+ // trap the branding comment below describes: omit a slice here and
4336
+ // every row computes as if it were empty — the markers were null on
4337
+ // every row until these two lines existed.
4338
+ canvasBySessionId: state.canvas?.bySessionId,
4339
+ canvasPrefs: state.canvas?.prefs,
4236
4340
  }), shallowEqualObject);
4237
4341
  const computedRows = React.useMemo(() => selectSessionRows({
4238
4342
  sessions: {
@@ -4256,7 +4360,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4256
4360
  // deployment, while the controller's own calls (which pass real state)
4257
4361
  // produced the branded name.
4258
4362
  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]);
4363
+ canvas: {
4364
+ bySessionId: viewState.canvasBySessionId,
4365
+ prefs: viewState.canvasPrefs,
4366
+ },
4367
+ }), [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
4368
  // Hold the previous rows when a poll produced identical output.
4261
4369
  const rows = useStableValue(computedRows);
4262
4370
  const activeSession = viewState.activeSessionId
@@ -5941,6 +6049,49 @@ function ArtifactTakeoverPane({ controller, onClose }) {
5941
6049
  })));
5942
6050
  }
5943
6051
 
6052
+ /**
6053
+ * The phone's canvas: a LAYER over the content region, not a full screen.
6054
+ *
6055
+ * It used to be a fixed, full-viewport overlay — the portal header and the
6056
+ * toolbar disappeared behind it too, so looking at a drawing meant leaving the
6057
+ * app and finding your way back. It now fills exactly the region the Main,
6058
+ * Activity and Inspector panes share: top edge under the toolbar, bottom edge
6059
+ * at the viewport, edge to edge, no inset. The header and the toolbar (with
6060
+ * its Canvas button, the only way in or out — there is no ✕ here) stay put.
6061
+ *
6062
+ * It is rendered as a SIBLING of that region's content, not inside any pane,
6063
+ * because the frame has to outlive every pane: the canvas runs interactive
6064
+ * pages, and switching to Inspector and back must not reset a running game.
6065
+ *
6066
+ * Maximized, it goes truly full-screen: the same element flips from absolute
6067
+ * inside the region to fixed over the whole viewport, covering the portal
6068
+ * header and the toolbar too. That is a CLASS change on one div and nothing
6069
+ * else — the frame is never moved in the DOM, so a game survives the toggle.
6070
+ * The thin strip persists in both states; only the right-hand button flips.
6071
+ */
6072
+ function MobileCanvasLayer({ controller, visible = true }) {
6073
+ const [maximized, setMaximized] = React.useState(false);
6074
+ const toggleMaximized = React.useCallback(() => setMaximized((current) => !current), []);
6075
+ // Leaving the canvas drops full-screen, so coming back lands in the inset
6076
+ // presentation rather than a takeover the user has to undo before they can
6077
+ // reach the toolbar again.
6078
+ React.useEffect(() => {
6079
+ if (!visible) setMaximized(false);
6080
+ }, [visible]);
6081
+ // Kept MOUNTED while hidden: unmounting destroys the sandboxed document,
6082
+ // and with it everything the user typed and scrolled. The hidden state is
6083
+ // an off-screen transform (see .ps-mobile-canvas-layer.is-hidden), so the
6084
+ // frame keeps a real, unchanged box and no settle-reload fires on re-show.
6085
+ return React.createElement("div", {
6086
+ className: `ps-mobile-canvas-layer${maximized ? " is-maximized" : ""}${visible ? "" : " is-hidden"}`,
6087
+ ...(visible ? {} : { inert: true, "aria-hidden": "true" }),
6088
+ },
6089
+ React.createElement(CanvasPane, {
6090
+ controller, mobile: true, visible, focusOnPromote: true,
6091
+ maximized, onToggleMaximized: toggleMaximized,
6092
+ }));
6093
+ }
6094
+
5944
6095
  /**
5945
6096
  * Mobile artifact viewer — a genuine full-viewport overlay, not a pane.
5946
6097
  *
@@ -5949,19 +6100,6 @@ function ArtifactTakeoverPane({ controller, onClose }) {
5949
6100
  * phone should feel like pushing a detail screen, the way a native app does,
5950
6101
  * not like shrinking content into one more box. Desktop never mounts this.
5951
6102
  */
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
6103
  function MobileArtifactOverlay({ controller }) {
5966
6104
  const view = useControllerSelector(controller, (state) => {
5967
6105
  const id = state.files.selectedArtifactId || "";
@@ -6066,6 +6204,114 @@ function CanvasGlyph() {
6066
6204
  React.createElement("path", { d: "M7 17.5h6M10 14.5v3", ...stroke }));
6067
6205
  }
6068
6206
 
6207
+ /**
6208
+ * Diagnostics: a bug, for "debug".
6209
+ *
6210
+ * Not a cog — the admin console owns that — and not sliders, which the filter
6211
+ * funnel is already close enough to.
6212
+ */
6213
+ function DiagnosticsGlyph() {
6214
+ // Bigger than its neighbours on purpose, and drawn heavier. A bug is a
6215
+ // busier shape than a funnel or a cog: at 16px with hairline legs it
6216
+ // collapsed into a smudge, so it takes 20px and a thicker stroke to read
6217
+ // as the same visual weight.
6218
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" };
6219
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 20, height: 20, "aria-hidden": "true" },
6220
+ // Shell, with the seam that says beetle rather than pill.
6221
+ 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 }),
6222
+ React.createElement("path", { d: "M6.2 10.3h7.6", ...stroke }),
6223
+ // Head and antennae.
6224
+ React.createElement("path", { d: "M7.6 6.5a2.4 2.4 0 0 1 4.8 0", ...stroke }),
6225
+ React.createElement("path", { d: "M8 4.4 6.9 2.9M12 4.4l1.1-1.5", ...stroke }),
6226
+ // Legs: three a side, the outer two angled so the silhouette reads.
6227
+ React.createElement("path", { d: "M6.3 8.6 3.8 7.4M6.2 10.6H3.6M6.3 12.6 3.8 13.9", ...stroke }),
6228
+ React.createElement("path", { d: "M13.7 8.6 16.2 7.4M13.8 10.6h2.6M13.7 12.6l2.5 1.3", ...stroke }));
6229
+ }
6230
+
6231
+ /**
6232
+ * The canvas marker on a session row.
6233
+ *
6234
+ * Two states, and they must be tellable apart at a glance in a dense list:
6235
+ * "canvas" outline only — a canvas exists here
6236
+ * "unseen" outline plus a filled dot — it changed since you last looked
6237
+ *
6238
+ * Shape carries the meaning, not colour alone: the dot is visible in either
6239
+ * theme and to a reader who cannot separate the two hues.
6240
+ */
6241
+ function SessionCanvasMark({ mark }) {
6242
+ if (mark !== "canvas" && mark !== "unseen") return null;
6243
+ const unseen = mark === "unseen";
6244
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
6245
+ return React.createElement("span", {
6246
+ className: `ps-session-canvas-mark${unseen ? " is-unseen" : ""}`,
6247
+ title: unseen ? "Canvas updated since you last viewed it" : "This session has a canvas",
6248
+ "aria-label": unseen ? "Canvas updated" : "Has a canvas",
6249
+ role: "img",
6250
+ },
6251
+ React.createElement("svg", { viewBox: "0 0 16 16", width: 12, height: 12, "aria-hidden": "true" },
6252
+ React.createElement("rect", { x: 1.6, y: 2.6, width: 12.8, height: 9.2, rx: 1.4, ...stroke }),
6253
+ React.createElement("path", { d: "M6 14.4h4", ...stroke }),
6254
+ unseen
6255
+ ? React.createElement("circle", { cx: 12.4, cy: 4.6, r: 2.4, fill: "currentColor", stroke: "none" })
6256
+ : null));
6257
+ }
6258
+
6259
+ /**
6260
+ * The canvas slot controls: a dropdown when the session has more than one
6261
+ * drawn canvas, plus prev/next arrows. Hidden entirely with one canvas —
6262
+ * the common case stays exactly as it always looked. Slot state is
6263
+ * ui.canvasSlot (in-memory; the selector clamps to a drawn slot).
6264
+ */
6265
+ function CanvasSlotControls({ controller, view, compact = false }) {
6266
+ const slots = Array.isArray(view.slots) ? view.slots : [];
6267
+ if (slots.length <= 1) return null;
6268
+ const order = slots.map((s2) => s2.slot);
6269
+ const at = Math.max(0, order.indexOf(view.slot));
6270
+ const go = (delta) => {
6271
+ const next = order[(at + delta + order.length) % order.length];
6272
+ controller.dispatch({ type: "ui/canvasSlot", slot: next });
6273
+ };
6274
+ const labelFor = (s2) => (s2.name ? s2.name : `canvas ${s2.slot}`) + (s2.unseen ? " •" : "");
6275
+ return React.createElement("span", { className: `ps-canvas-slot-controls${compact ? " is-compact" : ""}` },
6276
+ React.createElement("button", {
6277
+ type: "button", className: "ps-artifact-pane-btn",
6278
+ onClick: () => go(-1), title: "Previous canvas", "aria-label": "Previous canvas",
6279
+ }, "\u2039"),
6280
+ React.createElement("select", {
6281
+ className: "ps-canvas-slot-select",
6282
+ value: view.slot,
6283
+ onChange: (e) => controller.dispatch({ type: "ui/canvasSlot", slot: Number(e.target.value) }),
6284
+ "aria-label": "Choose canvas",
6285
+ title: "Choose canvas",
6286
+ }, slots.map((s2) => React.createElement("option", { key: s2.slot, value: s2.slot }, labelFor(s2)))),
6287
+ React.createElement("button", {
6288
+ type: "button", className: "ps-artifact-pane-btn",
6289
+ onClick: () => go(1), title: "Next canvas", "aria-label": "Next canvas",
6290
+ }, "\u203a"));
6291
+ }
6292
+
6293
+ /** Zen: a narrow chat rail beside a wide canvas. */
6294
+ function ZenGlyph() {
6295
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" };
6296
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 15, height: 15, "aria-hidden": "true" },
6297
+ React.createElement("rect", { x: 2.2, y: 3.4, width: 3.6, height: 13.2, rx: 1, ...stroke }),
6298
+ React.createElement("rect", { x: 8.2, y: 3.4, width: 9.6, height: 13.2, rx: 1, ...stroke }));
6299
+ }
6300
+
6301
+ /** Corners pushing OUT — take the whole screen. */
6302
+ function MaximizeGlyph() {
6303
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
6304
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 13, height: 13, "aria-hidden": "true" },
6305
+ React.createElement("path", { d: "M8 3H3v5M12 3h5v5M12 17h5v-5M8 17H3v-5", ...stroke }));
6306
+ }
6307
+
6308
+ /** Corners pulling IN — back to the canvas inset in the content region. */
6309
+ function RestoreGlyph() {
6310
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
6311
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 13, height: 13, "aria-hidden": "true" },
6312
+ React.createElement("path", { d: "M3 8h5V3M17 8h-5V3M17 12h-5v5M3 12h5v5", ...stroke }));
6313
+ }
6314
+
6069
6315
  /**
6070
6316
  * Double-buffered canvas frame with PER-REVISION blob URLs.
6071
6317
  *
@@ -6076,7 +6322,7 @@ function CanvasGlyph() {
6076
6322
  * loads hidden-but-painted behind the live frame and is promoted on load, so
6077
6323
  * a live redraw is a composite swap of something already drawn.
6078
6324
  */
6079
- function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null }) {
6325
+ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null }) {
6080
6326
  const [live, setLive] = React.useState(null); // { url, rev }
6081
6327
  const [staging, setStaging] = React.useState(null); // { url, rev }
6082
6328
  const [loadError, setLoadError] = React.useState(null); // { rev }
@@ -6131,7 +6377,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6131
6377
  if (!controller || !sessionId || !latestRev) return undefined;
6132
6378
  let cancelled = false;
6133
6379
  let retryTimer = null;
6134
- fetchArtifactHtmlObjectUrl(controller, sessionId, "canvas.html")
6380
+ fetchArtifactHtmlObjectUrl(controller, sessionId, slot <= 1 ? "canvas.html" : `canvas${slot}.html`)
6135
6381
  .then((url) => {
6136
6382
  if (cancelled) { URL.revokeObjectURL(url); return; }
6137
6383
  // A staging revision that never loaded is superseded here —
@@ -6159,7 +6405,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6159
6405
  cancelled = true;
6160
6406
  if (retryTimer) clearTimeout(retryTimer);
6161
6407
  };
6162
- }, [controller, sessionId, latestRev, retryTick]);
6408
+ }, [controller, sessionId, slot, latestRev, retryTick]);
6163
6409
 
6164
6410
  // Session switch / unmount: drop both frames — a different session is a
6165
6411
  // different document, and showing the old canvas under a new session is a
@@ -6201,7 +6447,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6201
6447
  // toggle) still promotes new revisions in the background — those are
6202
6448
  // NOT viewed; the badge must light until the user actually looks.
6203
6449
  if (controller && sessionId && visibleRef.current) {
6204
- controller.dispatch({ type: "canvas/viewed", sessionId, rev: frame.rev });
6450
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, rev: frame.rev });
6205
6451
  }
6206
6452
  // iOS: a programmatic focus into the freshly promoted frame is the
6207
6453
  // synchronous interaction that registers its scroller — the code
@@ -6229,7 +6475,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6229
6475
  // On-screen injection = seen; hidden injection leaves the badge lit
6230
6476
  // until the user actually looks (the catch-up below files it then).
6231
6477
  if (controller && sessionId && visibleRef.current) {
6232
- controller.dispatch({ type: "canvas/viewed", sessionId, dataRev });
6478
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, dataRev });
6233
6479
  }
6234
6480
  }, [live, dataRev, dataPayload, controller, sessionId]);
6235
6481
 
@@ -6237,7 +6483,7 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6237
6483
  // viewing it — this is what clears a badge earned while hidden.
6238
6484
  React.useEffect(() => {
6239
6485
  if (visible && live && controller && sessionId) {
6240
- controller.dispatch({ type: "canvas/viewed", sessionId, rev: live.rev, ...(dataRev ? { dataRev } : {}) });
6486
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, rev: live.rev, ...(dataRev ? { dataRev } : {}) });
6241
6487
  if (focusOnPromote) {
6242
6488
  try { liveIframeElRef.current?.focus({ preventScroll: true }); } catch { /* best effort */ }
6243
6489
  }
@@ -6327,12 +6573,20 @@ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, f
6327
6573
  }
6328
6574
 
6329
6575
  /**
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.
6576
+ * The session's standing display: rendered from the reserved canvas.html
6577
+ * artifact and converging live on canvas_updated events.
6578
+ *
6579
+ * Desktop mounts it in the right column, where it replaces the
6580
+ * inspector/activity split while active (the artifact reader still takes
6581
+ * precedence while open and returns here). The phone mounts it as a layer over
6582
+ * the content region — see MobileCanvasLayer — and gets a stripped header: the
6583
+ * revision on one thin line with a maximize toggle, no zoom control and no ✕,
6584
+ * because there the toolbar's Canvas button is the only way in or out.
6585
+ *
6586
+ * maximized / onToggleMaximized are the phone's full-screen state, owned by
6587
+ * MobileCanvasLayer. Passing no handler simply omits the button.
6334
6588
  */
6335
- function CanvasPane({ controller, mobile = false, onClose = null, visible = true, focusOnPromote = false }) {
6589
+ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote = false, maximized = false, onToggleMaximized = null }) {
6336
6590
  const view = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
6337
6591
  const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
6338
6592
  const setZoom = React.useCallback((next) => {
@@ -6346,38 +6600,127 @@ function CanvasPane({ controller, mobile = false, onClose = null, visible = true
6346
6600
  if (view.sessionId) controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
6347
6601
  }, [controller, view.sessionId]);
6348
6602
 
6349
- const backToPanes = React.useCallback(() => {
6350
- controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: view.sessionId, manual: true });
6603
+ const zenOn = useControllerSelector(controller, (s) => s?.ui?.canvasZen === true);
6604
+ const [toolbarSlot, setToolbarSlot] = React.useState(null);
6605
+ React.useEffect(() => {
6606
+ if (!maximized) { setToolbarSlot(null); return undefined; }
6607
+ // The slot is rendered by the Toolbar in the SAME commit that flips
6608
+ // maximized, so poll one frame rather than racing it.
6609
+ let raf = 0;
6610
+ const find = () => {
6611
+ const el = document.getElementById("ps-toolbar-canvas-slot");
6612
+ if (el) setToolbarSlot(el);
6613
+ else raf = requestAnimationFrame(find);
6614
+ };
6615
+ find();
6616
+ return () => cancelAnimationFrame(raf);
6617
+ }, [maximized]);
6618
+ const closeCanvas = React.useCallback(() => {
6619
+ controller.dispatch({ type: "ui/canvasOpen", open: false, sessionId: view.sessionId, manual: true });
6351
6620
  }, [controller, view.sessionId]);
6352
- const close = onClose || (!mobile ? backToPanes : null);
6621
+
6622
+ // The phone's header is ONE thin line: `rev 4` on the left, the
6623
+ // maximize/restore toggle hard right. Nothing else — no ✕ (the toolbar's
6624
+ // Canvas button is the way out) and no zoom control, because every pixel
6625
+ // of height here is canvas the user can no longer see.
6626
+ //
6627
+ // The strip renders whenever there is a revision to name OR the canvas is
6628
+ // maximized. That second half is not cosmetic: maximized covers the whole
6629
+ // viewport including the toolbar, so if a cleared canvas (sizeBytes 0)
6630
+ // dropped the strip it would take the only way back with it.
6631
+ const showRevStrip = mobile && (view.exists || maximized);
6632
+ const header = mobile
6633
+ ? (showRevStrip
6634
+ ? React.createElement("header", { className: "ps-artifact-pane-bar is-rev-strip" },
6635
+ React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6636
+ view.exists ? `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}` : ""),
6637
+ React.createElement(CanvasSlotControls, { controller, view, compact: true }),
6638
+ onToggleMaximized
6639
+ ? React.createElement("button", {
6640
+ type: "button",
6641
+ className: "ps-canvas-max-btn",
6642
+ onClick: onToggleMaximized,
6643
+ title: maximized ? "Back to the inset canvas" : "Maximize — cover the whole screen",
6644
+ "aria-label": maximized ? "Restore canvas" : "Maximize canvas",
6645
+ }, React.createElement(maximized ? RestoreGlyph : MaximizeGlyph))
6646
+ : null)
6647
+ : null)
6648
+ : (maximized ? (toolbarSlot ? createPortal(React.createElement(React.Fragment, null,
6649
+ view.exists
6650
+ ? React.createElement("span", { className: "ps-toolbar-canvas-rev" }, `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}`)
6651
+ : null,
6652
+ React.createElement(CanvasSlotControls, { controller, view, compact: true }),
6653
+ view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6654
+ React.createElement("button", {
6655
+ type: "button",
6656
+ className: `ps-artifact-pane-btn${zenOn ? " is-active" : ""}`,
6657
+ // Full screen covers the workspace, so a bare zen toggle
6658
+ // changes state UNDER the layer and looks dead. From here zen
6659
+ // means "step down into zen": drop full screen in the same
6660
+ // click so the change is visible.
6661
+ onClick: () => {
6662
+ controller.dispatch({ type: "ui/canvasMaximized", on: false });
6663
+ controller.dispatch({ type: "ui/canvasZen", on: !zenOn });
6664
+ },
6665
+ title: zenOn ? "Leave zen — bring back sessions and panels" : "Zen — chat rail + canvas only",
6666
+ "aria-label": zenOn ? "Leave zen mode" : "Zen mode",
6667
+ }, React.createElement(ZenGlyph)),
6668
+ React.createElement("button", {
6669
+ type: "button",
6670
+ className: "ps-artifact-pane-btn ps-toolbar-canvas-restore",
6671
+ onClick: () => controller.dispatch({ type: "ui/canvasMaximized", on: false }),
6672
+ title: "Back to the workspace",
6673
+ "aria-label": "Restore canvas",
6674
+ }, React.createElement(RestoreGlyph))), toolbarSlot) : null)
6675
+ : React.createElement("header", { className: "ps-artifact-pane-bar" },
6676
+ React.createElement("span", { className: "ps-artifact-pane-heading" },
6677
+ React.createElement("span", { className: "ps-artifact-pane-name ps-canvas-pane-glyph", title: view.note || "Canvas" },
6678
+ React.createElement(CanvasGlyph)),
6679
+ view.exists
6680
+ ? React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6681
+ `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}`)
6682
+ : null,
6683
+ React.createElement(CanvasSlotControls, { controller, view })),
6684
+ React.createElement("span", { className: "ps-artifact-pane-actions" },
6685
+ view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6686
+ // Zen: sessions step away, chat becomes a rail, the canvas is
6687
+ // the workbench. The mode for "the work happens on the canvas,
6688
+ // chat is for steering".
6689
+ React.createElement("button", {
6690
+ type: "button",
6691
+ className: `ps-artifact-pane-btn${zenOn ? " is-active" : ""}`,
6692
+ onClick: () => controller.dispatch({ type: "ui/canvasZen" }),
6693
+ title: zenOn ? "Leave zen — bring back sessions and panels" : "Zen — chat rail + canvas only",
6694
+ "aria-label": zenOn ? "Leave zen mode" : "Zen mode",
6695
+ }, React.createElement(ZenGlyph)),
6696
+ // Full screen. No ✕ beside it: the toolbar's Canvas button is
6697
+ // the way out, and a second close control in a pane that also
6698
+ // has a toggle is one affordance too many.
6699
+ //
6700
+ // While full screen this whole bar is hidden and the rev and
6701
+ // the way back are promoted into the portal header, so the
6702
+ // control never disappears with the pane that drew it.
6703
+ React.createElement("button", {
6704
+ type: "button",
6705
+ className: "ps-artifact-pane-btn",
6706
+ onClick: () => controller.dispatch({ type: "ui/canvasMaximized", on: true }),
6707
+ title: "Full screen — hide chat and sessions",
6708
+ "aria-label": "Full screen canvas",
6709
+ }, React.createElement(MaximizeGlyph)))));
6353
6710
 
6354
6711
  return React.createElement("section", {
6355
6712
  className: "ps-artifact-pane ps-canvas-pane",
6356
6713
  role: "region",
6357
6714
  "aria-label": "Session canvas",
6358
6715
  },
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)),
6716
+ header,
6376
6717
  React.createElement("div", { className: "ps-artifact-pane-body" },
6377
6718
  view.exists
6378
6719
  ? React.createElement(CanvasFrame, {
6720
+ key: `slot:${view.slot}`,
6379
6721
  controller,
6380
6722
  sessionId: view.sessionId,
6723
+ slot: view.slot,
6381
6724
  latestRev: view.latestRev,
6382
6725
  zoom,
6383
6726
  visible,
@@ -7659,7 +8002,7 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
7659
8002
  tooltipNode);
7660
8003
  }
7661
8004
 
7662
- function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvasOverlay = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
8005
+ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPane = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
7663
8006
  const [headerSlot, setHeaderSlot] = React.useState(null);
7664
8007
  React.useEffect(() => {
7665
8008
  if (typeof document === "undefined") return;
@@ -7671,6 +8014,12 @@ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvas
7671
8014
  }, [mobile]);
7672
8015
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
7673
8016
  const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
8017
+ const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
8018
+ const canvasMaximized = useControllerSelector(controller, (s) => s?.ui?.canvasMaximized === true);
8019
+ // The artifact reader takes over the right side; while it is up, the
8020
+ // surface on screen is NOT the canvas, so the canvas toggle must not
8021
+ // read as active (its next press still shows the canvas).
8022
+ const artifactReaderOpen = useControllerSelector(controller, (s) => Boolean(s?.files?.paneOpen && s?.files?.selectedArtifactId));
7674
8023
 
7675
8024
  // Icon-first toolbar: the glyph is the affordance, the label rides a
7676
8025
  // tooltip (desktop hover via title; mobile long-press via IconButton).
@@ -7698,37 +8047,54 @@ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvas
7698
8047
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
7699
8048
  },
7700
8049
  // 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.
8050
+ // column, the phone lays the canvas over the chat transcript — one
8051
+ // affordance, one badge, and on the phone the ONLY way in or out (the
8052
+ // canvas itself carries no ✕ there). While the canvas is hidden, an
8053
+ // undisplayed revision lights the badge.
7704
8054
  {
7705
8055
  key: "canvas",
7706
8056
  icon: React.createElement(CanvasGlyph),
7707
- label: (mobile ? canvasOverlayOpen : canvasView.mode === "canvas")
7708
- ? (mobile ? "Close the canvas" : "Show inspector and activity")
8057
+ label: (mobile ? canvasPaneOpen : (canvasView.mode === "canvas" && !artifactReaderOpen))
8058
+ ? "Hide the canvas"
7709
8059
  : "Show canvas",
7710
8060
  onClick: () => {
7711
8061
  if (mobile) {
7712
- if (onToggleCanvasOverlay) onToggleCanvasOverlay();
8062
+ if (onToggleCanvasPane) onToggleCanvasPane();
7713
8063
  return;
7714
8064
  }
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
- }
8065
+ // Desktop: the canvas is a column of its own now. Closing it
8066
+ // shows nothing in its place — Diagnostics has its own toggle.
8067
+ controller.dispatch({
8068
+ type: "ui/canvasOpen",
8069
+ open: canvasView.mode !== "canvas",
8070
+ sessionId: canvasView.sessionId,
8071
+ manual: true,
8072
+ });
7720
8073
  },
7721
- active: mobile ? canvasOverlayOpen : canvasView.mode === "canvas",
8074
+ active: mobile ? canvasPaneOpen : (canvasView.mode === "canvas" && !artifactReaderOpen),
7722
8075
  badge: mobile
7723
8076
  ? (canvasView.exists
7724
8077
  && (canvasView.latestRev > (canvasView.lastViewedRev || 0)
7725
8078
  || (canvasView.latestDataRev || 0) > (canvasView.lastViewedDataRev || 0))
7726
- && !canvasOverlayOpen)
8079
+ && !canvasPaneOpen)
7727
8080
  : canvasView.unseen,
7728
8081
  // Three states: plain outline (empty), green dot bottom-left
7729
8082
  // (loaded), green + yellow (loaded with unseen changes).
7730
8083
  loadedDot: canvasView.exists,
7731
8084
  },
8085
+ // Diagnostics — Inspector + Activity as one column, desktop only.
8086
+ //
8087
+ // They used to be tabs inside the chat pane and were always present.
8088
+ // Off by default now, and behind this toggle: they are instrumentation,
8089
+ // not part of reading a conversation. The phone keeps them as nav tabs,
8090
+ // where there is no column to put them in.
8091
+ ...(mobile ? [] : [{
8092
+ key: "diagnostics",
8093
+ icon: React.createElement(DiagnosticsGlyph),
8094
+ label: diagnosticsOpen ? "Hide diagnostics" : "Show diagnostics (inspector and activity)",
8095
+ onClick: () => controller.dispatch({ type: "ui/diagnosticsOpen" }),
8096
+ active: diagnosticsOpen,
8097
+ }]),
7732
8098
  // Admin console is desktop-only: its settings tree, package detail
7733
8099
  // and file preview need width the phone layout cannot give them, so
7734
8100
  // the button is omitted rather than shipped half-working.
@@ -7773,20 +8139,34 @@ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvas
7773
8139
  chat: "Main — chat only (tap for sessions only)",
7774
8140
  sessions: "Main — sessions only (tap for both)",
7775
8141
  };
8142
+ // Two tabs, not three: the row was out of space, and Inspector +
8143
+ // Activity are one idea on the desktop already (Diagnostics, the
8144
+ // bug). The bug button opens the inspector; tapping it again while
8145
+ // inside diagnostics flips inspector <-> activity; the main button is
8146
+ // always the way back.
8147
+ const onDiagnostics = mobilePane === "inspector" || mobilePane === "activity";
8148
+ const nextDiagnosticsPane = mobilePane === "inspector" ? "activity" : "inspector";
7776
8149
  const paneDefs = [
7777
8150
  { 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" },
8151
+ {
8152
+ id: nextDiagnosticsPane,
8153
+ icon: DiagnosticsGlyph,
8154
+ active: onDiagnostics,
8155
+ label: onDiagnostics
8156
+ ? (mobilePane === "inspector" ? "Diagnostics — inspector (tap for activity)" : "Diagnostics — activity (tap for inspector)")
8157
+ : "Diagnostics — inspector and activity",
8158
+ focus: nextDiagnosticsPane,
8159
+ },
7780
8160
  ];
7781
8161
  return React.createElement("div", { className: "ps-toolbar is-mobile" },
7782
8162
  React.createElement("div", { className: "ps-toolbar-row ps-toolbar-row-primary" },
7783
8163
  React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton)),
7784
8164
  React.createElement("div", { className: "ps-toolbar-row-actions is-panes" },
7785
8165
  paneDefs.map((def) => React.createElement(IconButton, {
7786
- key: def.id,
8166
+ key: def.id === "workspace" ? "workspace" : "diagnostics",
7787
8167
  icon: React.createElement(def.icon),
7788
8168
  label: def.label,
7789
- active: mobilePane === def.id,
8169
+ active: def.active ?? (mobilePane === def.id),
7790
8170
  onClick: () => {
7791
8171
  if (onSelectMobilePane) onSelectMobilePane(def.id, def.focus);
7792
8172
  },
@@ -7794,8 +8174,54 @@ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvas
7794
8174
  );
7795
8175
  }
7796
8176
 
8177
+ // Two groups. Diagnostics and the admin console are TOOLS — they act on
8178
+ // the app rather than on the conversation — so they sit apart from the
8179
+ // session controls, hard right. Everything else keeps its order.
8180
+ const RIGHT_GROUP = new Set(["diagnostics", "admin"]);
8181
+
8182
+ // While the canvas is full screen, ANY other button first drops full
8183
+ // screen and then does its own job. Pressing Filter and watching nothing
8184
+ // happen behind a canvas — or watching a panel open where you cannot see
8185
+ // it — is the confusing half of every full-screen mode. The Canvas toggle
8186
+ // is exempt: it already means "put the canvas away", and closing drops the
8187
+ // flag in the reducer.
8188
+ const withRestore = (def) => (!canvasMaximized || def.key === "canvas" ? def : {
8189
+ ...def,
8190
+ onClick: (...args) => {
8191
+ controller.dispatch({ type: "ui/canvasMaximized", on: false });
8192
+ return def.onClick?.(...args);
8193
+ },
8194
+ });
8195
+
8196
+ const leftButtons = buttonDefs.filter((d) => !RIGHT_GROUP.has(d.key)).map(withRestore);
8197
+ const rightButtons = buttonDefs.filter((d) => RIGHT_GROUP.has(d.key)).map(withRestore);
7797
8198
  const toolbar = React.createElement("div", { className: "ps-toolbar" },
7798
- React.createElement("div", { className: "ps-toolbar-actions" }, buttonDefs.map(renderButton)),
8199
+ // Three columns: left rail | main controls | right rail. Full-screen
8200
+ // canvas uses the otherwise-empty left rail for the normal actions so
8201
+ // they do not crowd the canvas metadata and controls across the top.
8202
+ React.createElement("div", { className: "ps-toolbar-side is-left" },
8203
+ canvasMaximized ? leftButtons.map(renderButton) : null),
8204
+ React.createElement("div", { className: "ps-toolbar-actions" },
8205
+ canvasMaximized ? null : leftButtons.map(renderButton)),
8206
+ React.createElement("div", { className: "ps-toolbar-side is-right" },
8207
+ // The portal header parks its version/status meta here, LEFT of
8208
+ // the tool buttons, and its sign-out glyph in the slot after
8209
+ // them — one right-aligned cluster: meta · bug · settings · out.
8210
+ React.createElement("span", { className: "ps-toolbar-meta-slot", id: "ps-toolbar-meta-slot" }),
8211
+ // While the canvas is full screen its header controls (rev, zoom,
8212
+ // zen, restore) portal INTO this slot from CanvasPane — zoom state
8213
+ // lives there, so the controls come to the toolbar rather than
8214
+ // their state moving out.
8215
+ canvasMaximized
8216
+ ? React.createElement("span", { className: "ps-toolbar-canvas-slot", id: "ps-toolbar-canvas-slot" })
8217
+ : null,
8218
+ canvasMaximized
8219
+ ? React.createElement("span", { className: "ps-toolbar-divider", "aria-hidden": "true" })
8220
+ : null,
8221
+ rightButtons.length
8222
+ ? React.createElement("div", { className: "ps-toolbar-actions is-tools" }, rightButtons.map(renderButton))
8223
+ : null,
8224
+ React.createElement("span", { className: "ps-toolbar-signout-slot", id: "ps-toolbar-signout-slot" })),
7799
8225
  );
7800
8226
 
7801
8227
  // Rich desktop UI: the toolbar lives IN the portal header (one top bar)
@@ -8084,112 +8510,318 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
8084
8510
  React.createElement("span", { className: "ps-column-resizer-dot" })));
8085
8511
  }
8086
8512
 
8513
+
8087
8514
  /**
8088
- * How many pixels the inspector/activity seam actually moves per row of adjust.
8515
+ * The seam between the canvas and diagnostics columns.
8089
8516
  *
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.
8517
+ * Only rendered when both are open — with one column the outer chat/right
8518
+ * handle is already the only split there is.
8097
8519
  *
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.
8520
+ * Drag either column below PORTAL_COLUMN_SNAP_CLOSED_PX and releasing CLOSES
8521
+ * it: the user asked for "resized to 0 means toggled off, and it has to be
8522
+ * toggled back on". The adjust is reset at the same time, so re-opening from
8523
+ * the toolbar comes back at the even split rather than at the sliver that
8524
+ * closed it.
8101
8525
  */
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 }) {
8526
+ function CanvasDiagnosticsResizeHandle({ controller, canvasPaneAdjust = 0, diagnosticsPaneAdjust = 0 }) {
8118
8527
  const dragStateRef = React.useRef(null);
8119
8528
  const [dragging, setDragging] = React.useState(false);
8120
8529
 
8530
+ const widths = (ca, da) => ({
8531
+ canvas: PORTAL_CANVAS_COL_DEFAULT_PX + (Number(ca) || 0),
8532
+ diag: PORTAL_DIAG_COL_DEFAULT_PX + (Number(da) || 0),
8533
+ });
8534
+
8535
+ // Moving the seam trades pixels between the two columns; the total — and
8536
+ // therefore chat — does not move. Below the snap on release, the starved
8537
+ // column CLOSES and both widths reset, so reopening is always usable.
8538
+ const commit = (ca, da) => {
8539
+ const w = widths(ca, da);
8540
+ if (w.canvas < PORTAL_COLUMN_SNAP_CLOSED_PX || w.diag < PORTAL_COLUMN_SNAP_CLOSED_PX) {
8541
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: 0 });
8542
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: 0 });
8543
+ controller.dispatch(w.canvas < PORTAL_COLUMN_SNAP_CLOSED_PX
8544
+ ? { type: "ui/canvasOpen", open: false, manual: true }
8545
+ : { type: "ui/diagnosticsOpen", open: false });
8546
+ return;
8547
+ }
8548
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: ca });
8549
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: da });
8550
+ };
8551
+
8121
8552
  React.useEffect(() => {
8122
8553
  if (!dragging) return undefined;
8554
+ const onMove = (event) => {
8555
+ const st = dragStateRef.current;
8556
+ if (!st) return;
8557
+ const dx = event.clientX - st.startX;
8558
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: st.startCanvas + dx });
8559
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: st.startDiag - dx });
8560
+ };
8561
+ const onUp = () => {
8562
+ const st = dragStateRef.current;
8563
+ dragStateRef.current = null;
8564
+ setDragging(false);
8565
+ document.body.classList.remove("is-resizing-pane-x");
8566
+ if (st) {
8567
+ const ui = controller.getState()?.ui?.layout || {};
8568
+ commit(ui.canvasPaneAdjust ?? 0, ui.diagnosticsPaneAdjust ?? 0);
8569
+ }
8570
+ };
8571
+ window.addEventListener("pointermove", onMove);
8572
+ window.addEventListener("pointerup", onUp);
8573
+ window.addEventListener("pointercancel", onUp);
8574
+ return () => {
8575
+ window.removeEventListener("pointermove", onMove);
8576
+ window.removeEventListener("pointerup", onUp);
8577
+ window.removeEventListener("pointercancel", onUp);
8578
+ };
8579
+ }, [controller, dragging]);
8123
8580
 
8124
- const stopDragging = () => {
8581
+ return React.createElement("button", {
8582
+ type: "button",
8583
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8584
+ title: "Drag to trade width between the canvas and diagnostics. Drag one away to close it. Double-click to reset.",
8585
+ "aria-label": "Resize canvas and diagnostics columns",
8586
+ onPointerDown: (event) => {
8587
+ if (event.button !== 0) return;
8588
+ event.preventDefault();
8589
+ capturePointerForDrag(event);
8590
+ dragStateRef.current = {
8591
+ startX: event.clientX,
8592
+ startCanvas: Number(canvasPaneAdjust) || 0,
8593
+ startDiag: Number(diagnosticsPaneAdjust) || 0,
8594
+ };
8595
+ setDragging(true);
8596
+ document.body.classList.add("is-resizing-pane-x");
8597
+ },
8598
+ onDoubleClick: () => {
8599
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: 0 });
8600
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: 0 });
8601
+ },
8602
+ onKeyDown: (event) => {
8603
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8604
+ event.preventDefault();
8605
+ const dx = event.key === "ArrowRight" ? 24 : -24;
8606
+ commit((Number(canvasPaneAdjust) || 0) + dx, (Number(diagnosticsPaneAdjust) || 0) - dx);
8607
+ },
8608
+ },
8609
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8610
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8611
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8612
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8613
+ }
8614
+
8615
+ /** The zen seam: drags the chat rail, remembered per session. */
8616
+ function ZenRailResizeHandle({ controller, sessionId, railPx = 380 }) {
8617
+ const dragStateRef = React.useRef(null);
8618
+ const [dragging, setDragging] = React.useState(false);
8619
+ React.useEffect(() => {
8620
+ if (!dragging) return undefined;
8621
+ const onMove = (event) => {
8622
+ const st = dragStateRef.current;
8623
+ if (!st) return;
8624
+ controller.dispatch({ type: "canvas/zenRail", sessionId, px: st.start + (event.clientX - st.startX) });
8625
+ };
8626
+ const onUp = () => {
8125
8627
  dragStateRef.current = null;
8126
8628
  setDragging(false);
8127
- document.body.classList.remove("is-resizing-pane-y");
8629
+ document.body.classList.remove("is-resizing-pane-x");
8128
8630
  };
8631
+ window.addEventListener("pointermove", onMove);
8632
+ window.addEventListener("pointerup", onUp);
8633
+ window.addEventListener("pointercancel", onUp);
8634
+ return () => {
8635
+ window.removeEventListener("pointermove", onMove);
8636
+ window.removeEventListener("pointerup", onUp);
8637
+ window.removeEventListener("pointercancel", onUp);
8638
+ };
8639
+ }, [controller, dragging, sessionId]);
8640
+ return React.createElement("button", {
8641
+ type: "button",
8642
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8643
+ title: "Drag to resize chat against the canvas (remembered for this session)",
8644
+ "aria-label": "Resize the zen chat rail",
8645
+ onPointerDown: (event) => {
8646
+ if (event.button !== 0) return;
8647
+ event.preventDefault();
8648
+ capturePointerForDrag(event);
8649
+ dragStateRef.current = { startX: event.clientX, start: railPx };
8650
+ setDragging(true);
8651
+ document.body.classList.add("is-resizing-pane-x");
8652
+ },
8653
+ onKeyDown: (event) => {
8654
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8655
+ event.preventDefault();
8656
+ controller.dispatch({ type: "canvas/zenRail", sessionId, px: railPx + (event.key === "ArrowRight" ? 24 : -24) });
8657
+ },
8658
+ },
8659
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8660
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8661
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8662
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8663
+ }
8129
8664
 
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;
8665
+ /**
8666
+ * The seam between chat and the right block. Chat flexes, so this drags the
8667
+ * width of the column beside it — canvas when open, else diagnostics. The
8668
+ * other column never moves: growing chat shrinks only its neighbour.
8669
+ */
8670
+ function ChatRightResizeHandle({ controller, target, adjust = 0 }) {
8671
+ const dragStateRef = React.useRef(null);
8672
+ const [dragging, setDragging] = React.useState(false);
8673
+ const actionType = target === "canvas" ? "ui/canvasPaneAdjust" : "ui/diagnosticsPaneAdjust";
8674
+ const key = target === "canvas" ? "canvasPaneAdjust" : "diagnosticsPaneAdjust";
8675
+ const base = target === "canvas" ? PORTAL_CANVAS_COL_DEFAULT_PX : PORTAL_DIAG_COL_DEFAULT_PX;
8676
+
8677
+ const commit = (next) => {
8678
+ if (base + next < PORTAL_COLUMN_SNAP_CLOSED_PX) {
8679
+ controller.dispatch({ type: actionType, [key]: 0 });
8680
+ controller.dispatch(target === "canvas"
8681
+ ? { type: "ui/canvasOpen", open: false, manual: true }
8682
+ : { type: "ui/diagnosticsOpen", open: false });
8683
+ return;
8684
+ }
8685
+ controller.dispatch({ type: actionType, [key]: next });
8686
+ };
8687
+
8688
+ React.useEffect(() => {
8689
+ if (!dragging) return undefined;
8690
+ const onMove = (event) => {
8691
+ const st = dragStateRef.current;
8692
+ if (!st) return;
8693
+ // Pointer right = chat grows = the neighbour gives up the pixels.
8694
+ controller.dispatch({ type: actionType, [key]: st.startAdjust - (event.clientX - st.startX) });
8695
+ };
8696
+ const onUp = () => {
8697
+ const st = dragStateRef.current;
8698
+ dragStateRef.current = null;
8699
+ setDragging(false);
8700
+ document.body.classList.remove("is-resizing-pane-x");
8701
+ if (st) commit((controller.getState()?.ui?.layout || {})[key] ?? 0);
8702
+ };
8703
+ window.addEventListener("pointermove", onMove);
8704
+ window.addEventListener("pointerup", onUp);
8705
+ window.addEventListener("pointercancel", onUp);
8706
+ return () => {
8707
+ window.removeEventListener("pointermove", onMove);
8708
+ window.removeEventListener("pointerup", onUp);
8709
+ window.removeEventListener("pointercancel", onUp);
8138
8710
  };
8711
+ }, [controller, dragging, actionType, key]);
8139
8712
 
8140
- window.addEventListener("pointermove", onPointerMove);
8141
- window.addEventListener("pointerup", stopDragging);
8142
- window.addEventListener("pointercancel", stopDragging);
8713
+ return React.createElement("button", {
8714
+ type: "button",
8715
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8716
+ title: "Drag to resize. Drag the column away to close it. Double-click to reset.",
8717
+ "aria-label": "Resize the right column",
8718
+ onPointerDown: (event) => {
8719
+ if (event.button !== 0) return;
8720
+ event.preventDefault();
8721
+ capturePointerForDrag(event);
8722
+ dragStateRef.current = { startX: event.clientX, startAdjust: Number(adjust) || 0 };
8723
+ setDragging(true);
8724
+ document.body.classList.add("is-resizing-pane-x");
8725
+ },
8726
+ onDoubleClick: () => controller.dispatch({ type: actionType, [key]: 0 }),
8727
+ onKeyDown: (event) => {
8728
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8729
+ event.preventDefault();
8730
+ commit((Number(adjust) || 0) - (event.key === "ArrowRight" ? 24 : -24));
8731
+ },
8732
+ },
8733
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8734
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8735
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8736
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8737
+ }
8143
8738
 
8739
+ /**
8740
+ * The inspector/activity seam, in continuous pixels — it follows the pointer
8741
+ * 1:1. Replaces the row-quantized ActivityRowResizeHandle for the portal:
8742
+ * that one converted drags to whole terminal rows (~20px notches) against
8743
+ * geometry computed from the whole viewport rather than this column, and the
8744
+ * legacy layout could snap-collapse a pane mid-drag. The grid's minmax does
8745
+ * all the clamping now.
8746
+ */
8747
+ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
8748
+ const dragStateRef = React.useRef(null);
8749
+ const [dragging, setDragging] = React.useState(false);
8750
+ React.useEffect(() => {
8751
+ if (!dragging) return undefined;
8752
+ const onMove = (event) => {
8753
+ const st = dragStateRef.current;
8754
+ if (!st) return;
8755
+ // The first grid track is `50% + adjust`, while the divider takes
8756
+ // real space between the two panes. Its bounds are asymmetric:
8757
+ // -column/2 makes Inspector 0px; column/2-divider makes Activity
8758
+ // 0px. Using one symmetric half-range left an 8-9px pane remnant.
8759
+ const next = clamp(st.start + (event.clientY - st.startY), st.minAdjust, st.maxAdjust);
8760
+ controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: next });
8761
+ };
8762
+ const onUp = () => {
8763
+ dragStateRef.current = null;
8764
+ setDragging(false);
8765
+ document.body.classList.remove("is-resizing-pane-y");
8766
+ };
8767
+ window.addEventListener("pointermove", onMove);
8768
+ window.addEventListener("pointerup", onUp);
8769
+ window.addEventListener("pointercancel", onUp);
8144
8770
  return () => {
8145
- window.removeEventListener("pointermove", onPointerMove);
8146
- window.removeEventListener("pointerup", stopDragging);
8147
- window.removeEventListener("pointercancel", stopDragging);
8771
+ window.removeEventListener("pointermove", onMove);
8772
+ window.removeEventListener("pointerup", onUp);
8773
+ window.removeEventListener("pointercancel", onUp);
8148
8774
  document.body.classList.remove("is-resizing-pane-y");
8149
8775
  };
8150
8776
  }, [controller, dragging]);
8151
-
8152
8777
  return React.createElement("button", {
8153
8778
  type: "button",
8154
8779
  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",
8780
+ title: "Drag to resize the inspector and activity panes. Double-click to reset.",
8781
+ "aria-label": "Resize the inspector and activity panes",
8157
8782
  onPointerDown: (event) => {
8158
8783
  if (event.button !== 0) return;
8159
8784
  event.preventDefault();
8160
8785
  capturePointerForDrag(event);
8786
+ const col = event.currentTarget.closest(".ps-workspace-column");
8787
+ const columnHeight = Math.max(0, col?.getBoundingClientRect?.().height || 0);
8788
+ const dividerHeight = Math.max(0, event.currentTarget.getBoundingClientRect().height || 0);
8789
+ const { minAdjust, maxAdjust } = getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight);
8161
8790
  dragStateRef.current = {
8162
8791
  startY: event.clientY,
8163
- appliedCells: 0,
8164
- // MEASURED, not assumed — see measureActivityRowPx.
8165
- pxPerRow: measureActivityRowPx(controller, event.currentTarget),
8792
+ start: clamp(Number(splitAdjust) || 0, minAdjust, maxAdjust),
8793
+ minAdjust,
8794
+ maxAdjust,
8166
8795
  };
8167
8796
  setDragging(true);
8168
8797
  document.body.classList.add("is-resizing-pane-y");
8169
8798
  },
8170
- onDoubleClick: () => {
8171
- if (!activityPaneAdjust) return;
8172
- controller.adjustActivityPaneSplit(-activityPaneAdjust);
8173
- },
8799
+ onDoubleClick: () => controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: 0 }),
8174
8800
  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
- }
8801
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
8802
+ event.preventDefault();
8803
+ const col = event.currentTarget.closest(".ps-workspace-column");
8804
+ const columnHeight = Math.max(0, col?.getBoundingClientRect?.().height || 0);
8805
+ const dividerHeight = Math.max(0, event.currentTarget.getBoundingClientRect().height || 0);
8806
+ const { minAdjust, maxAdjust } = getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight);
8807
+ controller.dispatch({
8808
+ type: "ui/diagnosticsSplitAdjust",
8809
+ diagnosticsSplitAdjust: clamp(
8810
+ (Number(splitAdjust) || 0) + (event.key === "ArrowDown" ? dividerHeight : -dividerHeight),
8811
+ minAdjust,
8812
+ maxAdjust,
8813
+ ),
8814
+ });
8184
8815
  },
8185
8816
  },
8186
8817
  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" })));
8818
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8819
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8820
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8190
8821
  }
8191
8822
 
8192
8823
 
8824
+
8193
8825
  function ModalLayer({ controller }) {
8194
8826
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
8195
8827
  const touchScale = useControllerSelector(controller, (state) => Boolean(state.ui.touchScale));
@@ -9225,7 +9857,13 @@ function AdminConsolePanel({ controller, mobile = false }) {
9225
9857
  const header = React.createElement("header", { className: "ps-admin-console__header" },
9226
9858
  React.createElement("h2", null, "Admin Console"),
9227
9859
  React.createElement("span", { className: "ps-admin-console__who" }, principalLabel),
9228
- React.createElement("button", { type: "button", className: "ps-mini-button", onClick: onClose }, "Close"));
9860
+ React.createElement("button", {
9861
+ type: "button",
9862
+ className: "ps-mini-button is-icon",
9863
+ onClick: onClose,
9864
+ title: "Close the admin console",
9865
+ "aria-label": "Close the admin console",
9866
+ }, "\u2715"));
9229
9867
 
9230
9868
  const ghcpSection = React.createElement(AdminGhcpSection, {
9231
9869
  view, draftRef, onBeginEdit, onCancelEdit, onClear, onSubmit, onDraftChange, onRefresh, controller,
@@ -9312,10 +9950,15 @@ function AdminWorkersPane({ controller, view }) {
9312
9950
  `${workers.summaryText}${counts.pools > 1 ? ` · ${counts.pools} pools` : ""}${counts.draining ? ` · ${counts.draining} draining` : ""}`)
9313
9951
  : null,
9314
9952
  React.createElement("button", {
9315
- type: "button", className: "ps-mini-button",
9316
- disabled: Boolean(workers.loading),
9953
+ type: "button",
9954
+ className: "ps-mini-button is-icon",
9317
9955
  onClick: () => controller.refreshAdminWorkers(),
9318
- }, workers.loading ? "Refreshing…" : "Refresh")),
9956
+ disabled: Boolean(workers.loading),
9957
+ title: workers.loading ? "Refreshing…" : "Refresh the worker list",
9958
+ "aria-label": "Refresh the worker list",
9959
+ }, 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" },
9960
+ 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" }),
9961
+ React.createElement("path", { d: "M16.4 3.6v4.6h-4.6M3.6 16.4v-4.6h4.6" })))),
9319
9962
  workers.error
9320
9963
  ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, workers.error)
9321
9964
  : null,
@@ -9359,7 +10002,14 @@ function AdminSettingsTree({ controller, view }) {
9359
10002
  `${row.label}`, React.createElement("span", { className: "ps-admin-tree__count" }, String(row.count ?? 0)));
9360
10003
  }
9361
10004
  const onClick = row.kind === "package"
9362
- ? () => controller.selectAdminPackage(row.name)
10005
+ // The selector says WHICH copy this row is — one name can be a
10006
+ // shared package and a user copy at once (scope shadowing).
10007
+ ? () => controller.selectAdminPackage(row.name, {
10008
+ scope: row.scope === "shared" ? "shared" : "user",
10009
+ ...(row.scope !== "shared" && row.owner?.provider && row.owner?.subject
10010
+ ? { owner: { provider: row.owner.provider, subject: row.owner.subject } }
10011
+ : {}),
10012
+ })
9363
10013
  : () => controller.setAdminSection(row.id === "agents" ? "packages" : row.id === "workers" ? "workers" : "ghcp");
9364
10014
  return React.createElement("button", {
9365
10015
  key: row.id,
@@ -9475,8 +10125,29 @@ function AdminPackageDetailPane({ controller, view }) {
9475
10125
  disabled: Boolean(pending),
9476
10126
  title: `Publish a new version of ${detail.name} from a repo folder`,
9477
10127
  }, "Update"),
9478
- React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.scope === "shared" ? "demote" : "promote"), disabled: Boolean(pending) },
9479
- detail.scope === "shared" ? "Demote to user" : "Promote to shared"),
10128
+ // Promote MOVES this row to shared scope, so it only works
10129
+ // while the shared name is free. Once a shared copy exists,
10130
+ // the update path is republish: this version's exact bytes
10131
+ // become a new version of the existing shared package.
10132
+ detail.scope === "user" && detail.hasSharedCounterpart
10133
+ ? React.createElement("button", {
10134
+ type: "button",
10135
+ className: "ps-mini-button",
10136
+ onClick: act("republish", detail.activeSemver),
10137
+ disabled: Boolean(pending) || !detail.activeSemver,
10138
+ title: `Publish ${detail.name}@${detail.activeSemver ?? "?"} into the existing shared package`,
10139
+ }, pending === "republish" ? "Publishing…" : `Publish ${detail.activeSemver ?? ""} to shared`)
10140
+ : React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.scope === "shared" ? "demote" : "promote"), disabled: Boolean(pending) },
10141
+ detail.scope === "shared" ? "Demote to user" : "Promote to shared"),
10142
+ detail.scope === "shared" && !detail.hasOwnUserCounterpart
10143
+ ? React.createElement("button", {
10144
+ type: "button",
10145
+ className: "ps-mini-button",
10146
+ onClick: act("republish", detail.activeSemver),
10147
+ disabled: Boolean(pending) || !detail.activeSemver,
10148
+ title: "Copy the active version into your user scope as a private test bench",
10149
+ }, pending === "republish" ? "Copying…" : "Copy to my user scope")
10150
+ : null,
9480
10151
  React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.enabled ? "disable" : "enable"), disabled: Boolean(pending) },
9481
10152
  detail.enabled ? "Disable" : "Enable"),
9482
10153
  React.createElement("button", { type: "button", className: "ps-mini-button is-danger", onClick: confirmDelete, disabled: Boolean(pending) }, "Delete"))
@@ -9951,6 +10622,11 @@ export function PilotSwarmWebApp({ controller }) {
9951
10622
  activeSessionId: rootState.sessions.activeSessionId || null,
9952
10623
  promptRows: getStatePromptRows(rootState),
9953
10624
  rightPaneMode: rootState.ui.rightPaneMode || "panes",
10625
+ // The two optional desktop columns, independent of each other.
10626
+ canvasOpen: rootState.ui.canvasOpen === true,
10627
+ diagnosticsOpen: rootState.ui.diagnosticsOpen === true,
10628
+ canvasMaximized: rootState.ui.canvasMaximized === true,
10629
+ canvasZen: rootState.ui.canvasZen === true,
9954
10630
  // Live reference — reducer updates replace the object, so
9955
10631
  // shallow-equal sees changes and the save effect fires.
9956
10632
  canvasPrefs: rootState.canvas?.prefs,
@@ -9959,7 +10635,11 @@ export function PilotSwarmWebApp({ controller }) {
9959
10635
  sessionPaneAdjust: rootState.ui.layout?.sessionPaneAdjust ?? 0,
9960
10636
  portalSessionColumnAdjust: rootState.ui.layout?.portalSessionColumnAdjust ?? 0,
9961
10637
  activityPaneAdjust: rootState.ui.layout?.activityPaneAdjust ?? 0,
10638
+ canvasPaneAdjust: rootState.ui.layout?.canvasPaneAdjust ?? 0,
10639
+ diagnosticsPaneAdjust: rootState.ui.layout?.diagnosticsPaneAdjust ?? 0,
10640
+ diagnosticsSplitAdjust: rootState.ui.layout?.diagnosticsSplitAdjust ?? 0,
9962
10641
  focusRegion: rootState.ui.focusRegion,
10642
+ requestedFocusRegion: rootState.ui.requestedFocusRegion,
9963
10643
  inspectorTab: rootState.ui.inspectorTab,
9964
10644
  filesFullscreen: Boolean(rootState.files.fullscreen),
9965
10645
  selectedArtifactId: rootState.files.selectedArtifactId || null,
@@ -9975,6 +10655,9 @@ export function PilotSwarmWebApp({ controller }) {
9975
10655
  // Last-known stored rightPaneMode — the desktop-owned slot a phone must
9976
10656
  // carry through its saves untouched (see profileSettingsFromViewState).
9977
10657
  const desktopRightPaneModeRef = React.useRef(null);
10658
+ // Same preservation contract as desktopRightPaneModeRef: a phone must not
10659
+ // overwrite the desktop's column toggles with its own (absent) ones.
10660
+ const desktopPanesRef = React.useRef(null);
9978
10661
  const profileSettingsSaveTimerRef = React.useRef(null);
9979
10662
  const profileSettingsPollTimerRef = React.useRef(null);
9980
10663
  const profileSettingsPollInFlightRef = React.useRef(false);
@@ -9993,44 +10676,54 @@ export function PilotSwarmWebApp({ controller }) {
9993
10676
  // so it follows the flip TICK instead (which fires even when the mode was
9994
10677
  // already "canvas" — e.g. a persisted mode with the user on Main).
9995
10678
  const canvasShellView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
9996
- const [mobileCanvasOpen, setMobileCanvasOpen] = React.useState(false);
10679
+ // "canvas" is a fourth value of mobilePane, alongside workspace, activity
10680
+ // and inspector — the canvas is one more thing that can be showing in the
10681
+ // content region, not a screen layered over the app.
10682
+ const mobileCanvasOpen = mobilePane === "canvas";
10683
+ // Which pane the canvas was opened FROM. Toggling it off puts that back —
10684
+ // chat, activity or inspector — instead of dropping the user on Main.
10685
+ const paneBeforeCanvasRef = React.useRef("workspace");
9997
10686
  // Once opened, the canvas stays MOUNTED (hidden) for the rest of the
9998
10687
  // session selection — page state (typed input, scroll, game progress)
9999
10688
  // survives toggling. A session switch remounts fresh via the key below.
10000
10689
  const [mobileCanvasEverOpened, setMobileCanvasEverOpened] = React.useState(false);
10001
10690
  const [desktopCanvasEverOpened, setDesktopCanvasEverOpened] = React.useState(false);
10002
10691
  React.useEffect(() => {
10003
- setMobileCanvasOpen(false);
10692
+ setMobilePane((current) => (current === "canvas" ? (paneBeforeCanvasRef.current || "workspace") : current));
10004
10693
  setMobileCanvasEverOpened(false);
10005
10694
  setDesktopCanvasEverOpened(false);
10006
10695
  }, [canvasShellView.sessionId]);
10007
- // iOS scroll-chaining: while the overlay is up, a pan that Safari decides
10696
+ // iOS scroll-chaining: while the canvas is up, a pan that Safari decides
10008
10697
  // is not the iframe's must not scroll the app shell behind it.
10009
10698
  React.useEffect(() => {
10010
10699
  if (typeof document === "undefined") return undefined;
10011
10700
  document.body.classList.toggle("ps-canvas-lock", Boolean(mobile && mobileCanvasOpen));
10012
10701
  return () => document.body.classList.remove("ps-canvas-lock");
10013
10702
  }, [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]);
10703
+ const openMobileCanvas = React.useCallback(() => {
10704
+ if (mobilePane !== "canvas") paneBeforeCanvasRef.current = mobilePane;
10705
+ setMobilePane("canvas");
10706
+ setMobileCanvasEverOpened(true);
10707
+ // Entering by hand un-opts-out, same as the desktop toggle.
10708
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasShellView.sessionId });
10709
+ }, [controller, mobilePane, canvasShellView.sessionId]);
10020
10710
  const closeMobileCanvas = React.useCallback(() => {
10021
- setMobileCanvasOpen(false);
10711
+ setMobilePane(paneBeforeCanvasRef.current || "workspace");
10022
10712
  // Mirror the desktop ✕ exactly: a deliberate close is the opt-out
10023
10713
  // gesture — later draws badge the toggle instead of re-covering the
10024
- // screen, and reopening clears the opt-out.
10714
+ // transcript, and reopening clears the opt-out.
10025
10715
  controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: canvasShellView.sessionId, manual: true });
10026
10716
  }, [controller, canvasShellView.sessionId]);
10717
+ const canvasFlipSeqRef = React.useRef(canvasShellView.flipSeq);
10718
+ React.useEffect(() => {
10719
+ if (canvasShellView.flipSeq === canvasFlipSeqRef.current) return;
10720
+ canvasFlipSeqRef.current = canvasShellView.flipSeq;
10721
+ if (mobile) openMobileCanvas();
10722
+ }, [canvasShellView.flipSeq, mobile, openMobileCanvas]);
10027
10723
  const toggleMobileCanvas = React.useCallback(() => {
10028
10724
  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]);
10725
+ openMobileCanvas();
10726
+ }, [mobileCanvasOpen, closeMobileCanvas, openMobileCanvas]);
10034
10727
 
10035
10728
  // gridViewport used to be PUBLISHED here via controller.setViewport — a
10036
10729
  // second writer to ui.layout that derived columns from the hard-coded
@@ -10083,16 +10776,40 @@ export function PilotSwarmWebApp({ controller }) {
10083
10776
  if (remoteNormalized.rightPaneMode === "canvas" || remoteNormalized.rightPaneMode === "panes") {
10084
10777
  desktopRightPaneModeRef.current = remoteNormalized.rightPaneMode;
10085
10778
  }
10779
+ // Its own check: a profile written by this build carries
10780
+ // desktopPanes and need not carry the legacy enum at all.
10781
+ if (remoteNormalized.desktopPanes) {
10782
+ desktopPanesRef.current = remoteNormalized.desktopPanes;
10783
+ }
10086
10784
  defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
10087
- controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
10785
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
10088
10786
  );
10787
+ // First visit on a desktop wide enough to hold it: open the
10788
+ // canvas. A workspace that opens as chat alone hides the thing
10789
+ // most sessions are FOR, and the canvas needs real width to be
10790
+ // worth showing — below the breakpoint chat alone is better.
10791
+ //
10792
+ // Folded into the settings rather than dispatched separately.
10793
+ // A separate dispatch raced the re-baseline at the end of this
10794
+ // same poll: the new value was recorded as "already saved",
10795
+ // never written, and the next poll put it back. Going through
10796
+ // the settings means the normal apply-and-save path carries it.
10797
+ //
10798
+ // Only when NOTHING is stored. An explicit choice, including
10799
+ // closing the canvas, is a preference from then on. Not
10800
+ // live-responsive either: narrowing the window never closes a
10801
+ // canvas the user opened on purpose.
10802
+ const wantsDefaultCanvas = !hasStoredDesktopPanes(remoteNormalized)
10803
+ && !isNarrowViewport()
10804
+ && window.innerWidth >= PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX;
10089
10805
  const settings = materializeProfileSettings(
10090
10806
  profile?.profileSettings,
10091
10807
  defaultProfileSettingsRef.current,
10092
10808
  );
10809
+ if (wantsDefaultCanvas) settings.desktopPanes = { canvasOpen: true, diagnosticsOpen: false };
10093
10810
  const settingsJson = JSON.stringify(settings);
10094
10811
  const currentSettingsBeforeApply = profileSettingsFromViewState(
10095
- controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
10812
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
10096
10813
  );
10097
10814
  const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
10098
10815
  const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
@@ -10106,6 +10823,22 @@ export function PilotSwarmWebApp({ controller }) {
10106
10823
  appliedProfileSettingsJsonRef.current = settingsJson;
10107
10824
  }
10108
10825
 
10826
+ // First run on this account, on a desktop wide enough to hold
10827
+ // it: open the canvas. A workspace that opens as chat alone
10828
+ // hides the thing most sessions are FOR, and the canvas needs
10829
+ // real width to be worth showing — below the breakpoint chat
10830
+ // alone is the better default.
10831
+ //
10832
+ // Once only, and only when nothing is stored. An explicit
10833
+ // choice, including closing it, is a stored preference from
10834
+ // then on and is never second-guessed. Not live-responsive
10835
+ // either: narrowing the window does not close a canvas the
10836
+ // user opened on purpose.
10837
+ // remoteNormalized, NOT `settings`: the merged object always
10838
+ // carries desktopPanes because the defaults are synthesized
10839
+ // from current state, so it can never say "unset" and the
10840
+ // default below would never fire.
10841
+
10109
10842
  // lastProfileSettingsJson is what the SAVE effect diffs against
10110
10843
  // to decide whether anything needs writing. Refreshing it on
10111
10844
  // every poll made the poll adopt UNSAVED local state as if it
@@ -10119,7 +10852,7 @@ export function PilotSwarmWebApp({ controller }) {
10119
10852
  // remote state by definition and there is nothing to lose).
10120
10853
  if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
10121
10854
  const currentSettings = profileSettingsFromViewState(
10122
- controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
10855
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
10123
10856
  );
10124
10857
  lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
10125
10858
  }
@@ -10153,7 +10886,7 @@ export function PilotSwarmWebApp({ controller }) {
10153
10886
 
10154
10887
  React.useEffect(() => {
10155
10888
  if (!profileSettingsHydratedRef.current) return undefined;
10156
- const settings = profileSettingsFromViewState(state, otherTouchScaleRef.current, desktopRightPaneModeRef.current);
10889
+ const settings = profileSettingsFromViewState(state, otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current);
10157
10890
  const settingsJson = JSON.stringify(settings);
10158
10891
  if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
10159
10892
  lastProfileSettingsJsonRef.current = settingsJson;
@@ -10195,10 +10928,20 @@ export function PilotSwarmWebApp({ controller }) {
10195
10928
  // toolbar: the sessions-only layout's list reclaims focus, which
10196
10929
  // bounced the user out of Inspector the instant they tapped it.
10197
10930
  // Returning to Main is the Main button's job now.
10198
- if (mobile && (state.focusRegion === "inspector" || state.focusRegion === "activity")) {
10931
+ //
10932
+ // Gate on the RAW request, not the normalized region: on a phone a
10933
+ // workspace region (sessions/chat) normalizes to inspector, so the
10934
+ // Main button's own setFocus("chat") would otherwise land here as an
10935
+ // "inspector" focus and yank the user into diagnostics the instant
10936
+ // they asked for the workspace. Only a focus the caller genuinely
10937
+ // aimed at inspector/activity switches the pane.
10938
+ const requested = state.requestedFocusRegion;
10939
+ if (mobile
10940
+ && (state.focusRegion === "inspector" || state.focusRegion === "activity")
10941
+ && (requested === "inspector" || requested === "activity")) {
10199
10942
  setMobilePane(state.focusRegion);
10200
10943
  }
10201
- }, [mobile, state.focusRegion]);
10944
+ }, [mobile, state.focusRegion, state.requestedFocusRegion]);
10202
10945
 
10203
10946
  React.useEffect(() => {
10204
10947
  const visibleTabs = getVisibleInspectorTabs(controller);
@@ -10230,11 +10973,66 @@ export function PilotSwarmWebApp({ controller }) {
10230
10973
  // precedence while open, and its ✕ leaves rightPaneMode untouched — so
10231
10974
  // closing a preview returns to the canvas, restore-what-was-displaced.
10232
10975
  const canvasModeActive = !mobile
10233
- && state.rightPaneMode === "canvas"
10976
+ && state.canvasOpen
10234
10977
  && !filesFullscreenActive;
10978
+ // sessionId is a dep ON PURPOSE, not just canvasModeActive. The reset
10979
+ // effect above wipes the gate whenever the session settles or switches —
10980
+ // and on a cold load the settings poll applies canvasOpen BEFORE the
10981
+ // sessions list has loaded, so activeSessionId lands in a LATER commit.
10982
+ // Keyed on canvasModeActive alone this never re-fired (the flag had not
10983
+ // changed), the gate stayed false, and a canvas that was open by stored
10984
+ // preference or by the wide-screen default simply never rendered.
10985
+ // Effects run in declaration order, so within one commit the reset runs
10986
+ // first and this one wins: active canvas => mounted canvas, always.
10235
10987
  React.useEffect(() => {
10236
10988
  if (canvasModeActive) setDesktopCanvasEverOpened(true);
10237
- }, [canvasModeActive]);
10989
+ }, [canvasModeActive, canvasShellView.sessionId]);
10990
+
10991
+ // Diagnostics — Inspector + Activity as one column, beside the canvas
10992
+ // rather than instead of it. The artifact reader still takes the whole
10993
+ // right side while it is open, as it always did.
10994
+ const diagnosticsActive = !mobile
10995
+ && state.diagnosticsOpen
10996
+ && !filesFullscreenActive
10997
+ && !artifactPaneActive
10998
+ // Zen is chat + canvas only; diagnostics keeps its stored toggle and
10999
+ // returns when zen ends.
11000
+ && !(state.canvasZen && state.canvasOpen);
11001
+ // Whether the right side of the workspace exists at all. Both columns off
11002
+ // is the default: sessions and chat, nothing else. An absent column takes
11003
+ // no width and grows no resizer — off means gone, not collapsed.
11004
+ const rightSideActive = artifactPaneActive || canvasModeActive || diagnosticsActive;
11005
+
11006
+ // In the STORE, not local state: the header owns the way out (the rev
11007
+ // strip is promoted up there while full screen), and every other toolbar
11008
+ // button has to be able to drop it before doing its own job.
11009
+ const canvasMaximized = state.canvasMaximized && canvasModeActive;
11010
+ // Zen: chat rail + canvas workbench. Diagnostics steps aside but keeps
11011
+ // its stored toggle; the session list hides entirely.
11012
+ const zenActive = state.canvasZen && canvasModeActive && !artifactPaneActive;
11013
+ const zenRailPx = Math.max(260, Math.min(720,
11014
+ Number(state.canvasPrefs?.[state.activeSessionId]?.zenRailPx) || 380));
11015
+ // Escape steps down one rung: full screen -> zen -> normal workspace.
11016
+ React.useEffect(() => {
11017
+ if (!canvasMaximized && !zenActive) return undefined;
11018
+ const onKey = (e) => {
11019
+ if (e.key !== "Escape") return;
11020
+ controller.dispatch(canvasMaximized
11021
+ ? { type: "ui/canvasMaximized", on: false }
11022
+ : { type: "ui/canvasZen", on: false });
11023
+ };
11024
+ window.addEventListener("keydown", onKey);
11025
+ return () => window.removeEventListener("keydown", onKey);
11026
+ }, [controller, canvasMaximized, zenActive]);
11027
+
11028
+ // The right block is exactly as wide as the columns in it. Pixels, not a
11029
+ // share of the window: when a column closes, its pixels go to chat and
11030
+ // sessions — never to the surviving column. That is the whole model.
11031
+ const canvasColPx = Math.max(0, PORTAL_CANVAS_COL_DEFAULT_PX + (Number(state.canvasPaneAdjust) || 0));
11032
+ const diagColPx = Math.max(0, PORTAL_DIAG_COL_DEFAULT_PX + (Number(state.diagnosticsPaneAdjust) || 0));
11033
+ const rightSidePx = (canvasModeActive ? canvasColPx : 0)
11034
+ + (canvasModeActive && diagnosticsActive ? 1 : 0) // the hairline seam
11035
+ + (diagnosticsActive ? diagColPx : 0);
10238
11036
 
10239
11037
  // Closing hands the column back — or collapses it again, if the user had
10240
11038
  // already resized it away before opening the pane. Both cases live in the
@@ -10246,15 +11044,34 @@ export function PilotSwarmWebApp({ controller }) {
10246
11044
  const estimatedMainGridWidth = Math.max(0, (viewport.width || 0) * 0.68);
10247
11045
  const measuredMainGridWidth = mainGridViewport.width || estimatedMainGridWidth;
10248
11046
  const sessionColumnWidth = portalSessionColumnWidth(measuredMainGridWidth, state.portalSessionColumnAdjust);
10249
- const sessionColumnMode = portalSessionColumnMode(sessionColumnWidth);
11047
+ const sessionColumnMode = zenActive ? "hidden" : portalSessionColumnMode(sessionColumnWidth);
10250
11048
  const sessionColumnTrack = sessionColumnMode === "hidden"
10251
11049
  ? "0px"
10252
11050
  : `clamp(0px, calc(${PORTAL_SESSION_COLUMN_RATIO * 100}% + ${Number(state.portalSessionColumnAdjust) || 0}px), max(0px, calc(100% - 14rem - 32px)))`;
10253
11051
 
10254
11052
  const desktopWorkspace = React.createElement("div", {
10255
- className: "ps-workspace-grid",
11053
+ className: `ps-workspace-grid${rightSideActive ? "" : " is-right-hidden"}`,
10256
11054
  style: {
10257
- gridTemplateColumns: `minmax(0, ${layout.leftWidth}fr) var(--ps-resizer-track, 16px) minmax(0, ${layout.rightWidth}fr)`,
11055
+ // With both optional columns off there is no right side and no
11056
+ // resizer for one: chat takes the whole width. Leaving a 0fr track
11057
+ // and its 16px resizer behind would put a drag handle against the
11058
+ // window edge that reveals nothing.
11059
+ //
11060
+ // With the canvas up the right track gets a pixel FLOOR. The fr
11061
+ // ratio comes from computeLegacyLayout, which was tuned when the
11062
+ // right column only ever held the inspector/activity split — it
11063
+ // left the canvas around 200px, too narrow to read a page in.
11064
+ // Capped at 55% so the floor can never crush chat on a narrow
11065
+ // window; below that the user drags, or closes a column.
11066
+ gridTemplateColumns: artifactPaneActive
11067
+ ? `minmax(0, ${layout.leftWidth}fr) var(--ps-resizer-track, 16px) minmax(0, ${layout.rightWidth}fr)`
11068
+ : (zenActive
11069
+ // Zen: chat is a rail, the canvas is the workbench. The
11070
+ // rail width is remembered for THIS session only.
11071
+ ? `${zenRailPx}px var(--ps-resizer-track, 16px) minmax(0, 1fr)`
11072
+ : (rightSideActive
11073
+ ? `minmax(0, 1fr) var(--ps-resizer-track, 16px) min(${rightSidePx}px, 60%)`
11074
+ : "minmax(0, 1fr)")),
10258
11075
  },
10259
11076
  },
10260
11077
  React.createElement("div", {
@@ -10282,53 +11099,102 @@ export function PilotSwarmWebApp({ controller }) {
10282
11099
  style: { gridColumn: "3" },
10283
11100
  },
10284
11101
  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" },
11102
+ rightSideActive
11103
+ ? (artifactPaneActive
11104
+ ? React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust })
11105
+ : (zenActive
11106
+ ? React.createElement(ZenRailResizeHandle, { controller, sessionId: state.activeSessionId, railPx: zenRailPx })
11107
+ : React.createElement(ChatRightResizeHandle, {
11108
+ controller,
11109
+ target: canvasModeActive ? "canvas" : "diagnostics",
11110
+ adjust: canvasModeActive ? state.canvasPaneAdjust : state.diagnosticsPaneAdjust,
11111
+ })))
11112
+ : null,
11113
+ // The right side of the workspace: canvas and diagnostics, side by side
11114
+ // and independent. Either, both, or neither.
11115
+ //
11116
+ // A flex ROW rather than more grid tracks. The outer grid's third track is
11117
+ // still sized against chat by the handle above, so that drag keeps meaning
11118
+ // exactly what it always meant: chat versus everything to its right.
11119
+ //
11120
+ // DOM order is the visual order: canvas, then diagnostics. Diagnostics is
11121
+ // always rightmost, whether or not the canvas is up.
11122
+ //
11123
+ // The artifact reader still takes the WHOLE right side while it is open —
11124
+ // canvas, inspector and activity all step aside — which is why
11125
+ // diagnosticsActive already excludes it.
11126
+ React.createElement("div", { className: `ps-workspace-column-host is-split${canvasMaximized ? " has-maximized" : ""}` },
10290
11127
  artifactPaneActive
10291
11128
  ? React.createElement("div", { className: "ps-workspace-column is-artifact" },
10292
11129
  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.
11130
+ : null,
11131
+ // The canvas keeps its DOCUMENT alive across toggles: once opened for this
11132
+ // session it stays mounted in a hidden layer — unmounting destroys typed
11133
+ // input, scroll position, and any running page. Session switches remount
11134
+ // fresh via the key. It is FIRST in the DOM so it sits left of
11135
+ // diagnostics; while hidden it is display:none and takes no width.
10325
11136
  desktopCanvasEverOpened
10326
11137
  ? React.createElement("div", {
10327
11138
  key: `canvas:${state.activeSessionId || ""}`,
10328
- className: `ps-workspace-column is-artifact ps-canvas-layer${canvasModeActive && !artifactPaneActive ? "" : " is-hidden"}`,
11139
+ className: `ps-workspace-column is-artifact ps-canvas-layer${canvasModeActive && !artifactPaneActive ? "" : " is-hidden"}${canvasMaximized && canvasModeActive && !artifactPaneActive ? " is-maximized" : ""}`,
11140
+ // Sharing the right side: canvas takes a fixed slice and
11141
+ // diagnostics absorbs the rest, so the handle between them moves
11142
+ // one number. Alone, or full screen, it simply fills its box.
11143
+ style: canvasModeActive && !artifactPaneActive && diagnosticsActive && !canvasMaximized
11144
+ ? { flex: `0 0 ${canvasColPx}px`, minWidth: 0 }
11145
+ : undefined,
10329
11146
  ...(canvasModeActive && !artifactPaneActive ? {} : { inert: true, "aria-hidden": "true" }),
10330
11147
  },
10331
- React.createElement(CanvasPane, { controller, visible: canvasModeActive && !artifactPaneActive }))
11148
+ React.createElement(CanvasPane, {
11149
+ controller,
11150
+ visible: canvasModeActive && !artifactPaneActive,
11151
+ maximized: canvasMaximized,
11152
+ }))
11153
+ : null,
11154
+ // Only when BOTH are up. With one column the outer chat/right handle is
11155
+ // already the only split there is, and a second handle would resize
11156
+ // nothing.
11157
+ canvasModeActive && diagnosticsActive
11158
+ ? React.createElement(CanvasDiagnosticsResizeHandle, { controller, canvasPaneAdjust: state.canvasPaneAdjust, diagnosticsPaneAdjust: state.diagnosticsPaneAdjust })
11159
+ : null,
11160
+ // Diagnostics: yesterday's inspector-over-activity column, unchanged
11161
+ // inside, now behind its own toggle and rightmost.
11162
+ diagnosticsActive
11163
+ ? React.createElement("div", {
11164
+ className: "ps-workspace-column",
11165
+ style: {
11166
+ // Continuous PIXEL split, minmax-clamped so neither pane can
11167
+ // be dragged away or collapse behind your back. The old
11168
+ // `${rows}fr` template rode the TUI's row-quantized layout —
11169
+ // ~20px notches and threshold-triggered collapse flips.
11170
+ // minmax(0,...): either pane may be dragged fully shut and
11171
+ // pulled back open with the same seam. The handle clamps the
11172
+ // adjust to the column's real height at drag time, so the bar
11173
+ // tracks the pointer 1:1 instead of detaching at a floor.
11174
+ gridTemplateRows: `minmax(0px, calc(50% + ${Math.round(Number(state.diagnosticsSplitAdjust) || 0)}px)) var(--ps-resizer-track, 16px) minmax(0px, 1fr)`,
11175
+ ...(canvasModeActive ? { flex: "1 1 0%", minWidth: 0 } : null),
11176
+ },
11177
+ },
11178
+ React.createElement("div", {
11179
+ className: "ps-workspace-pane-slot",
11180
+ style: { gridRow: "1" },
11181
+ },
11182
+ React.createElement(InspectorPane, { controller, mobile: false })),
11183
+ React.createElement("div", {
11184
+ style: { gridRow: "2", minHeight: 0, display: "flex", flexDirection: "column" },
11185
+ },
11186
+ React.createElement(DiagnosticsSplitResizeHandle, { controller, splitAdjust: state.diagnosticsSplitAdjust })),
11187
+ React.createElement("div", {
11188
+ className: "ps-workspace-pane-slot",
11189
+ style: { gridRow: "3" },
11190
+ },
11191
+ // The artifact preview takes over the activity slot while an
11192
+ // artifact is selected, so it inherits the row resizer and can
11193
+ // be sized freely. It yields back to Activity the moment the
11194
+ // selection clears.
11195
+ artifactPreviewDetached
11196
+ ? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
11197
+ : React.createElement(ActivityPane, { controller })))
10332
11198
  : null));
10333
11199
  const fullscreenWorkspace = React.createElement("div", { className: "ps-workspace-full" },
10334
11200
  React.createElement(InspectorPane, { controller, mobile: false }));
@@ -10352,8 +11218,25 @@ export function PilotSwarmWebApp({ controller }) {
10352
11218
  React.createElement(InspectorPane, { controller, mobile: true }));
10353
11219
  else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
10354
11220
  React.createElement(ActivityPane, { controller }));
11221
+ // The canvas fills the whole region, so there is nothing to render under
11222
+ // it — the layer below is a SIBLING of this content and covers it. Drawing
11223
+ // a pane here would only be invisible work.
11224
+ else if (mobilePane === "canvas") mobileContent = null;
10355
11225
  else mobileContent = React.createElement(MobileWorkspace, { controller, layout: mobileMainLayout });
10356
11226
 
11227
+ // The phone's canvas layer: a sibling of the content region's pane, NOT a
11228
+ // child of any pane. That is deliberate — panes mount and unmount as the
11229
+ // toolbar switches between Main, Activity and Inspector, and the canvas
11230
+ // frame has to survive all of it. Only a session change (which rekeys it)
11231
+ // or a reload may take a running page down.
11232
+ const mobileCanvasLayer = mobile && mobileCanvasEverOpened
11233
+ ? React.createElement(MobileCanvasLayer, {
11234
+ key: `canvas:${canvasShellView.sessionId || ""}`,
11235
+ controller,
11236
+ visible: mobileCanvasOpen,
11237
+ })
11238
+ : null;
11239
+
10357
11240
  return React.createElement(ControllerContext.Provider, { value: controller },
10358
11241
  React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
10359
11242
  // The phone's toolbar carries its NAVIGATION (the three view modes,
@@ -10362,13 +11245,14 @@ export function PilotSwarmWebApp({ controller }) {
10362
11245
  React.createElement(Toolbar, {
10363
11246
  controller,
10364
11247
  mobile,
10365
- canvasOverlayOpen: mobileCanvasOpen,
10366
- onToggleCanvasOverlay: toggleMobileCanvas,
11248
+ canvasPaneOpen: mobileCanvasOpen,
11249
+ onToggleCanvasPane: toggleMobileCanvas,
10367
11250
  mobilePane,
10368
11251
  mobileMainLayout,
10369
11252
  onSelectMobilePane: (paneId, focus) => {
10370
11253
  // Tapping Main while already on Main cycles its layout; from
10371
- // another pane it just returns you to Main, unchanged.
11254
+ // another pane (the canvas included) it just returns you to
11255
+ // Main, unchanged.
10372
11256
  if (paneId === "workspace" && mobilePane === "workspace") {
10373
11257
  setMobileMainLayout((current) => (
10374
11258
  current === "split" ? "chat" : current === "chat" ? "sessions" : "split"
@@ -10383,17 +11267,10 @@ export function PilotSwarmWebApp({ controller }) {
10383
11267
  ? React.createElement(AdminConsolePanel, { controller, mobile })
10384
11268
  : (filesFullscreenActive
10385
11269
  ? fullscreenWorkspace
10386
- : (mobile ? mobileContent : desktopWorkspace))),
11270
+ : (mobile ? mobileContent : desktopWorkspace)),
11271
+ mobileCanvasLayer),
10387
11272
  mobile && filesFullscreenActive
10388
11273
  ? React.createElement(MobileArtifactOverlay, { controller })
10389
11274
  : 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
11275
  React.createElement(ModalLayer, { controller })));
10399
11276
  }