pilotswarm 0.5.36 → 0.5.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/package.json +3 -3
  2. package/tui/src/app.js +0 -4
  3. package/tui/src/index.js +0 -9
  4. package/ui/core/src/canvas-actions.js +148 -0
  5. package/ui/core/src/commands.js +0 -1
  6. package/ui/core/src/controller.js +296 -21
  7. package/ui/core/src/history.js +134 -1
  8. package/ui/core/src/index.js +1 -0
  9. package/ui/core/src/reducer.js +407 -19
  10. package/ui/core/src/selectors.js +223 -154
  11. package/ui/core/src/state.js +139 -2
  12. package/ui/core/src/themes/index.js +1 -1
  13. package/ui/react/src/components.js +1 -7
  14. package/ui/react/src/web-app.js +1719 -366
  15. package/web/auth/config.js +3 -0
  16. package/web/auth/index.js +8 -1
  17. package/web/auth/normalize/proxy.js +48 -0
  18. package/web/auth/providers/proxy.js +141 -0
  19. package/web/dist/assets/{abnfDiagram-VRR7QNED-LOLCtsns.js → abnfDiagram-VRR7QNED-DrJhXhSQ.js} +1 -1
  20. package/web/dist/assets/{arc-BE-jVDEi.js → arc-BEaG9DWd.js} +1 -1
  21. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CdmUjwtj.js → architectureDiagram-ZJ3FMSHR-D9fc1Imx.js} +1 -1
  22. package/web/dist/assets/{blockDiagram-677ZJIJ3-NZ4TOa8L.js → blockDiagram-677ZJIJ3-rS5Eikvt.js} +1 -1
  23. package/web/dist/assets/{c4Diagram-LMCZKHZV-9ubX8uGA.js → c4Diagram-LMCZKHZV-HKLlX5cR.js} +1 -1
  24. package/web/dist/assets/channel-DRy7FvyF.js +1 -0
  25. package/web/dist/assets/{chunk-2Q5K7J3B-DNDnjbP6.js → chunk-2Q5K7J3B-kq0TyLSB.js} +1 -1
  26. package/web/dist/assets/{chunk-32BRIVSS-iilfiffr.js → chunk-32BRIVSS-O-LMarT5.js} +1 -1
  27. package/web/dist/assets/{chunk-5VM5RSS4-BakOMFmf.js → chunk-5VM5RSS4-Di4h0CAu.js} +1 -1
  28. package/web/dist/assets/{chunk-EX3LRPZG-BhluhGyZ.js → chunk-EX3LRPZG-C8rgqUOt.js} +1 -1
  29. package/web/dist/assets/{chunk-JWPE2WC7-B6GKLlNb.js → chunk-JWPE2WC7-BSH41q4T.js} +1 -1
  30. package/web/dist/assets/{chunk-MOJQB5TN-CXmt8msF.js → chunk-MOJQB5TN-BAFuQHAC.js} +1 -1
  31. package/web/dist/assets/{chunk-RYQCIY6F-Bu58-SDr.js → chunk-RYQCIY6F-DAg6JOs7.js} +1 -1
  32. package/web/dist/assets/{chunk-V7JOEXUC-AQdyoYHL.js → chunk-V7JOEXUC-DbVrT8BG.js} +1 -1
  33. package/web/dist/assets/{chunk-VR4S4FIN-DeM5iMsE.js → chunk-VR4S4FIN-C6f5_Grp.js} +1 -1
  34. package/web/dist/assets/{chunk-XXDRQBXY-DjGjajy1.js → chunk-XXDRQBXY-CqcCYpqA.js} +1 -1
  35. package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +1 -0
  36. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +1 -0
  37. package/web/dist/assets/{cose-bilkent-JH36ORCC-BGi4_egJ.js → cose-bilkent-JH36ORCC-DJw04eFh.js} +1 -1
  38. package/web/dist/assets/{cynefin-VYW2F7L2-CcgV1ve4.js → cynefin-VYW2F7L2-CpjwdEIU.js} +1 -1
  39. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BRDEsbBG.js → cynefinDiagram-TSTJHNR4-RqEIWoT0.js} +1 -1
  40. package/web/dist/assets/{dagre-VKFMJZFB-OjuC8dJB.js → dagre-VKFMJZFB-C1eGQNK2.js} +1 -1
  41. package/web/dist/assets/{diagram-FQU43EPY-CGdROWYh.js → diagram-FQU43EPY-BODLQ7dz.js} +1 -1
  42. package/web/dist/assets/{diagram-G47NLZAW-B5J0EUfS.js → diagram-G47NLZAW-DlhmukMC.js} +1 -1
  43. package/web/dist/assets/{diagram-NH7WQ7WH-zmb4-cP2.js → diagram-NH7WQ7WH-Ba_jZoCN.js} +1 -1
  44. package/web/dist/assets/{diagram-OA4YK3LP-j514bgaH.js → diagram-OA4YK3LP-DnSiAJBG.js} +1 -1
  45. package/web/dist/assets/{diagram-WEI45ONY-h5G459mZ.js → diagram-WEI45ONY-B3KoQ7_W.js} +1 -1
  46. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-GnMgbuFk.js → ebnfDiagram-CCIWWBDH-ChnFc5gf.js} +1 -1
  47. package/web/dist/assets/{erDiagram-Q63AITRT-BFgH3UQp.js → erDiagram-Q63AITRT-D2KkJXLZ.js} +1 -1
  48. package/web/dist/assets/{flowDiagram-23GEKE2U-BsbQ_VXV.js → flowDiagram-23GEKE2U-CmybZufj.js} +1 -1
  49. package/web/dist/assets/{ganttDiagram-NO4QXBWP-C_fjo2sf.js → ganttDiagram-NO4QXBWP-BzPurW_v.js} +1 -1
  50. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BUoXrAI9.js → gitGraphDiagram-IHSO6WYX-DAbXl4gO.js} +1 -1
  51. package/web/dist/assets/{index-ONJimzcx.js → index-CRY94nFp.js} +6 -6
  52. package/web/dist/assets/index-DtL0lbis.css +1 -0
  53. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CJiSANyk.js → infoDiagram-FWYZ7A6U-C6qGjNA1.js} +1 -1
  54. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-BKoQ-a2c.js → ishikawaDiagram-FXEZZL3T-D2d1tO-9.js} +1 -1
  55. package/web/dist/assets/{journeyDiagram-5HDEW3XC-Db6Crcd3.js → journeyDiagram-5HDEW3XC-fP13w1b7.js} +1 -1
  56. package/web/dist/assets/{kanban-definition-HUTT4EX6-B9GaB0UJ.js → kanban-definition-HUTT4EX6-BJfWAXZ0.js} +1 -1
  57. package/web/dist/assets/{linear-BXwRb8MN.js → linear-CltApkHf.js} +1 -1
  58. package/web/dist/assets/{mermaid.core-DwzXN6up.js → mermaid.core-B1Dwetk3.js} +4 -4
  59. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DKC_C_OY.js → mindmap-definition-LN4V7U3C-CdRUD7LF.js} +1 -1
  60. package/web/dist/assets/{papaparse.min-Cm1Pr54l.js → papaparse.min-C1y3wBH1.js} +1 -1
  61. package/web/dist/assets/{pegDiagram-2B236MQR-IeawbZPt.js → pegDiagram-2B236MQR-CeQkqN6Q.js} +1 -1
  62. package/web/dist/assets/{pieDiagram-ENE6RG2P-BOdsYBt8.js → pieDiagram-ENE6RG2P-Dqgc87jA.js} +1 -1
  63. package/web/dist/assets/pilotswarm-DayYdi39.js +96 -0
  64. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cr5yoN7C.js → quadrantDiagram-ABIIQ3AL-CD7f-w6m.js} +1 -1
  65. package/web/dist/assets/{railroadDiagram-RFXS5EU6-E7wuFBuk.js → railroadDiagram-RFXS5EU6-CcsAijSC.js} +1 -1
  66. package/web/dist/assets/{requirementDiagram-TGXJPOKE-sZCKiuDs.js → requirementDiagram-TGXJPOKE-BkzF80pL.js} +1 -1
  67. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_EWziUR.js → sankeyDiagram-HTMAVEWB-D9XOFw7Y.js} +1 -1
  68. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D2mN7onX.js → sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js} +1 -1
  69. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CEL9Ly2T.js → sizeCapture-X5ZJPWSS-CDJV16tP.js} +1 -1
  70. package/web/dist/assets/{stateDiagram-2N3HPSRC-C7ejJoeN.js → stateDiagram-2N3HPSRC-C0E-MPXO.js} +1 -1
  71. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +1 -0
  72. package/web/dist/assets/{swimlanes-5IMT3BWC-Ck3pbFxk.js → swimlanes-5IMT3BWC-n5Vsv7n9.js} +2 -2
  73. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +8 -0
  74. package/web/dist/assets/{timeline-definition-FHXFAJF6-K3H8Yuyr.js → timeline-definition-FHXFAJF6-DW2gV8-n.js} +1 -1
  75. package/web/dist/assets/{vennDiagram-L72KCM5P-C3NKjSv9.js → vennDiagram-L72KCM5P-D9dbDm08.js} +1 -1
  76. package/web/dist/assets/{wardleyDiagram-EHGQE667-CeeVuFiO.js → wardleyDiagram-EHGQE667-CIcG7fiC.js} +1 -1
  77. package/web/dist/assets/{xychartDiagram-FW5EYKEG-CF-vCvEq.js → xychartDiagram-FW5EYKEG-elEIFsc9.js} +1 -1
  78. package/web/dist/index.html +4 -4
  79. package/web/runtime.js +20 -1
  80. package/web/dist/assets/channel-BhHZnKdH.js +0 -1
  81. package/web/dist/assets/classDiagram-OUVF2IWQ-CM_bKBKz.js +0 -1
  82. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CM_bKBKz.js +0 -1
  83. package/web/dist/assets/index-CnH1Yv_d.css +0 -1
  84. package/web/dist/assets/pilotswarm-kCQFVjJm.js +0 -99
  85. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-UaVDt_gU.js +0 -1
  86. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CqO_fF5t.js +0 -8
@@ -20,6 +20,7 @@ import {
20
20
  getPromptInputRows,
21
21
  getTheme,
22
22
  isThemeLight,
23
+ normalizeStoredCanvasPrefs,
23
24
  parseTerminalMarkupRuns,
24
25
  PilotSwarmUiController,
25
26
  tokenizeInlineMarkdown,
@@ -32,6 +33,7 @@ import {
32
33
  selectChatLines,
33
34
  selectChatPaneChrome,
34
35
  selectOutboxOverlayLines,
36
+ selectCanvasView,
35
37
  selectFileBrowserItems,
36
38
  selectFilesFilterModal,
37
39
  selectFilesScope,
@@ -247,21 +249,28 @@ function normalizeProfileSettings(settings) {
247
249
  const id = candidate.activeSessionId == null ? null : String(candidate.activeSessionId).trim();
248
250
  normalized.activeSessionId = id || null;
249
251
  }
250
- if (isChatViewMode(candidate.chatViewMode)) {
251
- normalized.chatViewMode = candidate.chatViewMode;
252
+ if (candidate.rightPaneMode === "canvas" || candidate.rightPaneMode === "panes") {
253
+ normalized.rightPaneMode = candidate.rightPaneMode;
254
+ }
255
+ // The two independent desktop columns. This normalizer WHITELISTS keys, so
256
+ // an unlisted one is dropped on the way to the server without a word —
257
+ // which is exactly how a toggle looks like it never persisted.
258
+ if (hasOwn(candidate, "desktopPanes") && candidate.desktopPanes && typeof candidate.desktopPanes === "object") {
259
+ normalized.desktopPanes = {
260
+ canvasOpen: candidate.desktopPanes.canvasOpen === true,
261
+ diagnosticsOpen: candidate.desktopPanes.diagnosticsOpen === true,
262
+ zen: candidate.desktopPanes.zen === true,
263
+ };
264
+ }
265
+ if (hasOwn(candidate, "canvasPrefs")) {
266
+ normalized.canvasPrefs = normalizeStoredCanvasPrefs(candidate.canvasPrefs);
252
267
  }
253
268
  // Phones and desktops keep SEPARATE view preferences: rich prose reads well
254
269
  // on a wide transcript and poorly in a 390px column, so a single shared
255
270
  // value would have each device overwriting the other's choice.
256
- if (isChatViewMode(candidate.chatViewModeMobile)) {
257
- normalized.chatViewModeMobile = candidate.chatViewModeMobile;
258
- }
259
271
  return normalized;
260
272
  }
261
273
 
262
- function isChatViewMode(value) {
263
- return value === "summary" || value === "transcript" || value === "rich";
264
- }
265
274
 
266
275
  /** True on phone-sized viewports — the device class that owns the mobile slot. */
267
276
  function isNarrowViewport() {
@@ -271,17 +280,11 @@ function isNarrowViewport() {
271
280
  }
272
281
 
273
282
  /** Profile key this device reads and writes. */
274
- function chatViewModeKey() {
275
- return isNarrowViewport() ? "chatViewModeMobile" : "chatViewMode";
276
- }
277
283
 
278
284
  /** The key belonging to the OTHER device class. */
279
- function otherChatViewModeKey() {
280
- return isNarrowViewport() ? "chatViewMode" : "chatViewModeMobile";
281
- }
282
285
 
283
286
  /**
284
- * The "Mobile" (touch-scale) preference is per DEVICE CLASS, like chatViewMode:
287
+ * The "Mobile" (touch-scale) preference is per DEVICE CLASS:
285
288
  * it describes how big the type and hit targets should be on the screen you are
286
289
  * actually looking at, so turning it on from a phone must not scale up the
287
290
  * desktop. It is otherwise orthogonal to the theme — it lives in the theme
@@ -326,7 +329,7 @@ function hasOwn(value, key) {
326
329
  return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
327
330
  }
328
331
 
329
- function profileSettingsFromViewState(state, preservedOtherChatViewMode = null, preservedOtherTouchScale = null) {
332
+ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null, preservedDesktopPanes = null) {
330
333
  return normalizeProfileSettings({
331
334
  themeId: state.themeId,
332
335
  [touchScaleKey()]: state.touchScale,
@@ -336,25 +339,47 @@ function profileSettingsFromViewState(state, preservedOtherChatViewMode = null,
336
339
  sessionPaneAdjust: state.sessionPaneAdjust,
337
340
  portalSessionColumnAdjust: state.portalSessionColumnAdjust,
338
341
  activityPaneAdjust: state.activityPaneAdjust,
342
+ canvasPaneAdjust: state.canvasPaneAdjust,
343
+ diagnosticsPaneAdjust: state.diagnosticsPaneAdjust,
344
+ diagnosticsSplitAdjust: state.diagnosticsSplitAdjust,
339
345
  },
340
346
  pinnedSessionIds: state.pinnedIds,
341
347
  sessionOrder: state.manualOrder,
342
348
  collapsedSessionIds: state.collapsedSessionIds,
343
349
  activeSessionId: state.activeSessionId,
344
- [chatViewModeKey()]: state.chatViewMode,
350
+ // rightPaneMode describes the DESKTOP right column; a phone has no
351
+ // such column (its canvas surface is a nav tab), so a phone must not
352
+ // write its own value — it preserves the last-known stored one, or a
353
+ // desktop that flipped to canvas would be flipped back by the phone's
354
+ // next save.
355
+ //
356
+ // desktopPanes replaces it and is preserved the same way, for the same
357
+ // reason. rightPaneMode is still written so a rolled-back build reads
358
+ // something sane.
359
+ ...(isNarrowViewport()
360
+ ? {
361
+ ...(preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
362
+ ? { rightPaneMode: preservedRightPaneMode }
363
+ : {}),
364
+ ...(preservedDesktopPanes && typeof preservedDesktopPanes === "object"
365
+ ? { desktopPanes: preservedDesktopPanes }
366
+ : {}),
367
+ }
368
+ : {
369
+ rightPaneMode: state.canvasOpen ? "canvas" : "panes",
370
+ desktopPanes: { canvasOpen: state.canvasOpen === true, diagnosticsOpen: state.diagnosticsOpen === true, zen: state.canvasZen === true },
371
+ }),
372
+ canvasPrefs: state.canvasPrefs,
345
373
  // setCurrentUserProfileSettings REPLACES the settings object, so the
346
374
  // other device class's slot has to be written back verbatim or saving
347
375
  // from a desktop would wipe the phone's preference (and vice versa).
348
- ...(isChatViewMode(preservedOtherChatViewMode)
349
- ? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
350
- : {}),
351
376
  ...(typeof preservedOtherTouchScale === "boolean"
352
377
  ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
353
378
  : {}),
354
379
  });
355
380
  }
356
381
 
357
- function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null, preservedOtherTouchScale = null) {
382
+ function buildDefaultProfileSettingsFromState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null) {
358
383
  return normalizeProfileSettings({
359
384
  themeId: state?.ui?.themeId,
360
385
  [touchScaleKey()]: state?.ui?.touchScale,
@@ -370,10 +395,19 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode
370
395
  sessionOrder: state?.sessions?.manualOrder,
371
396
  collapsedSessionIds: state?.sessions?.collapsedIds,
372
397
  activeSessionId: state?.sessions?.activeSessionId,
373
- [chatViewModeKey()]: state?.ui?.chatViewMode,
374
- ...(isChatViewMode(preservedOtherChatViewMode)
375
- ? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
376
- : {}),
398
+ ...(isNarrowViewport()
399
+ ? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
400
+ ? { rightPaneMode: preservedRightPaneMode }
401
+ : {})
402
+ : {
403
+ rightPaneMode: state?.ui?.rightPaneMode,
404
+ desktopPanes: {
405
+ canvasOpen: state?.ui?.canvasOpen === true,
406
+ diagnosticsOpen: state?.ui?.diagnosticsOpen === true,
407
+ zen: state?.ui?.canvasZen === true,
408
+ },
409
+ }),
410
+ canvasPrefs: state?.canvas?.prefs,
377
411
  ...(typeof preservedOtherTouchScale === "boolean"
378
412
  ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
379
413
  : {}),
@@ -383,13 +417,11 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode
383
417
  function materializeProfileSettings(remoteSettings, defaults) {
384
418
  const normalizedRemote = normalizeProfileSettings(remoteSettings);
385
419
  const normalizedDefaults = normalizeProfileSettings(defaults);
386
- // For chatViewMode specifically, do NOT fall back to defaults when the
387
- // remote profile lacks the field. The poll runs every few seconds; if
388
- // we synthesized a default here, every poll where the server hasn't
389
- // yet persisted the user's toggle would clobber the local choice and
390
- // snap the chat pane back from Summary to Chat. Leaving chatViewMode
391
- // off the materialized settings causes the `profileSettings/apply`
392
- // reducer's `hasChatViewMode` guard to preserve the current value.
420
+ // No-defaults rule for per-device and toggle-like fields: when the remote
421
+ // profile lacks a key, do NOT synthesize a default — the poll runs every
422
+ // few seconds, and a synthesized value would clobber a fresh local change
423
+ // the server has not persisted yet. An omitted key leaves the
424
+ // profileSettings/apply guards preserving the current value.
393
425
  return normalizeProfileSettings({
394
426
  themeId: hasOwn(normalizedRemote, "themeId")
395
427
  ? normalizedRemote.themeId
@@ -419,13 +451,23 @@ function materializeProfileSettings(remoteSettings, defaults) {
419
451
  // Apply only the slot this device owns. The other is preserved by the
420
452
  // save path (see profileSettingsFromViewState), not applied here — it
421
453
  // describes a screen size we are not on.
422
- ...(hasOwn(normalizedRemote, chatViewModeKey())
423
- ? { chatViewMode: normalizedRemote[chatViewModeKey()] }
454
+ // Canvas keys follow the same no-defaults rule: the poll must not
455
+ // clobber a local flip/badge advance the server has not persisted yet.
456
+ // An omitted key leaves the profileSettings/apply guards preserving
457
+ // the current value.
458
+ ...(hasOwn(normalizedRemote, "desktopPanes")
459
+ ? { desktopPanes: normalizedRemote.desktopPanes }
460
+ : {}),
461
+ ...(hasOwn(normalizedRemote, "rightPaneMode")
462
+ ? { rightPaneMode: normalizedRemote.rightPaneMode }
463
+ : {}),
464
+ ...(hasOwn(normalizedRemote, "canvasPrefs")
465
+ ? { canvasPrefs: normalizedRemote.canvasPrefs }
424
466
  : {}),
425
467
  // touchScale was missing from this merge entirely, which is why the
426
468
  // "Mobile" checkbox never survived a reload: the save path wrote it,
427
469
  // but nothing ever read it back, and the reducer's hasTouchScale guard
428
- // then preserved the default. Same no-defaults rule as chatViewMode —
470
+ // then preserved the default. Same no-defaults rule —
429
471
  // synthesizing one here would let a poll clobber a fresh local toggle.
430
472
  ...(hasOwn(normalizedRemote, touchScaleKey())
431
473
  ? { touchScale: normalizedRemote[touchScaleKey()] }
@@ -468,6 +510,65 @@ function clampNumber(value, min, max) {
468
510
  return Math.max(min, Math.min(max, value));
469
511
  }
470
512
 
513
+ /**
514
+ * Below this, releasing a drag CLOSES the column rather than leaving a sliver.
515
+ *
516
+ * "Resized to 0 toggles it off" is the rule, but a pointer rarely lands on
517
+ * exactly 0 — without a threshold you get a 3px column you cannot read, cannot
518
+ * grab, and did not ask for. 120px is about the narrowest either column is
519
+ * still useful at.
520
+ */
521
+ const PORTAL_COLUMN_SNAP_CLOSED_PX = 120;
522
+
523
+ /** Width of the draggable track between the canvas and diagnostics columns. */
524
+ const PORTAL_COLUMN_RESIZER_PX = 16;
525
+
526
+ /**
527
+ * The narrowest the canvas should OPEN at. Not a clamp — drag it smaller if
528
+ * you like, or drag it away entirely.
529
+ *
530
+ * A canvas is a rendered page being looked at, so it needs enough width for a
531
+ * normal document column. The inherited fr split left it near 200px.
532
+ */
533
+ const PORTAL_CANVAS_MIN_PX = 480;
534
+
535
+ /**
536
+ * Wide enough to OPEN the canvas by default on a first visit.
537
+ *
538
+ * Sessions and chat need roughly 224px + 500px to stay comfortable, and the
539
+ * canvas wants PORTAL_CANVAS_MIN_PX beside them. 1440 clears that with room to
540
+ * spare and is a real device width (a 14" MacBook, a 900p desktop), so the
541
+ * common laptop gets a canvas and a small window does not get three cramped
542
+ * columns.
543
+ */
544
+ const PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX = 1440;
545
+
546
+ /**
547
+ * Whether this profile has ever expressed a preference about the two columns.
548
+ *
549
+ * The width-based default must fire only in its absence: someone who CLOSED
550
+ * the canvas has stored `{canvasOpen: false}`, which is a choice and reads
551
+ * nothing like "unset". A legacy rightPaneMode counts too — it is the same
552
+ * preference in the older shape.
553
+ */
554
+ function hasStoredDesktopPanes(settings) {
555
+ if (!settings || typeof settings !== "object") return false;
556
+ if (settings.desktopPanes && typeof settings.desktopPanes === "object") return true;
557
+ return settings.rightPaneMode === "canvas" || settings.rightPaneMode === "panes";
558
+ }
559
+
560
+ /**
561
+ * The widths the two optional columns OPEN at, in pixels. 2:1 — the canvas is
562
+ * the thing being looked at; diagnostics is a readout beside it.
563
+ *
564
+ * Pixels, not shares of the window, because of the rule the fr model broke:
565
+ * when a column closes, the freed space goes to chat + sessions — never to
566
+ * the surviving column. A pane owns its width; chat flexes.
567
+ */
568
+ const PORTAL_CANVAS_COL_DEFAULT_PX = 640;
569
+ const PORTAL_DIAG_COL_DEFAULT_PX = 320;
570
+
571
+
471
572
  function portalSessionColumnBounds(width) {
472
573
  const safeWidth = Math.max(0, Number(width) || 0);
473
574
  const availableWidth = Math.max(
@@ -513,7 +614,7 @@ function computeStateLayout(state) {
513
614
  }, state.ui.layout?.paneAdjust ?? 0, getStatePromptRows(state), state.ui.layout?.sessionPaneAdjust ?? 0);
514
615
  }
515
616
 
516
- function getPortalInspectorContentWidth(paneWidth, inspectorTab) {
617
+ function getPortalInspectorContentWidth(paneWidth, inspectorTab, mobile = false) {
517
618
  // The sequence view once reserved 3 extra columns here so that "content
518
619
  // that fits visually does not trip a cosmetic x-scrollbar". That was a
519
620
  // fudge for a real bug: every sequence line was padded to the full width,
@@ -522,7 +623,14 @@ function getPortalInspectorContentWidth(paneWidth, inspectorTab) {
522
623
  // and any rounding tipped it into a permanent scrollbar. The padding is now
523
624
  // trimmed at the source (see trimTrailingRunPad in ui-core selectors), so
524
625
  // the guard would only throw away 3 usable columns.
525
- return Math.max(20, paneWidth - 4);
626
+ //
627
+ // A PHONE still needs a margin, for a different reason: the column count
628
+ // is derived from an assumed character cell, and iOS Safari's monospace
629
+ // fallback renders wider than the desktop's. A grid sized to the exact
630
+ // pane there overflows by a chunk, and the reader meets a view scrolled
631
+ // sideways with its timestamps and STATS header sliced off. Two columns of
632
+ // slack absorb the metric difference.
633
+ return Math.max(20, paneWidth - (mobile ? 6 : 4));
526
634
  }
527
635
 
528
636
  function normalizeLines(lines) {
@@ -2819,6 +2927,10 @@ function HtmlArtifactPreviewPanel({
2819
2927
  title: filename || "HTML artifact",
2820
2928
  // No allow-same-origin — see the block comment above.
2821
2929
  sandbox: "allow-scripts allow-popups allow-popups-to-escape-sandbox",
2930
+ // Opaque origin ≠ "self", so the autoplay policy would
2931
+ // silently mute an interactive artifact (same fix the
2932
+ // canvas frame carries).
2933
+ allow: "autoplay *",
2822
2934
  referrerPolicy: "no-referrer",
2823
2935
  onLoad: () => frames.promote(nonce),
2824
2936
  }))
@@ -3197,7 +3309,14 @@ function parseStructuredChatBlocks(lines = []) {
3197
3309
  }
3198
3310
 
3199
3311
  function StructuredChatBlocks({ lines, theme, controller = null }) {
3200
- const blocks = React.useMemo(() => parseStructuredChatBlocks(lines), [lines]);
3312
+ // canvasUpdate-flagged lines are the TUI's affordance (an artifact link
3313
+ // for a host that cannot render the canvas). In the browser the canvas
3314
+ // pane updating IS the signal, so the chat skips them outright — desktop
3315
+ // and mobile share this renderer, so one filter covers both.
3316
+ const blocks = React.useMemo(
3317
+ () => parseStructuredChatBlocks((lines || []).filter((line) => !line?.canvasUpdate && !line?.canvasAction)),
3318
+ [lines],
3319
+ );
3201
3320
  return React.createElement(StructuredBlockList, { blocks, theme, controller });
3202
3321
  }
3203
3322
 
@@ -3626,6 +3745,28 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
3626
3745
  const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
3627
3746
  const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
3628
3747
 
3748
+ // A stale sideways offset is how a fitted grid greets the reader with its
3749
+ // timestamps and header sliced off: pan once, switch tabs or sessions, and
3750
+ // the new content inherits the old scrollLeft. Reset it whenever the pane's
3751
+ // identity changes — the user's own pan within a view still stands.
3752
+ const horizontalResetKey = `${paneKey}:${title}`;
3753
+ React.useEffect(() => {
3754
+ // Two passes, deliberately: the first runs before the new view's lines
3755
+ // have laid out, and the sticky-header scroll sync can copy a stale
3756
+ // offset back onto the body afterwards. The rAF pass lands after both
3757
+ // and is what actually sticks (observed on Stats, whose header row
3758
+ // syncs horizontally with the body).
3759
+ const reset = () => {
3760
+ if (ref.current) ref.current.scrollLeft = 0;
3761
+ if (stickyRef.current) stickyRef.current.scrollLeft = 0;
3762
+ };
3763
+ reset();
3764
+ const frame = typeof requestAnimationFrame === "function"
3765
+ ? requestAnimationFrame(() => { reset(); })
3766
+ : null;
3767
+ return () => { if (frame != null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame); };
3768
+ }, [horizontalResetKey]);
3769
+
3629
3770
  // Clicking (or touching) a scrollable pane makes the keyboard scroll keys
3630
3771
  // act on THAT pane. Fires on mousedown rather than click so a drag-select
3631
3772
  // inside the pane claims focus too.
@@ -4009,6 +4150,7 @@ function SessionRowContent({ row, theme, structured = false, showInlineDetail =
4009
4150
  return React.createElement(React.Fragment, null,
4010
4151
  plain.statusColor ? React.createElement(StatusDot, { color: plain.statusColor, node: row.depth > 0 }) : null,
4011
4152
  React.createElement(OwnerAvatar, { badge: row.ownerBadge }),
4153
+ React.createElement(SessionCanvasMark, { mark: row.canvasMark }),
4012
4154
  React.createElement("span", { className: "ps-session-row-title__text" },
4013
4155
  React.createElement(Runs, { runs: plain.rest, theme })));
4014
4156
  }
@@ -4031,6 +4173,7 @@ function SessionRowContent({ row, theme, structured = false, showInlineDetail =
4031
4173
  React.createElement("div", { className: "ps-session-row-title" },
4032
4174
  title.statusColor ? React.createElement(StatusDot, { color: title.statusColor, node: row.depth > 0 }) : null,
4033
4175
  React.createElement(OwnerAvatar, { badge: row.ownerBadge }),
4176
+ React.createElement(SessionCanvasMark, { mark: row.canvasMark }),
4034
4177
  // Runs wrapped in ONE element: the title is a flex row so the
4035
4178
  // avatar centres against the text, and the runs keep normal
4036
4179
  // inline flow inside the wrapper. Flexing the runs directly
@@ -4151,9 +4294,11 @@ function useHorizontalPanOptIn(ref) {
4151
4294
  }, [ref]);
4152
4295
  }
4153
4296
 
4154
- function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false }) {
4155
- // Mobile keeps its inline detail line and gets no detail box — a reserved
4156
- // footer would eat a meaningful slice of a phone screen.
4297
+ function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false, showDetailBox = null }) {
4298
+ // Mobile keeps its inline detail line and normally gets no detail box — a
4299
+ // reserved footer would eat a meaningful slice of a phone screen. The
4300
+ // sessions-ONLY layout is the exception: it has the whole screen and the
4301
+ // detail box is the point of asking for it.
4157
4302
  const isMobilePane = String(panelClassName).includes("ps-mobile-session-pane");
4158
4303
  // Vertical is the primary scroll axis; sideways takes a deliberate swipe.
4159
4304
  const sessionListRef = React.useRef(null);
@@ -4184,6 +4329,12 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4184
4329
  // so the reconstruction below must carry it or the click does nothing
4185
4330
  // visible (the same omission that blinded the Node Map).
4186
4331
  listDeselected: Boolean(state.sessions.listDeselected),
4332
+ // The canvas markers read these. This synthetic state is exactly the
4333
+ // trap the branding comment below describes: omit a slice here and
4334
+ // every row computes as if it were empty — the markers were null on
4335
+ // every row until these two lines existed.
4336
+ canvasBySessionId: state.canvas?.bySessionId,
4337
+ canvasPrefs: state.canvas?.prefs,
4187
4338
  }), shallowEqualObject);
4188
4339
  const computedRows = React.useMemo(() => selectSessionRows({
4189
4340
  sessions: {
@@ -4207,7 +4358,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4207
4358
  // deployment, while the controller's own calls (which pass real state)
4208
4359
  // produced the branded name.
4209
4360
  branding: viewState.branding,
4210
- }), [viewState.activeSessionId, viewState.auth, viewState.branding, viewState.connectionMode, viewState.filterQuery, viewState.listDeselected, viewState.ownerFilter, viewState.pinnedIds, viewState.manualOrder, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
4361
+ canvas: {
4362
+ bySessionId: viewState.canvasBySessionId,
4363
+ prefs: viewState.canvasPrefs,
4364
+ },
4365
+ }), [viewState.activeSessionId, viewState.auth, viewState.branding, viewState.canvasBySessionId, viewState.canvasPrefs, viewState.connectionMode, viewState.filterQuery, viewState.listDeselected, viewState.ownerFilter, viewState.pinnedIds, viewState.manualOrder, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
4211
4366
  // Hold the previous rows when a poll produced identical output.
4212
4367
  const rows = useStableValue(computedRows);
4213
4368
  const activeSession = viewState.activeSessionId
@@ -4936,12 +5091,12 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4936
5091
  // touch hijacks the finger that should be scrolling the list.
4937
5092
  drag: touchInput ? null : dragHandlers,
4938
5093
  }))),
4939
- isMobilePane
4940
- ? null
4941
- : React.createElement(SessionDetailBox, {
5094
+ (showDetailBox === null ? !isMobilePane : showDetailBox)
5095
+ ? React.createElement(SessionDetailBox, {
4942
5096
  session: activeSession,
4943
5097
  childCount: activeRow?.childCount || 0,
4944
- })),
5098
+ })
5099
+ : null),
4945
5100
  (manageOpen && activeSession && !activeSession.isGroup)
4946
5101
  ? React.createElement(SessionModifyModal, {
4947
5102
  controller,
@@ -5032,20 +5187,9 @@ function FunnelGlyph() {
5032
5187
  }
5033
5188
 
5034
5189
  // Summary — a written summary. Was "≣", the same codepoint the Logs tab used.
5035
- function SummaryGlyph() {
5036
- return React.createElement(Glyph, null,
5037
- React.createElement("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }),
5038
- React.createElement("path", { d: "M14 3v5h5" }),
5039
- React.createElement("line", { x1: "8.5", y1: "13", x2: "15.5", y2: "13" }),
5040
- React.createElement("line", { x1: "8.5", y1: "17", x2: "13", y2: "17" }));
5041
- }
5042
5190
 
5043
5191
  // Back to the transcript. Replaces the 💬 emoji, which iOS force-renders in
5044
5192
  // colour against an otherwise monochrome row (see the note on the tab table).
5045
- function TranscriptGlyph() {
5046
- return React.createElement(Glyph, null,
5047
- React.createElement("path", { d: "M21 14.5a2.5 2.5 0 0 1-2.5 2.5H8l-5 4V5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5z" }));
5048
- }
5049
5193
 
5050
5194
  function PlusGlyph() {
5051
5195
  return React.createElement(Glyph, null,
@@ -5130,6 +5274,33 @@ function SidebarGlyph() {
5130
5274
  React.createElement("path", { d: "M9.5 3.5v17" }));
5131
5275
  }
5132
5276
 
5277
+ // The phone's view modes live in the top bar, not a bottom nav; each reads at
5278
+ // icon size without a label.
5279
+ // The Main pane, whatever layout it is in: a frame with a list rail and a
5280
+ // conversation beside it. Constant across the three-way cycle.
5281
+ function MainLayoutGlyph() {
5282
+ return React.createElement(Glyph, null,
5283
+ React.createElement("rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }),
5284
+ React.createElement("line", { x1: "9.5", y1: "4", x2: "9.5", y2: "20" }),
5285
+ React.createElement("line", { x1: "12.5", y1: "9", x2: "18", y2: "9" }),
5286
+ React.createElement("line", { x1: "12.5", y1: "13", x2: "18", y2: "13" }));
5287
+ }
5288
+
5289
+ // Inspector takes the pulse — the trace/sequence view is its default face.
5290
+ function InspectorPaneGlyph() {
5291
+ return React.createElement(Glyph, null,
5292
+ React.createElement("path", { d: "M3 12h4l2.5-6 4 12 2.5-6H21" }));
5293
+ }
5294
+
5295
+ // Activity reads a log: a page with lines on it.
5296
+ function ActivityPaneGlyph() {
5297
+ return React.createElement(Glyph, null,
5298
+ React.createElement("path", { d: "M6 3h8l4 4v14H6z" }),
5299
+ React.createElement("path", { d: "M14 3v4h4" }),
5300
+ React.createElement("line", { x1: "9", y1: "12", x2: "15", y2: "12" }),
5301
+ React.createElement("line", { x1: "9", y1: "16", x2: "15", y2: "16" }));
5302
+ }
5303
+
5133
5304
  // Logs are console output; a prompt chevron says that and nothing else.
5134
5305
  function LogsGlyph() {
5135
5306
  return React.createElement(Glyph, null,
@@ -5574,7 +5745,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5574
5745
  sessionsById: state.sessions.byId,
5575
5746
  sessionsFlat: state.sessions.flat,
5576
5747
  inspectorTab: state.ui.inspectorTab,
5577
- chatViewMode: state.ui.chatViewMode || "transcript",
5578
5748
  activeSessionIsGroup: Boolean(activeSessionId && state.sessions.byId[activeSessionId]?.isGroup),
5579
5749
  activeSessionStatus: activeSessionId ? String(state.sessions.byId[activeSessionId]?.status || "").toLowerCase() : "",
5580
5750
  focused: state.ui.focusRegion === "chat",
@@ -5606,7 +5776,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5606
5776
  },
5607
5777
  ui: {
5608
5778
  inspectorTab: viewState.inspectorTab,
5609
- chatViewMode: viewState.chatViewMode,
5610
5779
  },
5611
5780
  }), [
5612
5781
  viewState.activeHistory,
@@ -5615,7 +5784,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5615
5784
  viewState.authPrincipal,
5616
5785
  viewState.branding,
5617
5786
  viewState.connection,
5618
- viewState.chatViewMode,
5619
5787
  viewState.inspectorTab,
5620
5788
  viewState.sessionsById,
5621
5789
  viewState.sessionsFlat,
@@ -5667,7 +5835,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5667
5835
  controller, viewState.activeSessionId, viewState.activeSessionIsGroup,
5668
5836
  );
5669
5837
  const navigationError = useControllerSelector(controller, selectNavigationError, shallowEqualObject);
5670
- const composerBase = showComposer && !viewState.activeSessionIsGroup && viewState.chatViewMode !== "summary";
5838
+ const composerBase = showComposer && !viewState.activeSessionIsGroup;
5671
5839
  // Service sessions (⚗ tree-scoped machinery, e.g. the regen distiller) are
5672
5840
  // read-only BY KIND: their transcript is the trace of runtime machinery,
5673
5841
  // never a conversation surface — no prompt for anyone, owner included.
@@ -5879,6 +6047,49 @@ function ArtifactTakeoverPane({ controller, onClose }) {
5879
6047
  })));
5880
6048
  }
5881
6049
 
6050
+ /**
6051
+ * The phone's canvas: a LAYER over the content region, not a full screen.
6052
+ *
6053
+ * It used to be a fixed, full-viewport overlay — the portal header and the
6054
+ * toolbar disappeared behind it too, so looking at a drawing meant leaving the
6055
+ * app and finding your way back. It now fills exactly the region the Main,
6056
+ * Activity and Inspector panes share: top edge under the toolbar, bottom edge
6057
+ * at the viewport, edge to edge, no inset. The header and the toolbar (with
6058
+ * its Canvas button, the only way in or out — there is no ✕ here) stay put.
6059
+ *
6060
+ * It is rendered as a SIBLING of that region's content, not inside any pane,
6061
+ * because the frame has to outlive every pane: the canvas runs interactive
6062
+ * pages, and switching to Inspector and back must not reset a running game.
6063
+ *
6064
+ * Maximized, it goes truly full-screen: the same element flips from absolute
6065
+ * inside the region to fixed over the whole viewport, covering the portal
6066
+ * header and the toolbar too. That is a CLASS change on one div and nothing
6067
+ * else — the frame is never moved in the DOM, so a game survives the toggle.
6068
+ * The thin strip persists in both states; only the right-hand button flips.
6069
+ */
6070
+ function MobileCanvasLayer({ controller, visible = true }) {
6071
+ const [maximized, setMaximized] = React.useState(false);
6072
+ const toggleMaximized = React.useCallback(() => setMaximized((current) => !current), []);
6073
+ // Leaving the canvas drops full-screen, so coming back lands in the inset
6074
+ // presentation rather than a takeover the user has to undo before they can
6075
+ // reach the toolbar again.
6076
+ React.useEffect(() => {
6077
+ if (!visible) setMaximized(false);
6078
+ }, [visible]);
6079
+ // Kept MOUNTED while hidden: unmounting destroys the sandboxed document,
6080
+ // and with it everything the user typed and scrolled. The hidden state is
6081
+ // an off-screen transform (see .ps-mobile-canvas-layer.is-hidden), so the
6082
+ // frame keeps a real, unchanged box and no settle-reload fires on re-show.
6083
+ return React.createElement("div", {
6084
+ className: `ps-mobile-canvas-layer${maximized ? " is-maximized" : ""}${visible ? "" : " is-hidden"}`,
6085
+ ...(visible ? {} : { inert: true, "aria-hidden": "true" }),
6086
+ },
6087
+ React.createElement(CanvasPane, {
6088
+ controller, mobile: true, visible, focusOnPromote: true,
6089
+ maximized, onToggleMaximized: toggleMaximized,
6090
+ }));
6091
+ }
6092
+
5882
6093
  /**
5883
6094
  * Mobile artifact viewer — a genuine full-viewport overlay, not a pane.
5884
6095
  *
@@ -5952,18 +6163,575 @@ function MobileArtifactOverlay({ controller }) {
5952
6163
  })));
5953
6164
  }
5954
6165
 
5955
- function MobileWorkspace({ controller }) {
5956
- // The session list is always shown; use the toolbar Focus button to give
5957
- // the chat the full screen (the old Show/Hide toggle was redundant with it).
6166
+ /**
6167
+ * The phone's Main pane, in one of three layouts cycled by the Main toolbar
6168
+ * button: split (sessions + chat), chat only, sessions only (list + the
6169
+ * detail sub-panel). This replaced chat-focus mode, which was a second way to
6170
+ * say "chat only" with its own chrome and its own exit.
6171
+ */
6172
+ function MobileWorkspace({ controller, layout = "split" }) {
6173
+ const sessionPane = React.createElement(SessionPane, {
6174
+ controller,
6175
+ panelClassName: "ps-mobile-session-pane",
6176
+ });
6177
+ if (layout === "chat") {
6178
+ return React.createElement("div", { className: "ps-mobile-workspace is-chat-only" },
6179
+ React.createElement("div", { className: "ps-mobile-chat-pane" },
6180
+ React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
6181
+ }
6182
+ if (layout === "sessions") {
6183
+ return React.createElement("div", { className: "ps-mobile-workspace is-sessions-only" },
6184
+ React.createElement(SessionPane, {
6185
+ controller,
6186
+ panelClassName: "ps-mobile-session-pane",
6187
+ showDetailBox: true,
6188
+ }));
6189
+ }
5958
6190
  return React.createElement("div", { className: "ps-mobile-workspace" },
5959
- React.createElement(SessionPane, {
5960
- controller,
5961
- panelClassName: "ps-mobile-session-pane",
5962
- }),
6191
+ sessionPane,
5963
6192
  React.createElement("div", { className: "ps-mobile-chat-pane" },
5964
6193
  React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
5965
6194
  }
5966
6195
 
6196
+ /** Frame-and-easel glyph for the Canvas toggle. */
6197
+ function CanvasGlyph() {
6198
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" };
6199
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 16, height: 16, "aria-hidden": "true" },
6200
+ React.createElement("rect", { x: 2.5, y: 3.5, width: 15, height: 11, rx: 1.5, ...stroke }),
6201
+ React.createElement("path", { d: "M5.5 11.5l3-3.5 2.5 2.5 3.5-4", ...stroke }),
6202
+ React.createElement("path", { d: "M7 17.5h6M10 14.5v3", ...stroke }));
6203
+ }
6204
+
6205
+ /**
6206
+ * Diagnostics: a bug, for "debug".
6207
+ *
6208
+ * Not a cog — the admin console owns that — and not sliders, which the filter
6209
+ * funnel is already close enough to.
6210
+ */
6211
+ function DiagnosticsGlyph() {
6212
+ // Bigger than its neighbours on purpose, and drawn heavier. A bug is a
6213
+ // busier shape than a funnel or a cog: at 16px with hairline legs it
6214
+ // collapsed into a smudge, so it takes 20px and a thicker stroke to read
6215
+ // as the same visual weight.
6216
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" };
6217
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 20, height: 20, "aria-hidden": "true" },
6218
+ // Shell, with the seam that says beetle rather than pill.
6219
+ React.createElement("path", { d: "M6.2 8.6a3.8 3.8 0 0 1 7.6 0v2.9a3.8 3.8 0 0 1-7.6 0z", ...stroke }),
6220
+ React.createElement("path", { d: "M6.2 10.3h7.6", ...stroke }),
6221
+ // Head and antennae.
6222
+ React.createElement("path", { d: "M7.6 6.5a2.4 2.4 0 0 1 4.8 0", ...stroke }),
6223
+ React.createElement("path", { d: "M8 4.4 6.9 2.9M12 4.4l1.1-1.5", ...stroke }),
6224
+ // Legs: three a side, the outer two angled so the silhouette reads.
6225
+ React.createElement("path", { d: "M6.3 8.6 3.8 7.4M6.2 10.6H3.6M6.3 12.6 3.8 13.9", ...stroke }),
6226
+ React.createElement("path", { d: "M13.7 8.6 16.2 7.4M13.8 10.6h2.6M13.7 12.6l2.5 1.3", ...stroke }));
6227
+ }
6228
+
6229
+ /**
6230
+ * The canvas marker on a session row.
6231
+ *
6232
+ * Two states, and they must be tellable apart at a glance in a dense list:
6233
+ * "canvas" outline only — a canvas exists here
6234
+ * "unseen" outline plus a filled dot — it changed since you last looked
6235
+ *
6236
+ * Shape carries the meaning, not colour alone: the dot is visible in either
6237
+ * theme and to a reader who cannot separate the two hues.
6238
+ */
6239
+ function SessionCanvasMark({ mark }) {
6240
+ if (mark !== "canvas" && mark !== "unseen") return null;
6241
+ const unseen = mark === "unseen";
6242
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
6243
+ return React.createElement("span", {
6244
+ className: `ps-session-canvas-mark${unseen ? " is-unseen" : ""}`,
6245
+ title: unseen ? "Canvas updated since you last viewed it" : "This session has a canvas",
6246
+ "aria-label": unseen ? "Canvas updated" : "Has a canvas",
6247
+ role: "img",
6248
+ },
6249
+ React.createElement("svg", { viewBox: "0 0 16 16", width: 12, height: 12, "aria-hidden": "true" },
6250
+ React.createElement("rect", { x: 1.6, y: 2.6, width: 12.8, height: 9.2, rx: 1.4, ...stroke }),
6251
+ React.createElement("path", { d: "M6 14.4h4", ...stroke }),
6252
+ unseen
6253
+ ? React.createElement("circle", { cx: 12.4, cy: 4.6, r: 2.4, fill: "currentColor", stroke: "none" })
6254
+ : null));
6255
+ }
6256
+
6257
+ /**
6258
+ * The canvas slot controls: a dropdown when the session has more than one
6259
+ * drawn canvas, plus prev/next arrows. Hidden entirely with one canvas —
6260
+ * the common case stays exactly as it always looked. Slot state is
6261
+ * ui.canvasSlot (in-memory; the selector clamps to a drawn slot).
6262
+ */
6263
+ function CanvasSlotControls({ controller, view, compact = false }) {
6264
+ const slots = Array.isArray(view.slots) ? view.slots : [];
6265
+ if (slots.length <= 1) return null;
6266
+ const order = slots.map((s2) => s2.slot);
6267
+ const at = Math.max(0, order.indexOf(view.slot));
6268
+ const go = (delta) => {
6269
+ const next = order[(at + delta + order.length) % order.length];
6270
+ controller.dispatch({ type: "ui/canvasSlot", slot: next });
6271
+ };
6272
+ const labelFor = (s2) => (s2.name ? s2.name : `canvas ${s2.slot}`) + (s2.unseen ? " •" : "");
6273
+ return React.createElement("span", { className: `ps-canvas-slot-controls${compact ? " is-compact" : ""}` },
6274
+ React.createElement("button", {
6275
+ type: "button", className: "ps-artifact-pane-btn",
6276
+ onClick: () => go(-1), title: "Previous canvas", "aria-label": "Previous canvas",
6277
+ }, "\u2039"),
6278
+ React.createElement("select", {
6279
+ className: "ps-canvas-slot-select",
6280
+ value: view.slot,
6281
+ onChange: (e) => controller.dispatch({ type: "ui/canvasSlot", slot: Number(e.target.value) }),
6282
+ "aria-label": "Choose canvas",
6283
+ title: "Choose canvas",
6284
+ }, slots.map((s2) => React.createElement("option", { key: s2.slot, value: s2.slot }, labelFor(s2)))),
6285
+ React.createElement("button", {
6286
+ type: "button", className: "ps-artifact-pane-btn",
6287
+ onClick: () => go(1), title: "Next canvas", "aria-label": "Next canvas",
6288
+ }, "\u203a"));
6289
+ }
6290
+
6291
+ /** Zen: a narrow chat rail beside a wide canvas. */
6292
+ function ZenGlyph() {
6293
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" };
6294
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 15, height: 15, "aria-hidden": "true" },
6295
+ React.createElement("rect", { x: 2.2, y: 3.4, width: 3.6, height: 13.2, rx: 1, ...stroke }),
6296
+ React.createElement("rect", { x: 8.2, y: 3.4, width: 9.6, height: 13.2, rx: 1, ...stroke }));
6297
+ }
6298
+
6299
+ /** Corners pushing OUT — take the whole screen. */
6300
+ function MaximizeGlyph() {
6301
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
6302
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 13, height: 13, "aria-hidden": "true" },
6303
+ React.createElement("path", { d: "M8 3H3v5M12 3h5v5M12 17h5v-5M8 17H3v-5", ...stroke }));
6304
+ }
6305
+
6306
+ /** Corners pulling IN — back to the canvas inset in the content region. */
6307
+ function RestoreGlyph() {
6308
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
6309
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 13, height: 13, "aria-hidden": "true" },
6310
+ React.createElement("path", { d: "M3 8h5V3M17 8h-5V3M17 12h-5v5M3 12h5v5", ...stroke }));
6311
+ }
6312
+
6313
+ /**
6314
+ * Double-buffered canvas frame with PER-REVISION blob URLs.
6315
+ *
6316
+ * Deliberately not the artifact preview panel: that component nulls its frame
6317
+ * URL when its fetch key changes (right for a DIFFERENT document, a blank
6318
+ * flash for a redraw of the same one) and shares one URL between live and
6319
+ * staging frames. Here each fetched revision owns its URL; the replacement
6320
+ * loads hidden-but-painted behind the live frame and is promoted on load, so
6321
+ * a live redraw is a composite swap of something already drawn.
6322
+ */
6323
+ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null }) {
6324
+ const [live, setLive] = React.useState(null); // { url, rev }
6325
+ const [staging, setStaging] = React.useState(null); // { url, rev }
6326
+ const [loadError, setLoadError] = React.useState(null); // { rev }
6327
+ const [retryTick, setRetryTick] = React.useState(0);
6328
+ // Blob URLs tracked in a ref so cleanup can revoke directly — setState
6329
+ // updaters are neither a reliable post-unmount hook nor a pure place for
6330
+ // side effects (StrictMode double-invokes them).
6331
+ const urlsRef = React.useRef({ live: null, staging: null });
6332
+ const autoRetryRef = React.useRef({ rev: 0, used: false });
6333
+ const hostRef = React.useRef(null);
6334
+ const liveIframeElRef = React.useRef(null);
6335
+ const hostBox = useElementBox(hostRef);
6336
+ // The frame geometry each revision LOADED at. A sandboxed page cannot be
6337
+ // poked after the fact (no reaching into an opaque origin), and pages
6338
+ // routinely measure the viewport exactly once — so when the pane settles
6339
+ // at a materially different size than the page loaded at (boot-time
6340
+ // hydration, a column resize, zoom), the only honest fix is the reader's:
6341
+ // reload through the double buffer at the new size. Verified live: a page
6342
+ // loading during boot measured the iframe's INTRINSIC 150px default and
6343
+ // baked it in, which is how a full-height game renders as a bottom sliver
6344
+ // under a black void.
6345
+ const loadedBoxRef = React.useRef(null);
6346
+ const zoomRef = React.useRef(zoom);
6347
+ zoomRef.current = zoom;
6348
+ const visibleRef = React.useRef(visible);
6349
+ visibleRef.current = visible;
6350
+
6351
+ // The interactive-canvas bridge. Provenance is THIS check: a message is
6352
+ // accepted only when its source window is the LIVE canvas iframe — the
6353
+ // staging frame, the artifact reader, file previews, and every other
6354
+ // window on the page fail the identity test. Contract validation and
6355
+ // rate limiting happen in controller.submitCanvasAction; with no
6356
+ // contract on the current revision, nothing is accepted at all.
6357
+ React.useEffect(() => {
6358
+ if (!controller || !sessionId) return undefined;
6359
+ const onMessage = (event) => {
6360
+ const frameEl = liveIframeElRef.current;
6361
+ if (!frameEl || !event.source || event.source !== frameEl.contentWindow) return;
6362
+ const payload = event.data;
6363
+ if (!payload || typeof payload !== "object" || payload.type !== "canvas-action") return;
6364
+ controller.submitCanvasAction(sessionId, payload).then((result) => {
6365
+ if (result && result.ok === false && typeof console !== "undefined") {
6366
+ console.warn(`canvas action rejected: ${result.reason}`);
6367
+ }
6368
+ }).catch(() => {});
6369
+ };
6370
+ window.addEventListener("message", onMessage);
6371
+ return () => window.removeEventListener("message", onMessage);
6372
+ }, [controller, sessionId]);
6373
+
6374
+ React.useEffect(() => {
6375
+ if (!controller || !sessionId || !latestRev) return undefined;
6376
+ let cancelled = false;
6377
+ let retryTimer = null;
6378
+ fetchArtifactHtmlObjectUrl(controller, sessionId, slot <= 1 ? "canvas.html" : `canvas${slot}.html`)
6379
+ .then((url) => {
6380
+ if (cancelled) { URL.revokeObjectURL(url); return; }
6381
+ // A staging revision that never loaded is superseded here —
6382
+ // revoke it, or every rapid redraw leaks a document-sized blob.
6383
+ if (urlsRef.current.staging && urlsRef.current.staging !== urlsRef.current.live) {
6384
+ URL.revokeObjectURL(urlsRef.current.staging);
6385
+ }
6386
+ urlsRef.current.staging = url;
6387
+ setLoadError(null);
6388
+ setStaging({ url, rev: latestRev });
6389
+ })
6390
+ .catch(() => {
6391
+ if (cancelled) return;
6392
+ // One quiet retry for transient blips; after that, surface it.
6393
+ // The next draw event or a pane re-open also refetches.
6394
+ if (autoRetryRef.current.rev !== latestRev) autoRetryRef.current = { rev: latestRev, used: false };
6395
+ if (!autoRetryRef.current.used) {
6396
+ autoRetryRef.current.used = true;
6397
+ retryTimer = setTimeout(() => { if (!cancelled) setRetryTick((t) => t + 1); }, 1500);
6398
+ return;
6399
+ }
6400
+ setLoadError({ rev: latestRev });
6401
+ });
6402
+ return () => {
6403
+ cancelled = true;
6404
+ if (retryTimer) clearTimeout(retryTimer);
6405
+ };
6406
+ }, [controller, sessionId, slot, latestRev, retryTick]);
6407
+
6408
+ // Session switch / unmount: drop both frames — a different session is a
6409
+ // different document, and showing the old canvas under a new session is a
6410
+ // lie. Revocation goes through the ref, not state updaters.
6411
+ React.useEffect(() => () => {
6412
+ if (urlsRef.current.staging && urlsRef.current.staging !== urlsRef.current.live) {
6413
+ URL.revokeObjectURL(urlsRef.current.staging);
6414
+ }
6415
+ if (urlsRef.current.live) URL.revokeObjectURL(urlsRef.current.live);
6416
+ urlsRef.current = { live: null, staging: null };
6417
+ setLive(null);
6418
+ setStaging(null);
6419
+ setLoadError(null);
6420
+ }, [sessionId]);
6421
+
6422
+ const promote = React.useCallback((frame) => {
6423
+ // Only the CURRENT staging revision promotes — a load event from a
6424
+ // frame the fetch effect already superseded must not go live again.
6425
+ if (!frame || urlsRef.current.staging !== frame.url) return;
6426
+ const oldLive = urlsRef.current.live;
6427
+ urlsRef.current.live = frame.url;
6428
+ urlsRef.current.staging = null;
6429
+ if (oldLive && oldLive !== frame.url) URL.revokeObjectURL(oldLive);
6430
+ setLive(frame);
6431
+ setStaging((current) => (current && current.url === frame.url ? null : current));
6432
+ // Record the geometry this revision loaded at (in page-viewport
6433
+ // units, i.e. divided by zoom) — the settle effect below compares
6434
+ // against it to decide when a reload is owed.
6435
+ const hostEl = hostRef.current;
6436
+ if (hostEl) {
6437
+ const rect = hostEl.getBoundingClientRect();
6438
+ const z = zoomRef.current || 1;
6439
+ loadedBoxRef.current = { w: Math.round(rect.width / z), h: Math.round(rect.height / z) };
6440
+ }
6441
+ // The surface's receipt: THIS is the one place a revision is known to
6442
+ // be truly on screen, so this is where viewed-marking lives. Mode
6443
+ // transitions marking revs viewed claimed phones saw drawings their
6444
+ // layout never showed. A frame kept MOUNTED but hidden (the sticky
6445
+ // toggle) still promotes new revisions in the background — those are
6446
+ // NOT viewed; the badge must light until the user actually looks.
6447
+ if (controller && sessionId && visibleRef.current) {
6448
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, rev: frame.rev });
6449
+ }
6450
+ // iOS: a programmatic focus into the freshly promoted frame is the
6451
+ // synchronous interaction that registers its scroller — the code
6452
+ // equivalent of the tap users discovered by accident. preventScroll
6453
+ // so the viewport never jumps. Phone-only (the desktop must not have
6454
+ // its composer focus stolen by a background draw).
6455
+ if (focusOnPromote && visibleRef.current) {
6456
+ try { liveIframeElRef.current?.focus({ preventScroll: true }); } catch { /* best effort */ }
6457
+ }
6458
+ }, [controller, sessionId, focusOnPromote]);
6459
+
6460
+ // The LLM→page half of the duplex: post the latest data tick INTO the
6461
+ // live frame. Keyed on BOTH the tick and the live frame identity, so a
6462
+ // freshly promoted document (redraw, reload, cold open) replays the
6463
+ // current state without the agent doing anything — the page just needs
6464
+ // one idempotent applyData(). Posting into a cross-origin frame is
6465
+ // allowed; a page with no listener ignores it.
6466
+ React.useEffect(() => {
6467
+ if (!live || !dataRev || dataPayload === null) return;
6468
+ try {
6469
+ liveIframeElRef.current?.contentWindow?.postMessage(
6470
+ { type: "canvas-data", data: dataPayload, dataRev }, "*",
6471
+ );
6472
+ } catch { /* frame mid-teardown */ }
6473
+ // On-screen injection = seen; hidden injection leaves the badge lit
6474
+ // until the user actually looks (the catch-up below files it then).
6475
+ if (controller && sessionId && visibleRef.current) {
6476
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, dataRev });
6477
+ }
6478
+ }, [live, dataRev, dataPayload, controller, sessionId]);
6479
+
6480
+ // Catch-up receipt: becoming visible with a live revision on screen IS
6481
+ // viewing it — this is what clears a badge earned while hidden.
6482
+ React.useEffect(() => {
6483
+ if (visible && live && controller && sessionId) {
6484
+ controller.dispatch({ type: "canvas/viewed", sessionId, slot, rev: live.rev, ...(dataRev ? { dataRev } : {}) });
6485
+ if (focusOnPromote) {
6486
+ try { liveIframeElRef.current?.focus({ preventScroll: true }); } catch { /* best effort */ }
6487
+ }
6488
+ }
6489
+ }, [visible, live, controller, sessionId, focusOnPromote, dataRev]);
6490
+
6491
+ // Settle-reload: once the pane's size (or zoom) stops moving somewhere
6492
+ // materially different from where the live page loaded, refetch the same
6493
+ // revision through the staging buffer so the page re-runs its layout at
6494
+ // the true viewport. Debounced past the resize gesture; a couple of px
6495
+ // of tolerance so subpixel wobble never churns it.
6496
+ React.useEffect(() => {
6497
+ if (!live || !visible) return undefined;
6498
+ const z = zoom || 1;
6499
+ const w = Math.round((hostBox.width || 0) / z);
6500
+ const h = Math.round((hostBox.height || 0) / z);
6501
+ if (!w || !h) return undefined;
6502
+ const loadedAt = loadedBoxRef.current;
6503
+ if (loadedAt) {
6504
+ // A page that loaded against a REAL box owns its layout from then
6505
+ // on and is NEVER auto-reloaded: the inner window receives genuine
6506
+ // resize events when this element changes size (sandboxing blocks
6507
+ // DOM poking, not resize), and a reload would cost a stateful app
6508
+ // — a game, a half-filled form — everything it holds, for a
6509
+ // purely cosmetic guarantee. Observed live: dragging the desktop
6510
+ // window reset a farm game to day 1. Settle-reload exists ONLY to
6511
+ // rescue documents that loaded against a degenerate box — the
6512
+ // iframe's 300×150 intrinsic default before layout, or a hidden
6513
+ // host — and baked that measurement in.
6514
+ const loadedDegenerate = loadedAt.w < 220 || loadedAt.h < 170;
6515
+ if (!loadedDegenerate) return undefined;
6516
+ const dw = Math.abs(loadedAt.w - w);
6517
+ const dh = Math.abs(loadedAt.h - h);
6518
+ if (dw <= 2 && dh <= 2) return undefined;
6519
+ // Height-only jitter is iOS toolbar churn (the URL bar collapses
6520
+ // and expands, ±6% or so) — a reload for that costs the user
6521
+ // their scroll position and page state for nothing.
6522
+ if (dw <= 2 && dh / Math.max(1, loadedAt.h) < 0.15) return undefined;
6523
+ }
6524
+ const timer = setTimeout(() => {
6525
+ loadedBoxRef.current = { w, h };
6526
+ setRetryTick((t) => t + 1);
6527
+ }, 350);
6528
+ return () => clearTimeout(timer);
6529
+ }, [hostBox.width, hostBox.height, zoom, live, visible]);
6530
+
6531
+ const zoomStyle = zoom !== 1 && hostBox.width > 0
6532
+ ? {
6533
+ flex: "none",
6534
+ width: `${Math.max(1, Math.round(hostBox.width / zoom))}px`,
6535
+ height: `${Math.max(1, Math.round(hostBox.height / zoom))}px`,
6536
+ transform: `scale(${zoom})`,
6537
+ transformOrigin: "top left",
6538
+ }
6539
+ : undefined;
6540
+
6541
+ return React.createElement("div", { ref: hostRef, className: "ps-html-preview-host is-fitting" },
6542
+ [live, staging].filter(Boolean).map((frame) => React.createElement("iframe", {
6543
+ key: `canvas:${frame.rev}:${frame.url}`,
6544
+ ref: frame === live ? ((el) => { liveIframeElRef.current = el; }) : undefined,
6545
+ className: `ps-html-preview-frame${frame === live ? "" : " is-staging"}`,
6546
+ style: zoomStyle,
6547
+ src: frame.url,
6548
+ title: "Session canvas",
6549
+ // Same sandbox contract as artifact previews — no allow-same-origin.
6550
+ sandbox: "allow-scripts allow-popups allow-popups-to-escape-sandbox",
6551
+ // The autoplay Permissions Policy defaults to `self`, and a
6552
+ // sandboxed frame has an OPAQUE origin — so a canvas page could
6553
+ // build an AudioContext, get no error, and emit nothing. Granting
6554
+ // autoplay is what lets a game's sound effects actually play.
6555
+ allow: "autoplay *",
6556
+ referrerPolicy: "no-referrer",
6557
+ onLoad: frame === staging ? () => promote(frame) : undefined,
6558
+ })),
6559
+ loadError
6560
+ ? React.createElement("div", { className: "ps-canvas-load-error" },
6561
+ React.createElement("span", null,
6562
+ live
6563
+ ? `Couldn't load rev ${loadError.rev} — showing rev ${live.rev}.`
6564
+ : `Couldn't load rev ${loadError.rev}.`),
6565
+ React.createElement("button", {
6566
+ type: "button",
6567
+ className: "ps-artifact-pane-btn",
6568
+ onClick: () => { autoRetryRef.current = { rev: 0, used: false }; setLoadError(null); setRetryTick((t) => t + 1); },
6569
+ }, "Retry"))
6570
+ : null);
6571
+ }
6572
+
6573
+ /**
6574
+ * The session's standing display: rendered from the reserved canvas.html
6575
+ * artifact and converging live on canvas_updated events.
6576
+ *
6577
+ * Desktop mounts it in the right column, where it replaces the
6578
+ * inspector/activity split while active (the artifact reader still takes
6579
+ * precedence while open and returns here). The phone mounts it as a layer over
6580
+ * the content region — see MobileCanvasLayer — and gets a stripped header: the
6581
+ * revision on one thin line with a maximize toggle, no zoom control and no ✕,
6582
+ * because there the toolbar's Canvas button is the only way in or out.
6583
+ *
6584
+ * maximized / onToggleMaximized are the phone's full-screen state, owned by
6585
+ * MobileCanvasLayer. Passing no handler simply omits the button.
6586
+ */
6587
+ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote = false, maximized = false, onToggleMaximized = null }) {
6588
+ const view = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
6589
+ const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
6590
+ const setZoom = React.useCallback((next) => {
6591
+ controller.dispatch({ type: "files/htmlZoom", zoom: next });
6592
+ }, [controller]);
6593
+
6594
+ // Belt to the selection-burst braces: a pane mounted before the burst's
6595
+ // snapshot resolves (deep link straight into canvas mode) fetches it here;
6596
+ // the memo makes the duplicate call free.
6597
+ React.useEffect(() => {
6598
+ if (view.sessionId) controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
6599
+ }, [controller, view.sessionId]);
6600
+
6601
+ const zenOn = useControllerSelector(controller, (s) => s?.ui?.canvasZen === true);
6602
+ const [toolbarSlot, setToolbarSlot] = React.useState(null);
6603
+ React.useEffect(() => {
6604
+ if (!maximized) { setToolbarSlot(null); return undefined; }
6605
+ // The slot is rendered by the Toolbar in the SAME commit that flips
6606
+ // maximized, so poll one frame rather than racing it.
6607
+ let raf = 0;
6608
+ const find = () => {
6609
+ const el = document.getElementById("ps-toolbar-canvas-slot");
6610
+ if (el) setToolbarSlot(el);
6611
+ else raf = requestAnimationFrame(find);
6612
+ };
6613
+ find();
6614
+ return () => cancelAnimationFrame(raf);
6615
+ }, [maximized]);
6616
+ const closeCanvas = React.useCallback(() => {
6617
+ controller.dispatch({ type: "ui/canvasOpen", open: false, sessionId: view.sessionId, manual: true });
6618
+ }, [controller, view.sessionId]);
6619
+
6620
+ // The phone's header is ONE thin line: `rev 4` on the left, the
6621
+ // maximize/restore toggle hard right. Nothing else — no ✕ (the toolbar's
6622
+ // Canvas button is the way out) and no zoom control, because every pixel
6623
+ // of height here is canvas the user can no longer see.
6624
+ //
6625
+ // The strip renders whenever there is a revision to name OR the canvas is
6626
+ // maximized. That second half is not cosmetic: maximized covers the whole
6627
+ // viewport including the toolbar, so if a cleared canvas (sizeBytes 0)
6628
+ // dropped the strip it would take the only way back with it.
6629
+ const showRevStrip = mobile && (view.exists || maximized);
6630
+ const header = mobile
6631
+ ? (showRevStrip
6632
+ ? React.createElement("header", { className: "ps-artifact-pane-bar is-rev-strip" },
6633
+ React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6634
+ view.exists ? `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}` : ""),
6635
+ React.createElement(CanvasSlotControls, { controller, view, compact: true }),
6636
+ onToggleMaximized
6637
+ ? React.createElement("button", {
6638
+ type: "button",
6639
+ className: "ps-canvas-max-btn",
6640
+ onClick: onToggleMaximized,
6641
+ title: maximized ? "Back to the inset canvas" : "Maximize — cover the whole screen",
6642
+ "aria-label": maximized ? "Restore canvas" : "Maximize canvas",
6643
+ }, React.createElement(maximized ? RestoreGlyph : MaximizeGlyph))
6644
+ : null)
6645
+ : null)
6646
+ : (maximized ? (toolbarSlot ? createPortal(React.createElement(React.Fragment, null,
6647
+ view.exists
6648
+ ? React.createElement("span", { className: "ps-toolbar-canvas-rev" }, `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}`)
6649
+ : null,
6650
+ React.createElement(CanvasSlotControls, { controller, view, compact: true }),
6651
+ view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6652
+ React.createElement("button", {
6653
+ type: "button",
6654
+ className: `ps-artifact-pane-btn${zenOn ? " is-active" : ""}`,
6655
+ // Full screen covers the workspace, so a bare zen toggle
6656
+ // changes state UNDER the layer and looks dead. From here zen
6657
+ // means "step down into zen": drop full screen in the same
6658
+ // click so the change is visible.
6659
+ onClick: () => {
6660
+ controller.dispatch({ type: "ui/canvasMaximized", on: false });
6661
+ controller.dispatch({ type: "ui/canvasZen", on: !zenOn });
6662
+ },
6663
+ title: zenOn ? "Leave zen — bring back sessions and panels" : "Zen — chat rail + canvas only",
6664
+ "aria-label": zenOn ? "Leave zen mode" : "Zen mode",
6665
+ }, React.createElement(ZenGlyph)),
6666
+ React.createElement("button", {
6667
+ type: "button",
6668
+ className: "ps-artifact-pane-btn ps-toolbar-canvas-restore",
6669
+ onClick: () => controller.dispatch({ type: "ui/canvasMaximized", on: false }),
6670
+ title: "Back to the workspace",
6671
+ "aria-label": "Restore canvas",
6672
+ }, React.createElement(RestoreGlyph))), toolbarSlot) : null)
6673
+ : React.createElement("header", { className: "ps-artifact-pane-bar" },
6674
+ React.createElement("span", { className: "ps-artifact-pane-heading" },
6675
+ React.createElement("span", { className: "ps-artifact-pane-name ps-canvas-pane-glyph", title: view.note || "Canvas" },
6676
+ React.createElement(CanvasGlyph)),
6677
+ view.exists
6678
+ ? React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6679
+ `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}`)
6680
+ : null,
6681
+ React.createElement(CanvasSlotControls, { controller, view })),
6682
+ React.createElement("span", { className: "ps-artifact-pane-actions" },
6683
+ view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6684
+ // Zen: sessions step away, chat becomes a rail, the canvas is
6685
+ // the workbench. The mode for "the work happens on the canvas,
6686
+ // chat is for steering".
6687
+ React.createElement("button", {
6688
+ type: "button",
6689
+ className: `ps-artifact-pane-btn${zenOn ? " is-active" : ""}`,
6690
+ onClick: () => controller.dispatch({ type: "ui/canvasZen" }),
6691
+ title: zenOn ? "Leave zen — bring back sessions and panels" : "Zen — chat rail + canvas only",
6692
+ "aria-label": zenOn ? "Leave zen mode" : "Zen mode",
6693
+ }, React.createElement(ZenGlyph)),
6694
+ // Full screen. No ✕ beside it: the toolbar's Canvas button is
6695
+ // the way out, and a second close control in a pane that also
6696
+ // has a toggle is one affordance too many.
6697
+ //
6698
+ // While full screen this whole bar is hidden and the rev and
6699
+ // the way back are promoted into the portal header, so the
6700
+ // control never disappears with the pane that drew it.
6701
+ React.createElement("button", {
6702
+ type: "button",
6703
+ className: "ps-artifact-pane-btn",
6704
+ onClick: () => controller.dispatch({ type: "ui/canvasMaximized", on: true }),
6705
+ title: "Full screen — hide chat and sessions",
6706
+ "aria-label": "Full screen canvas",
6707
+ }, React.createElement(MaximizeGlyph)))));
6708
+
6709
+ return React.createElement("section", {
6710
+ className: "ps-artifact-pane ps-canvas-pane",
6711
+ role: "region",
6712
+ "aria-label": "Session canvas",
6713
+ },
6714
+ header,
6715
+ React.createElement("div", { className: "ps-artifact-pane-body" },
6716
+ view.exists
6717
+ ? React.createElement(CanvasFrame, {
6718
+ key: `slot:${view.slot}`,
6719
+ controller,
6720
+ sessionId: view.sessionId,
6721
+ slot: view.slot,
6722
+ latestRev: view.latestRev,
6723
+ zoom,
6724
+ visible,
6725
+ focusOnPromote,
6726
+ dataRev: view.latestDataRev,
6727
+ dataPayload: view.dataPayload,
6728
+ })
6729
+ : React.createElement("div", { className: "ps-canvas-blank" },
6730
+ React.createElement("p", { className: "ps-canvas-blank-title" }, "Nothing on the canvas yet."),
6731
+ React.createElement("p", null,
6732
+ "Ask the agent to draw — a dashboard, a chart, a diagram — or it will draw when it has something worth showing."))));
6733
+ }
6734
+
5967
6735
  function InspectorTabs({ activeTab, controller }) {
5968
6736
  const visibleTabs = React.useMemo(() => getVisibleInspectorTabs(controller), [controller]);
5969
6737
  // Icon-only tabs: the full label (e.g. "Node Map") lives in the IconButton
@@ -6410,7 +7178,7 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
6410
7178
  followBottom: state.ui.followBottom?.inspector !== false,
6411
7179
  logsTailing: state.logs.tailing,
6412
7180
  filesFullscreen: Boolean(state.files.fullscreen),
6413
- contentWidth: getPortalInspectorContentWidth(paneWidth, inspectorTab),
7181
+ contentWidth: getPortalInspectorContentWidth(paneWidth, inspectorTab, mobile),
6414
7182
  };
6415
7183
  }, shallowEqualObject);
6416
7184
  const selectorState = React.useMemo(() => ({
@@ -6465,10 +7233,13 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
6465
7233
  viewState.sessionsById,
6466
7234
  viewState.sessionsFlat,
6467
7235
  ]);
7236
+ // allowWideColumns let the sequence grid exceed the viewport so a phone
7237
+ // could pan it — in practice it rendered clipped on BOTH edges (the
7238
+ // timestamps and the STATS header sliced off) and read as broken. Fitting
7239
+ // to width collapses surplus lanes into "…" instead, which is legible.
6468
7240
  const inspector = React.useMemo(() => selectInspector(selectorState, {
6469
7241
  width: viewState.contentWidth,
6470
- allowWideColumns: mobile,
6471
- }), [mobile, selectorState, viewState.contentWidth]);
7242
+ }), [selectorState, viewState.contentWidth]);
6472
7243
  const completionByTurn = React.useMemo(() => {
6473
7244
  const history = viewState.activeSessionId
6474
7245
  ? viewState.historyBySessionId?.get(viewState.activeSessionId) || null
@@ -6499,6 +7270,54 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
6499
7270
  return () => window.clearInterval(timer);
6500
7271
  }, [controller, viewState.inspectorTab]);
6501
7272
 
7273
+ // Ground-truth clamp, phones only. The global column count comes from a
7274
+ // window-level probe that phones have repeatedly proven able to lie to
7275
+ // (pinch state, desktop-site layout viewports, probe-context fonts). The
7276
+ // pane itself cannot lie: it measures its own content box and its own
7277
+ // rendered line font, and when the published count disagrees materially it
7278
+ // republishes from those measurements. Runs after paint on mount and tab
7279
+ // switches; the ±2 tolerance keeps it from ping-ponging with publish().
7280
+ const clampFrameRef = React.useRef(null);
7281
+ React.useEffect(() => {
7282
+ if (!mobile) return undefined;
7283
+ clampFrameRef.current = requestAnimationFrame(() => {
7284
+ try {
7285
+ const panel = document.querySelector(".ps-mobile-pane-fill .ps-inspector-pane .ps-scroll-panel");
7286
+ const line = panel ? panel.querySelector(".ps-line") : null;
7287
+ if (!panel || !line) return;
7288
+ const cs = window.getComputedStyle(line);
7289
+ const probe = document.createElement("span");
7290
+ probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
7291
+ probe.style.fontFamily = cs.fontFamily;
7292
+ probe.style.fontSize = cs.fontSize;
7293
+ probe.style.fontWeight = cs.fontWeight;
7294
+ probe.style.letterSpacing = cs.letterSpacing;
7295
+ probe.textContent = "0".repeat(100);
7296
+ line.parentElement.appendChild(probe);
7297
+ const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
7298
+ probe.remove();
7299
+ const panelStyle = window.getComputedStyle(panel);
7300
+ const contentPx = panel.clientWidth
7301
+ - (parseFloat(panelStyle.paddingLeft) || 0)
7302
+ - (parseFloat(panelStyle.paddingRight) || 0);
7303
+ if (!(contentPx > 50)) return;
7304
+ const contentCols = Math.floor(contentPx / charWidth);
7305
+ // getPortalInspectorContentWidth hands the selector paneWidth-6
7306
+ // on mobile, so publishing contentCols+6 makes the selector see
7307
+ // exactly what the pane measured.
7308
+ const paneCols = contentCols + 6;
7309
+ const currentCols = controller.getState().ui.layout?.viewportWidth ?? 120;
7310
+ if (Math.abs(paneCols - currentCols) <= 2) return;
7311
+ controller.dispatch({ type: "ui/viewport", width: paneCols, height: null });
7312
+ } catch {
7313
+ // Measurement is best-effort; the publish path still stands.
7314
+ }
7315
+ });
7316
+ return () => {
7317
+ if (clampFrameRef.current) cancelAnimationFrame(clampFrameRef.current);
7318
+ };
7319
+ }, [controller, mobile, viewState.inspectorTab, viewState.activeSessionId]);
7320
+
6502
7321
  if (viewState.inspectorTab === "files" && !inspector.disabled) {
6503
7322
  return React.createElement(FilesPane, { controller, focused: viewState.focused, mobile });
6504
7323
  }
@@ -6705,51 +7524,6 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
6705
7524
  }, content);
6706
7525
  }
6707
7526
 
6708
- function ChatFocusWorkspace({ controller, openPane, onTogglePane, onExitFocus, mobile = false }) {
6709
- const focusRegion = useControllerSelector(controller, (state) => state.ui.focusRegion);
6710
-
6711
- // Icons, not words: two text buttons ate a whole row of phone height, and
6712
- // IconButton carries the label as a hover tooltip / long-press on touch.
6713
- const rail = mobile
6714
- ? React.createElement("div", { className: "ps-chat-focus-rail" },
6715
- React.createElement(IconButton, {
6716
- className: "ps-mini-button ps-chat-focus-button",
6717
- icon: React.createElement(CollapseGlyph),
6718
- label: "Exit focus mode",
6719
- onClick: onExitFocus,
6720
- }),
6721
- React.createElement(IconButton, {
6722
- className: "ps-mini-button ps-chat-focus-button",
6723
- icon: React.createElement(SidebarGlyph),
6724
- label: openPane === "sessions" ? "Hide sessions" : "Show sessions",
6725
- active: openPane === "sessions",
6726
- onClick: () => onTogglePane("sessions"),
6727
- }))
6728
- : React.createElement("div", { className: "ps-chat-focus-rail" },
6729
- CHAT_FOCUS_PANES.map((pane) => React.createElement("button", {
6730
- key: pane.id,
6731
- type: "button",
6732
- className: `ps-mini-button ps-chat-focus-button${openPane === pane.id ? " is-active" : ""}`,
6733
- "aria-pressed": openPane === pane.id ? "true" : "false",
6734
- onClick: () => onTogglePane(pane.id),
6735
- }, pane.label)),
6736
- React.createElement("div", { className: "ps-chat-focus-status" },
6737
- openPane
6738
- ? `Focused: ${CHAT_FOCUS_PANES.find((pane) => pane.id === openPane)?.label || openPane}`
6739
- : `Focused: ${focusRegion === "prompt" ? "Prompt" : "Chat"}`));
6740
-
6741
- return React.createElement("div", { className: "ps-chat-focus-shell" },
6742
- rail,
6743
- React.createElement("div", { className: "ps-chat-focus-body" },
6744
- React.createElement(ChatPane, { controller, mobile, fullWidth: true }),
6745
- React.createElement(ChatFocusOverlay, {
6746
- controller,
6747
- pane: openPane,
6748
- onClose: () => onTogglePane(openPane),
6749
- mobile,
6750
- })));
6751
- }
6752
-
6753
7527
  const EMPTY_ARRAY = Object.freeze([]);
6754
7528
 
6755
7529
  function formatAttachmentSize(sizeBytes) {
@@ -7226,7 +8000,7 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
7226
8000
  tooltipNode);
7227
8001
  }
7228
8002
 
7229
- function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus = null, chatFocusDisabled = false }) {
8003
+ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPane = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
7230
8004
  const [headerSlot, setHeaderSlot] = React.useState(null);
7231
8005
  React.useEffect(() => {
7232
8006
  if (typeof document === "undefined") return;
@@ -7237,10 +8011,13 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
7237
8011
  setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
7238
8012
  }, [mobile]);
7239
8013
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
7240
- const chatView = useControllerSelector(controller, (state) => ({
7241
- mode: state.ui.chatViewMode || "transcript",
7242
- activeSessionIsGroup: Boolean(state.sessions.activeSessionId && state.sessions.byId[state.sessions.activeSessionId]?.isGroup),
7243
- }), shallowEqualObject);
8014
+ const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
8015
+ const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
8016
+ const canvasMaximized = useControllerSelector(controller, (s) => s?.ui?.canvasMaximized === true);
8017
+ // The artifact reader takes over the right side; while it is up, the
8018
+ // surface on screen is NOT the canvas, so the canvas toggle must not
8019
+ // read as active (its next press still shows the canvas).
8020
+ const artifactReaderOpen = useControllerSelector(controller, (s) => Boolean(s?.files?.paneOpen && s?.files?.selectedArtifactId));
7244
8021
 
7245
8022
  // Icon-first toolbar: the glyph is the affordance, the label rides a
7246
8023
  // tooltip (desktop hover via title; mobile long-press via IconButton).
@@ -7267,24 +8044,55 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
7267
8044
  label: "Theme",
7268
8045
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
7269
8046
  },
8047
+ // Canvas toggle, both device classes: the desktop flips the right
8048
+ // column, the phone lays the canvas over the chat transcript — one
8049
+ // affordance, one badge, and on the phone the ONLY way in or out (the
8050
+ // canvas itself carries no ✕ there). While the canvas is hidden, an
8051
+ // undisplayed revision lights the badge.
7270
8052
  {
7271
- key: "summary",
7272
- icon: React.createElement(chatView.mode === "summary" ? TranscriptGlyph : SummaryGlyph),
7273
- label: chatView.activeSessionIsGroup
7274
- ? "Groups show group details"
7275
- : (chatView.mode === "summary" ? "Show chat transcript" : "Show summary"),
7276
- onClick: () => controller.setChatViewMode(chatView.mode === "summary" ? "transcript" : "summary"),
7277
- disabled: chatView.activeSessionIsGroup,
7278
- active: chatView.mode === "summary",
8053
+ key: "canvas",
8054
+ icon: React.createElement(CanvasGlyph),
8055
+ label: (mobile ? canvasPaneOpen : (canvasView.mode === "canvas" && !artifactReaderOpen))
8056
+ ? "Hide the canvas"
8057
+ : "Show canvas",
8058
+ onClick: () => {
8059
+ if (mobile) {
8060
+ if (onToggleCanvasPane) onToggleCanvasPane();
8061
+ return;
8062
+ }
8063
+ // Desktop: the canvas is a column of its own now. Closing it
8064
+ // shows nothing in its place — Diagnostics has its own toggle.
8065
+ controller.dispatch({
8066
+ type: "ui/canvasOpen",
8067
+ open: canvasView.mode !== "canvas",
8068
+ sessionId: canvasView.sessionId,
8069
+ manual: true,
8070
+ });
8071
+ },
8072
+ active: mobile ? canvasPaneOpen : (canvasView.mode === "canvas" && !artifactReaderOpen),
8073
+ badge: mobile
8074
+ ? (canvasView.exists
8075
+ && (canvasView.latestRev > (canvasView.lastViewedRev || 0)
8076
+ || (canvasView.latestDataRev || 0) > (canvasView.lastViewedDataRev || 0))
8077
+ && !canvasPaneOpen)
8078
+ : canvasView.unseen,
8079
+ // Three states: plain outline (empty), green dot bottom-left
8080
+ // (loaded), green + yellow (loaded with unseen changes).
8081
+ loadedDot: canvasView.exists,
7279
8082
  },
7280
- ...(onToggleChatFocus ? [{
7281
- key: "focus",
7282
- icon: React.createElement(chatFocusMode ? CollapseGlyph : ExpandGlyph),
7283
- label: chatFocusMode ? "Exit focus mode" : "Focus the chat pane",
7284
- onClick: onToggleChatFocus,
7285
- disabled: chatFocusDisabled,
7286
- active: chatFocusMode,
7287
- }] : []),
8083
+ // Diagnostics — Inspector + Activity as one column, desktop only.
8084
+ //
8085
+ // They used to be tabs inside the chat pane and were always present.
8086
+ // Off by default now, and behind this toggle: they are instrumentation,
8087
+ // not part of reading a conversation. The phone keeps them as nav tabs,
8088
+ // where there is no column to put them in.
8089
+ ...(mobile ? [] : [{
8090
+ key: "diagnostics",
8091
+ icon: React.createElement(DiagnosticsGlyph),
8092
+ label: diagnosticsOpen ? "Hide diagnostics" : "Show diagnostics (inspector and activity)",
8093
+ onClick: () => controller.dispatch({ type: "ui/diagnosticsOpen" }),
8094
+ active: diagnosticsOpen,
8095
+ }]),
7288
8096
  // Admin console is desktop-only: its settings tree, package detail
7289
8097
  // and file preview need width the phone layout cannot give them, so
7290
8098
  // the button is omitted rather than shipped half-working.
@@ -7297,27 +8105,120 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
7297
8105
  }]),
7298
8106
  ];
7299
8107
 
7300
- const renderButton = (def) => React.createElement(IconButton, {
7301
- key: def.key,
7302
- icon: def.icon,
7303
- label: def.label,
7304
- onClick: def.onClick,
7305
- disabled: Boolean(def.disabled),
7306
- active: Boolean(def.active),
7307
- });
8108
+ const renderButton = (def) => {
8109
+ const button = React.createElement(IconButton, {
8110
+ key: def.badge ? undefined : def.key,
8111
+ icon: def.icon,
8112
+ label: def.label,
8113
+ onClick: def.onClick,
8114
+ disabled: Boolean(def.disabled),
8115
+ active: Boolean(def.active),
8116
+ });
8117
+ // Defs with corner markers wrap in the positioning span: yellow
8118
+ // top-right = unseen changes, green bottom-left = canvas has content.
8119
+ return (def.badge || def.loadedDot)
8120
+ ? React.createElement("span", { key: def.key, className: "ps-canvas-toggle" },
8121
+ button,
8122
+ def.loadedDot ? React.createElement("span", { className: "ps-canvas-loaded-dot", "aria-hidden": "true" }) : null,
8123
+ def.badge ? React.createElement("span", { className: "ps-canvas-badge", "aria-hidden": "true" }) : null)
8124
+ : button;
8125
+ };
7308
8126
 
7309
8127
  if (mobile) {
7310
- // Single line: icon buttons are compact enough to fit one row; the
7311
- // ps-toolbar-row-actions container scrolls horizontally (hidden
7312
- // scrollbar) on the narrowest screens instead of wrapping.
8128
+ // One row, two groups: actions left, the three VIEW MODES right —
8129
+ // they used to be word tabs pinned to the bottom of the screen, and
8130
+ // folding them up here hands that whole strip back to the chat pane.
8131
+ // The Main button is a CYCLE once you are already on Main: split →
8132
+ // chat only → sessions only. ONE glyph for all three states (a
8133
+ // layout/panes mark) — a shape-shifting icon reads as three different
8134
+ // buttons; only the tooltip names what the next tap gives you.
8135
+ const mainLabels = {
8136
+ split: "Main — sessions and chat (tap for chat only)",
8137
+ chat: "Main — chat only (tap for sessions only)",
8138
+ sessions: "Main — sessions only (tap for both)",
8139
+ };
8140
+ // Two tabs, not three: the row was out of space, and Inspector +
8141
+ // Activity are one idea on the desktop already (Diagnostics, the
8142
+ // bug). The bug button opens the inspector; tapping it again while
8143
+ // inside diagnostics flips inspector <-> activity; the main button is
8144
+ // always the way back.
8145
+ const onDiagnostics = mobilePane === "inspector" || mobilePane === "activity";
8146
+ const nextDiagnosticsPane = mobilePane === "inspector" ? "activity" : "inspector";
8147
+ const paneDefs = [
8148
+ { id: "workspace", icon: MainLayoutGlyph, label: mainLabels[mobileMainLayout] || mainLabels.split, focus: "chat" },
8149
+ {
8150
+ id: nextDiagnosticsPane,
8151
+ icon: DiagnosticsGlyph,
8152
+ active: onDiagnostics,
8153
+ label: onDiagnostics
8154
+ ? (mobilePane === "inspector" ? "Diagnostics — inspector (tap for activity)" : "Diagnostics — activity (tap for inspector)")
8155
+ : "Diagnostics — inspector and activity",
8156
+ focus: nextDiagnosticsPane,
8157
+ },
8158
+ ];
7313
8159
  return React.createElement("div", { className: "ps-toolbar is-mobile" },
7314
8160
  React.createElement("div", { className: "ps-toolbar-row ps-toolbar-row-primary" },
7315
- React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton))),
8161
+ React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton)),
8162
+ React.createElement("div", { className: "ps-toolbar-row-actions is-panes" },
8163
+ paneDefs.map((def) => React.createElement(IconButton, {
8164
+ key: def.id === "workspace" ? "workspace" : "diagnostics",
8165
+ icon: React.createElement(def.icon),
8166
+ label: def.label,
8167
+ active: def.active ?? (mobilePane === def.id),
8168
+ onClick: () => {
8169
+ if (onSelectMobilePane) onSelectMobilePane(def.id, def.focus);
8170
+ },
8171
+ })))),
7316
8172
  );
7317
8173
  }
7318
8174
 
8175
+ // Two groups. Diagnostics and the admin console are TOOLS — they act on
8176
+ // the app rather than on the conversation — so they sit apart from the
8177
+ // session controls, hard right. Everything else keeps its order.
8178
+ const RIGHT_GROUP = new Set(["diagnostics", "admin"]);
8179
+
8180
+ // While the canvas is full screen, ANY other button first drops full
8181
+ // screen and then does its own job. Pressing Filter and watching nothing
8182
+ // happen behind a canvas — or watching a panel open where you cannot see
8183
+ // it — is the confusing half of every full-screen mode. The Canvas toggle
8184
+ // is exempt: it already means "put the canvas away", and closing drops the
8185
+ // flag in the reducer.
8186
+ const withRestore = (def) => (!canvasMaximized || def.key === "canvas" ? def : {
8187
+ ...def,
8188
+ onClick: (...args) => {
8189
+ controller.dispatch({ type: "ui/canvasMaximized", on: false });
8190
+ return def.onClick?.(...args);
8191
+ },
8192
+ });
8193
+
8194
+ const leftButtons = buttonDefs.filter((d) => !RIGHT_GROUP.has(d.key)).map(withRestore);
8195
+ const rightButtons = buttonDefs.filter((d) => RIGHT_GROUP.has(d.key)).map(withRestore);
7319
8196
  const toolbar = React.createElement("div", { className: "ps-toolbar" },
7320
- React.createElement("div", { className: "ps-toolbar-actions" }, buttonDefs.map(renderButton)),
8197
+ // Three columns: spacer | main controls | right rail. The main group
8198
+ // sits in the CENTER column, so it stays dead-centre in every mode —
8199
+ // an auto margin only centres in leftover space, and the full-screen
8200
+ // strip was shoving it sideways.
8201
+ React.createElement("div", { className: "ps-toolbar-side is-left", "aria-hidden": "true" }),
8202
+ React.createElement("div", { className: "ps-toolbar-actions" }, leftButtons.map(renderButton)),
8203
+ React.createElement("div", { className: "ps-toolbar-side is-right" },
8204
+ // The portal header parks its version/status meta here, LEFT of
8205
+ // the tool buttons, and its sign-out glyph in the slot after
8206
+ // them — one right-aligned cluster: meta · bug · settings · out.
8207
+ React.createElement("span", { className: "ps-toolbar-meta-slot", id: "ps-toolbar-meta-slot" }),
8208
+ // While the canvas is full screen its header controls (rev, zoom,
8209
+ // zen, restore) portal INTO this slot from CanvasPane — zoom state
8210
+ // lives there, so the controls come to the toolbar rather than
8211
+ // their state moving out.
8212
+ canvasMaximized
8213
+ ? React.createElement("span", { className: "ps-toolbar-canvas-slot", id: "ps-toolbar-canvas-slot" })
8214
+ : null,
8215
+ canvasMaximized
8216
+ ? React.createElement("span", { className: "ps-toolbar-divider", "aria-hidden": "true" })
8217
+ : null,
8218
+ rightButtons.length
8219
+ ? React.createElement("div", { className: "ps-toolbar-actions is-tools" }, rightButtons.map(renderButton))
8220
+ : null,
8221
+ React.createElement("span", { className: "ps-toolbar-signout-slot", id: "ps-toolbar-signout-slot" })),
7321
8222
  );
7322
8223
 
7323
8224
  // Rich desktop UI: the toolbar lives IN the portal header (one top bar)
@@ -7606,128 +8507,302 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
7606
8507
  React.createElement("span", { className: "ps-column-resizer-dot" })));
7607
8508
  }
7608
8509
 
8510
+
7609
8511
  /**
7610
- * How many pixels the inspector/activity seam actually moves per row of adjust.
8512
+ * The seam between the canvas and diagnostics columns.
7611
8513
  *
7612
- * The split is stored in character ROWS, but the column renders them as `fr`
7613
- * — so one row of adjustment moves the seam by (column height / total rows)
7614
- * pixels, whatever that happens to be. Dividing the pointer delta by a fixed
7615
- * GRID_CELL_HEIGHT only tracks if the column is exactly rows × 19px tall, and
7616
- * it is not: the column excludes the header and toolbar above it, and the row
7617
- * height moves with the type scale. So the seam ran ahead of the pointer or
7618
- * trailed behind it, and the error grew with the drag.
8514
+ * Only rendered when both are open — with one column the outer chat/right
8515
+ * handle is already the only split there is.
7619
8516
  *
7620
- * Both operands are fixed for the duration of a drag — the split moves, the
7621
- * total does not — so this is measured once at pointerdown rather than on
7622
- * every pointermove.
8517
+ * Drag either column below PORTAL_COLUMN_SNAP_CLOSED_PX and releasing CLOSES
8518
+ * it: the user asked for "resized to 0 means toggled off, and it has to be
8519
+ * toggled back on". The adjust is reset at the same time, so re-opening from
8520
+ * the toolbar comes back at the even split rather than at the sliver that
8521
+ * closed it.
7623
8522
  */
7624
- function measureActivityRowPx(controller, handleEl) {
7625
- try {
7626
- const layout = controller.getCurrentLayout?.();
7627
- const rows = (layout?.inspectorPaneHeight || 0) + (layout?.activityPaneHeight || 0);
7628
- const column = handleEl?.closest?.(".ps-workspace-column");
7629
- if (!column || rows <= 0) return GRID_CELL_HEIGHT;
7630
- // The resizer's own track is not part of either pane's share.
7631
- const usable = column.clientHeight - (handleEl.getBoundingClientRect().height || 0);
7632
- const pxPerRow = usable / rows;
7633
- return Number.isFinite(pxPerRow) && pxPerRow > 0 ? pxPerRow : GRID_CELL_HEIGHT;
7634
- } catch {
7635
- return GRID_CELL_HEIGHT;
7636
- }
7637
- }
7638
-
7639
- function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
8523
+ function CanvasDiagnosticsResizeHandle({ controller, canvasPaneAdjust = 0, diagnosticsPaneAdjust = 0 }) {
7640
8524
  const dragStateRef = React.useRef(null);
7641
8525
  const [dragging, setDragging] = React.useState(false);
7642
8526
 
8527
+ const widths = (ca, da) => ({
8528
+ canvas: PORTAL_CANVAS_COL_DEFAULT_PX + (Number(ca) || 0),
8529
+ diag: PORTAL_DIAG_COL_DEFAULT_PX + (Number(da) || 0),
8530
+ });
8531
+
8532
+ // Moving the seam trades pixels between the two columns; the total — and
8533
+ // therefore chat — does not move. Below the snap on release, the starved
8534
+ // column CLOSES and both widths reset, so reopening is always usable.
8535
+ const commit = (ca, da) => {
8536
+ const w = widths(ca, da);
8537
+ if (w.canvas < PORTAL_COLUMN_SNAP_CLOSED_PX || w.diag < PORTAL_COLUMN_SNAP_CLOSED_PX) {
8538
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: 0 });
8539
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: 0 });
8540
+ controller.dispatch(w.canvas < PORTAL_COLUMN_SNAP_CLOSED_PX
8541
+ ? { type: "ui/canvasOpen", open: false, manual: true }
8542
+ : { type: "ui/diagnosticsOpen", open: false });
8543
+ return;
8544
+ }
8545
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: ca });
8546
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: da });
8547
+ };
8548
+
7643
8549
  React.useEffect(() => {
7644
8550
  if (!dragging) return undefined;
8551
+ const onMove = (event) => {
8552
+ const st = dragStateRef.current;
8553
+ if (!st) return;
8554
+ const dx = event.clientX - st.startX;
8555
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: st.startCanvas + dx });
8556
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: st.startDiag - dx });
8557
+ };
8558
+ const onUp = () => {
8559
+ const st = dragStateRef.current;
8560
+ dragStateRef.current = null;
8561
+ setDragging(false);
8562
+ document.body.classList.remove("is-resizing-pane-x");
8563
+ if (st) {
8564
+ const ui = controller.getState()?.ui?.layout || {};
8565
+ commit(ui.canvasPaneAdjust ?? 0, ui.diagnosticsPaneAdjust ?? 0);
8566
+ }
8567
+ };
8568
+ window.addEventListener("pointermove", onMove);
8569
+ window.addEventListener("pointerup", onUp);
8570
+ window.addEventListener("pointercancel", onUp);
8571
+ return () => {
8572
+ window.removeEventListener("pointermove", onMove);
8573
+ window.removeEventListener("pointerup", onUp);
8574
+ window.removeEventListener("pointercancel", onUp);
8575
+ };
8576
+ }, [controller, dragging]);
7645
8577
 
7646
- const stopDragging = () => {
8578
+ return React.createElement("button", {
8579
+ type: "button",
8580
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8581
+ title: "Drag to trade width between the canvas and diagnostics. Drag one away to close it. Double-click to reset.",
8582
+ "aria-label": "Resize canvas and diagnostics columns",
8583
+ onPointerDown: (event) => {
8584
+ if (event.button !== 0) return;
8585
+ event.preventDefault();
8586
+ capturePointerForDrag(event);
8587
+ dragStateRef.current = {
8588
+ startX: event.clientX,
8589
+ startCanvas: Number(canvasPaneAdjust) || 0,
8590
+ startDiag: Number(diagnosticsPaneAdjust) || 0,
8591
+ };
8592
+ setDragging(true);
8593
+ document.body.classList.add("is-resizing-pane-x");
8594
+ },
8595
+ onDoubleClick: () => {
8596
+ controller.dispatch({ type: "ui/canvasPaneAdjust", canvasPaneAdjust: 0 });
8597
+ controller.dispatch({ type: "ui/diagnosticsPaneAdjust", diagnosticsPaneAdjust: 0 });
8598
+ },
8599
+ onKeyDown: (event) => {
8600
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8601
+ event.preventDefault();
8602
+ const dx = event.key === "ArrowRight" ? 24 : -24;
8603
+ commit((Number(canvasPaneAdjust) || 0) + dx, (Number(diagnosticsPaneAdjust) || 0) - dx);
8604
+ },
8605
+ },
8606
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8607
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8608
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8609
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8610
+ }
8611
+
8612
+ /** The zen seam: drags the chat rail, remembered per session. */
8613
+ function ZenRailResizeHandle({ controller, sessionId, railPx = 380 }) {
8614
+ const dragStateRef = React.useRef(null);
8615
+ const [dragging, setDragging] = React.useState(false);
8616
+ React.useEffect(() => {
8617
+ if (!dragging) return undefined;
8618
+ const onMove = (event) => {
8619
+ const st = dragStateRef.current;
8620
+ if (!st) return;
8621
+ controller.dispatch({ type: "canvas/zenRail", sessionId, px: st.start + (event.clientX - st.startX) });
8622
+ };
8623
+ const onUp = () => {
7647
8624
  dragStateRef.current = null;
7648
8625
  setDragging(false);
7649
- document.body.classList.remove("is-resizing-pane-y");
8626
+ document.body.classList.remove("is-resizing-pane-x");
8627
+ };
8628
+ window.addEventListener("pointermove", onMove);
8629
+ window.addEventListener("pointerup", onUp);
8630
+ window.addEventListener("pointercancel", onUp);
8631
+ return () => {
8632
+ window.removeEventListener("pointermove", onMove);
8633
+ window.removeEventListener("pointerup", onUp);
8634
+ window.removeEventListener("pointercancel", onUp);
7650
8635
  };
8636
+ }, [controller, dragging, sessionId]);
8637
+ return React.createElement("button", {
8638
+ type: "button",
8639
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8640
+ title: "Drag to resize chat against the canvas (remembered for this session)",
8641
+ "aria-label": "Resize the zen chat rail",
8642
+ onPointerDown: (event) => {
8643
+ if (event.button !== 0) return;
8644
+ event.preventDefault();
8645
+ capturePointerForDrag(event);
8646
+ dragStateRef.current = { startX: event.clientX, start: railPx };
8647
+ setDragging(true);
8648
+ document.body.classList.add("is-resizing-pane-x");
8649
+ },
8650
+ onKeyDown: (event) => {
8651
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8652
+ event.preventDefault();
8653
+ controller.dispatch({ type: "canvas/zenRail", sessionId, px: railPx + (event.key === "ArrowRight" ? 24 : -24) });
8654
+ },
8655
+ },
8656
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8657
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8658
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8659
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8660
+ }
7651
8661
 
7652
- const onPointerMove = (event) => {
7653
- const dragState = dragStateRef.current;
7654
- if (!dragState) return;
7655
- const deltaCells = Math.round((event.clientY - dragState.startY) / dragState.pxPerRow);
7656
- const deltaIncrement = deltaCells - dragState.appliedCells;
7657
- if (!deltaIncrement) return;
7658
- controller.adjustActivityPaneSplit(-deltaIncrement);
7659
- dragState.appliedCells = deltaCells;
8662
+ /**
8663
+ * The seam between chat and the right block. Chat flexes, so this drags the
8664
+ * width of the column beside it — canvas when open, else diagnostics. The
8665
+ * other column never moves: growing chat shrinks only its neighbour.
8666
+ */
8667
+ function ChatRightResizeHandle({ controller, target, adjust = 0 }) {
8668
+ const dragStateRef = React.useRef(null);
8669
+ const [dragging, setDragging] = React.useState(false);
8670
+ const actionType = target === "canvas" ? "ui/canvasPaneAdjust" : "ui/diagnosticsPaneAdjust";
8671
+ const key = target === "canvas" ? "canvasPaneAdjust" : "diagnosticsPaneAdjust";
8672
+ const base = target === "canvas" ? PORTAL_CANVAS_COL_DEFAULT_PX : PORTAL_DIAG_COL_DEFAULT_PX;
8673
+
8674
+ const commit = (next) => {
8675
+ if (base + next < PORTAL_COLUMN_SNAP_CLOSED_PX) {
8676
+ controller.dispatch({ type: actionType, [key]: 0 });
8677
+ controller.dispatch(target === "canvas"
8678
+ ? { type: "ui/canvasOpen", open: false, manual: true }
8679
+ : { type: "ui/diagnosticsOpen", open: false });
8680
+ return;
8681
+ }
8682
+ controller.dispatch({ type: actionType, [key]: next });
8683
+ };
8684
+
8685
+ React.useEffect(() => {
8686
+ if (!dragging) return undefined;
8687
+ const onMove = (event) => {
8688
+ const st = dragStateRef.current;
8689
+ if (!st) return;
8690
+ // Pointer right = chat grows = the neighbour gives up the pixels.
8691
+ controller.dispatch({ type: actionType, [key]: st.startAdjust - (event.clientX - st.startX) });
8692
+ };
8693
+ const onUp = () => {
8694
+ const st = dragStateRef.current;
8695
+ dragStateRef.current = null;
8696
+ setDragging(false);
8697
+ document.body.classList.remove("is-resizing-pane-x");
8698
+ if (st) commit((controller.getState()?.ui?.layout || {})[key] ?? 0);
7660
8699
  };
8700
+ window.addEventListener("pointermove", onMove);
8701
+ window.addEventListener("pointerup", onUp);
8702
+ window.addEventListener("pointercancel", onUp);
8703
+ return () => {
8704
+ window.removeEventListener("pointermove", onMove);
8705
+ window.removeEventListener("pointerup", onUp);
8706
+ window.removeEventListener("pointercancel", onUp);
8707
+ };
8708
+ }, [controller, dragging, actionType, key]);
7661
8709
 
7662
- window.addEventListener("pointermove", onPointerMove);
7663
- window.addEventListener("pointerup", stopDragging);
7664
- window.addEventListener("pointercancel", stopDragging);
8710
+ return React.createElement("button", {
8711
+ type: "button",
8712
+ className: `ps-column-resizer${dragging ? " is-dragging" : ""}`,
8713
+ title: "Drag to resize. Drag the column away to close it. Double-click to reset.",
8714
+ "aria-label": "Resize the right column",
8715
+ onPointerDown: (event) => {
8716
+ if (event.button !== 0) return;
8717
+ event.preventDefault();
8718
+ capturePointerForDrag(event);
8719
+ dragStateRef.current = { startX: event.clientX, startAdjust: Number(adjust) || 0 };
8720
+ setDragging(true);
8721
+ document.body.classList.add("is-resizing-pane-x");
8722
+ },
8723
+ onDoubleClick: () => controller.dispatch({ type: actionType, [key]: 0 }),
8724
+ onKeyDown: (event) => {
8725
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
8726
+ event.preventDefault();
8727
+ commit((Number(adjust) || 0) - (event.key === "ArrowRight" ? 24 : -24));
8728
+ },
8729
+ },
8730
+ React.createElement("span", { className: "ps-column-resizer-handle", "aria-hidden": "true" },
8731
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8732
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8733
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
8734
+ }
7665
8735
 
8736
+ /**
8737
+ * The inspector/activity seam, in continuous pixels — it follows the pointer
8738
+ * 1:1. Replaces the row-quantized ActivityRowResizeHandle for the portal:
8739
+ * that one converted drags to whole terminal rows (~20px notches) against
8740
+ * geometry computed from the whole viewport rather than this column, and the
8741
+ * legacy layout could snap-collapse a pane mid-drag. The grid's minmax does
8742
+ * all the clamping now.
8743
+ */
8744
+ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
8745
+ const dragStateRef = React.useRef(null);
8746
+ const [dragging, setDragging] = React.useState(false);
8747
+ React.useEffect(() => {
8748
+ if (!dragging) return undefined;
8749
+ const onMove = (event) => {
8750
+ const st = dragStateRef.current;
8751
+ if (!st) return;
8752
+ // Clamped to the half-height each side can actually give. Without
8753
+ // this the stored value keeps counting past the edge while the
8754
+ // bar stops — and the pointer leads the seam by the overshoot on
8755
+ // the way back.
8756
+ const next = Math.max(-st.half, Math.min(st.half, st.start + (event.clientY - st.startY)));
8757
+ controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: next });
8758
+ };
8759
+ const onUp = () => {
8760
+ dragStateRef.current = null;
8761
+ setDragging(false);
8762
+ document.body.classList.remove("is-resizing-pane-y");
8763
+ };
8764
+ window.addEventListener("pointermove", onMove);
8765
+ window.addEventListener("pointerup", onUp);
8766
+ window.addEventListener("pointercancel", onUp);
7666
8767
  return () => {
7667
- window.removeEventListener("pointermove", onPointerMove);
7668
- window.removeEventListener("pointerup", stopDragging);
7669
- window.removeEventListener("pointercancel", stopDragging);
8768
+ window.removeEventListener("pointermove", onMove);
8769
+ window.removeEventListener("pointerup", onUp);
8770
+ window.removeEventListener("pointercancel", onUp);
7670
8771
  document.body.classList.remove("is-resizing-pane-y");
7671
8772
  };
7672
8773
  }, [controller, dragging]);
7673
-
7674
8774
  return React.createElement("button", {
7675
8775
  type: "button",
7676
8776
  className: `ps-row-resizer${dragging ? " is-dragging" : ""}`,
7677
- title: "Drag to resize the activity pane. Double-click to reset.",
7678
- "aria-label": "Resize activity pane",
8777
+ title: "Drag to resize the inspector and activity panes. Double-click to reset.",
8778
+ "aria-label": "Resize the inspector and activity panes",
7679
8779
  onPointerDown: (event) => {
7680
8780
  if (event.button !== 0) return;
7681
8781
  event.preventDefault();
7682
8782
  capturePointerForDrag(event);
7683
- dragStateRef.current = {
7684
- startY: event.clientY,
7685
- appliedCells: 0,
7686
- // MEASURED, not assumed — see measureActivityRowPx.
7687
- pxPerRow: measureActivityRowPx(controller, event.currentTarget),
7688
- };
8783
+ const col = event.currentTarget.closest(".ps-workspace-column");
8784
+ const half = Math.max(0, ((col?.getBoundingClientRect?.().height || 0) - 16) / 2);
8785
+ dragStateRef.current = { startY: event.clientY, start: Number(splitAdjust) || 0, half };
7689
8786
  setDragging(true);
7690
8787
  document.body.classList.add("is-resizing-pane-y");
7691
8788
  },
7692
- onDoubleClick: () => {
7693
- if (!activityPaneAdjust) return;
7694
- controller.adjustActivityPaneSplit(-activityPaneAdjust);
7695
- },
8789
+ onDoubleClick: () => controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: 0 }),
7696
8790
  onKeyDown: (event) => {
7697
- if (event.key === "ArrowUp") {
7698
- event.preventDefault();
7699
- controller.adjustActivityPaneSplit(1);
7700
- return;
7701
- }
7702
- if (event.key === "ArrowDown") {
7703
- event.preventDefault();
7704
- controller.adjustActivityPaneSplit(-1);
7705
- }
8791
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
8792
+ event.preventDefault();
8793
+ controller.dispatch({
8794
+ type: "ui/diagnosticsSplitAdjust",
8795
+ diagnosticsSplitAdjust: (Number(splitAdjust) || 0) + (event.key === "ArrowDown" ? 16 : -16),
8796
+ });
7706
8797
  },
7707
8798
  },
7708
8799
  React.createElement("span", { className: "ps-row-resizer-handle", "aria-hidden": "true" },
7709
- React.createElement("span", { className: "ps-row-resizer-dot" }),
7710
- React.createElement("span", { className: "ps-row-resizer-dot" }),
7711
- React.createElement("span", { className: "ps-row-resizer-dot" })));
8800
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8801
+ React.createElement("span", { className: "ps-column-resizer-dot" }),
8802
+ React.createElement("span", { className: "ps-column-resizer-dot" })));
7712
8803
  }
7713
8804
 
7714
- function MobileNav({ activePane, setActivePane, controller }) {
7715
- const tabs = [
7716
- { id: "workspace", label: "Main", focus: "chat" },
7717
- { id: "inspector", label: "Inspector", focus: "inspector" },
7718
- { id: "activity", label: "Activity", focus: "activity" },
7719
- ];
7720
- return React.createElement("div", { className: "ps-mobile-nav" },
7721
- tabs.map((tab) => React.createElement("button", {
7722
- key: tab.id,
7723
- type: "button",
7724
- className: `ps-mobile-nav-button${activePane === tab.id ? " is-active" : ""}`,
7725
- onClick: () => {
7726
- setActivePane(tab.id);
7727
- controller.setFocus(tab.focus);
7728
- },
7729
- }, tab.label)));
7730
- }
8805
+
7731
8806
 
7732
8807
  function ModalLayer({ controller }) {
7733
8808
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
@@ -8149,7 +9224,7 @@ function ModalLayer({ controller }) {
8149
9224
  type: "button",
8150
9225
  className: "ps-modal-button is-primary",
8151
9226
  onClick: () => controller.handleCommand(UI_COMMANDS.MODAL_CONFIRM).catch(() => {}),
8152
- }, "Create and Move")),
9227
+ }, modalState.sessionGroupName.mode === "rename" ? "Rename" : "Create and Move")),
8153
9228
  ));
8154
9229
  }
8155
9230
  if (modal.type === "terminatePicker") {
@@ -8438,11 +9513,6 @@ function useKeyboardShortcuts(controller, mobile) {
8438
9513
  controller.handleCommand(UI_COMMANDS.REFRESH).catch(() => {});
8439
9514
  return;
8440
9515
  }
8441
- if (focusRegion === "chat" && event.key === "s" && isPlainShortcut) {
8442
- event.preventDefault();
8443
- controller.handleCommand(UI_COMMANDS.TOGGLE_CHAT_VIEW).catch(() => {});
8444
- return;
8445
- }
8446
9516
  if (event.key === "n" && isPlainShortcut) {
8447
9517
  event.preventDefault();
8448
9518
  controller.handleCommand(UI_COMMANDS.NEW_SESSION).catch(() => {});
@@ -8769,7 +9839,13 @@ function AdminConsolePanel({ controller, mobile = false }) {
8769
9839
  const header = React.createElement("header", { className: "ps-admin-console__header" },
8770
9840
  React.createElement("h2", null, "Admin Console"),
8771
9841
  React.createElement("span", { className: "ps-admin-console__who" }, principalLabel),
8772
- React.createElement("button", { type: "button", className: "ps-mini-button", onClick: onClose }, "Close"));
9842
+ React.createElement("button", {
9843
+ type: "button",
9844
+ className: "ps-mini-button is-icon",
9845
+ onClick: onClose,
9846
+ title: "Close the admin console",
9847
+ "aria-label": "Close the admin console",
9848
+ }, "\u2715"));
8773
9849
 
8774
9850
  const ghcpSection = React.createElement(AdminGhcpSection, {
8775
9851
  view, draftRef, onBeginEdit, onCancelEdit, onClear, onSubmit, onDraftChange, onRefresh, controller,
@@ -8856,10 +9932,15 @@ function AdminWorkersPane({ controller, view }) {
8856
9932
  `${workers.summaryText}${counts.pools > 1 ? ` · ${counts.pools} pools` : ""}${counts.draining ? ` · ${counts.draining} draining` : ""}`)
8857
9933
  : null,
8858
9934
  React.createElement("button", {
8859
- type: "button", className: "ps-mini-button",
8860
- disabled: Boolean(workers.loading),
9935
+ type: "button",
9936
+ className: "ps-mini-button is-icon",
8861
9937
  onClick: () => controller.refreshAdminWorkers(),
8862
- }, workers.loading ? "Refreshing…" : "Refresh")),
9938
+ disabled: Boolean(workers.loading),
9939
+ title: workers.loading ? "Refreshing…" : "Refresh the worker list",
9940
+ "aria-label": "Refresh the worker list",
9941
+ }, React.createElement("svg", { viewBox: "0 0 20 20", width: 15, height: 15, fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
9942
+ React.createElement("path", { d: "M16.2 8.2A6.4 6.4 0 0 0 4.6 6.4M3.8 11.8a6.4 6.4 0 0 0 11.6 1.8" }),
9943
+ React.createElement("path", { d: "M16.4 3.6v4.6h-4.6M3.6 16.4v-4.6h4.6" })))),
8863
9944
  workers.error
8864
9945
  ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, workers.error)
8865
9946
  : null,
@@ -9410,7 +10491,6 @@ export function createWebPilotSwarmController({ transport, mode = "remote", bran
9410
10491
  mode,
9411
10492
  branding,
9412
10493
  docs,
9413
- chatViewMode: isNarrowViewport ? "transcript" : "rich",
9414
10494
  }));
9415
10495
  return new PilotSwarmUiController({ store, transport });
9416
10496
  }
@@ -9429,21 +10509,42 @@ export function PilotSwarmWebApp({ controller }) {
9429
10509
  // device — phones kept getting desktop splash art narrower than ~76 cols.
9430
10510
  React.useEffect(() => {
9431
10511
  const publish = () => {
9432
- const probe = document.createElement("span");
9433
- probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
9434
- // Measure with the font real lines render in — an off-context
9435
- // probe can inherit a different size and skew the column count.
9436
- const sample = document.querySelector(".ps-scroll-panel .ps-line") || document.querySelector(".ps-line");
9437
- if (sample) probe.style.font = window.getComputedStyle(sample).font;
9438
- probe.textContent = "0".repeat(100);
9439
- (sample?.parentElement || document.body).appendChild(probe);
9440
- const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
9441
- probe.remove();
9442
- controller.dispatch({
9443
- type: "ui/viewport",
9444
- width: Math.floor(window.innerWidth / charWidth),
9445
- height: Math.floor(window.innerHeight / SCROLL_ROW_HEIGHT),
9446
- });
10512
+ try {
10513
+ const probe = document.createElement("span");
10514
+ probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
10515
+ // Measure with the font real lines render in — an off-context
10516
+ // probe can inherit a different size and skew the column count.
10517
+ // Longhands, not the `font` shorthand: Safari serializes the
10518
+ // shorthand as "" whenever the longhands do not reduce to a
10519
+ // canonical form, which silently reverted the probe to its
10520
+ // parent's font.
10521
+ const sample = document.querySelector(".ps-scroll-panel .ps-line") || document.querySelector(".ps-line");
10522
+ if (sample) {
10523
+ const cs = window.getComputedStyle(sample);
10524
+ probe.style.fontFamily = cs.fontFamily;
10525
+ probe.style.fontSize = cs.fontSize;
10526
+ probe.style.fontWeight = cs.fontWeight;
10527
+ probe.style.letterSpacing = cs.letterSpacing;
10528
+ }
10529
+ probe.textContent = "0".repeat(100);
10530
+ (sample?.parentElement || document.body).appendChild(probe);
10531
+ const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
10532
+ probe.remove();
10533
+ // Layout viewport, NOT window.innerWidth: on iOS innerWidth tracks
10534
+ // the VISUAL viewport, so a probe taken while pinch-zoomed (or with
10535
+ // the keyboard up) publishes a wildly wrong column count and the
10536
+ // sequence grid is built for a viewport that doesn't exist.
10537
+ const layoutWidth = document.documentElement.clientWidth || window.innerWidth;
10538
+ const layoutHeight = document.documentElement.clientHeight || window.innerHeight;
10539
+ controller.dispatch({
10540
+ type: "ui/viewport",
10541
+ width: Math.floor(layoutWidth / charWidth),
10542
+ height: Math.floor(layoutHeight / SCROLL_ROW_HEIGHT),
10543
+ });
10544
+ } catch {
10545
+ // A probe crash keeps the last published (or fallback) size;
10546
+ // the pane-clamp below self-corrects the inspector regardless.
10547
+ }
9447
10548
  };
9448
10549
  publish();
9449
10550
  let timer;
@@ -9456,8 +10557,9 @@ export function PilotSwarmWebApp({ controller }) {
9456
10557
  window.removeEventListener("orientationchange", onResize);
9457
10558
  };
9458
10559
  }, [controller]);
9459
- const [chatFocusMode, setChatFocusMode] = React.useState(false);
9460
- const [chatFocusPane, setChatFocusPane] = React.useState(null);
10560
+ // The phone's Main layout: split | chat | sessions (cycled by the Main
10561
+ // toolbar button). Desktop has real columns and needs no such cycle.
10562
+ const [mobileMainLayout, setMobileMainLayout] = React.useState("split");
9461
10563
  const state = useControllerSelector(controller, (rootState) => ({
9462
10564
  themeId: rootState.ui.themeId,
9463
10565
  touchScale: Boolean(rootState.ui.touchScale),
@@ -9473,12 +10575,23 @@ export function PilotSwarmWebApp({ controller }) {
9473
10575
  collapsedSessionIds: rootState.sessions.collapsedIds,
9474
10576
  activeSessionId: rootState.sessions.activeSessionId || null,
9475
10577
  promptRows: getStatePromptRows(rootState),
9476
- chatViewMode: rootState.ui.chatViewMode || "transcript",
10578
+ rightPaneMode: rootState.ui.rightPaneMode || "panes",
10579
+ // The two optional desktop columns, independent of each other.
10580
+ canvasOpen: rootState.ui.canvasOpen === true,
10581
+ diagnosticsOpen: rootState.ui.diagnosticsOpen === true,
10582
+ canvasMaximized: rootState.ui.canvasMaximized === true,
10583
+ canvasZen: rootState.ui.canvasZen === true,
10584
+ // Live reference — reducer updates replace the object, so
10585
+ // shallow-equal sees changes and the save effect fires.
10586
+ canvasPrefs: rootState.canvas?.prefs,
9477
10587
  activeSessionIsGroup: Boolean(rootState.sessions.activeSessionId && rootState.sessions.byId[rootState.sessions.activeSessionId]?.isGroup),
9478
10588
  paneAdjust: rootState.ui.layout?.paneAdjust ?? 0,
9479
10589
  sessionPaneAdjust: rootState.ui.layout?.sessionPaneAdjust ?? 0,
9480
10590
  portalSessionColumnAdjust: rootState.ui.layout?.portalSessionColumnAdjust ?? 0,
9481
10591
  activityPaneAdjust: rootState.ui.layout?.activityPaneAdjust ?? 0,
10592
+ canvasPaneAdjust: rootState.ui.layout?.canvasPaneAdjust ?? 0,
10593
+ diagnosticsPaneAdjust: rootState.ui.layout?.diagnosticsPaneAdjust ?? 0,
10594
+ diagnosticsSplitAdjust: rootState.ui.layout?.diagnosticsSplitAdjust ?? 0,
9482
10595
  focusRegion: rootState.ui.focusRegion,
9483
10596
  inspectorTab: rootState.ui.inspectorTab,
9484
10597
  filesFullscreen: Boolean(rootState.files.fullscreen),
@@ -9491,8 +10604,13 @@ export function PilotSwarmWebApp({ controller }) {
9491
10604
  // Last-known value of the OTHER device class's chat-view slot. The save
9492
10605
  // endpoint replaces the whole settings object, so this is what keeps a
9493
10606
  // desktop save from erasing the phone's preference.
9494
- const otherChatViewModeRef = React.useRef(null);
9495
10607
  const otherTouchScaleRef = React.useRef(null);
10608
+ // Last-known stored rightPaneMode — the desktop-owned slot a phone must
10609
+ // carry through its saves untouched (see profileSettingsFromViewState).
10610
+ const desktopRightPaneModeRef = React.useRef(null);
10611
+ // Same preservation contract as desktopRightPaneModeRef: a phone must not
10612
+ // overwrite the desktop's column toggles with its own (absent) ones.
10613
+ const desktopPanesRef = React.useRef(null);
9496
10614
  const profileSettingsSaveTimerRef = React.useRef(null);
9497
10615
  const profileSettingsPollTimerRef = React.useRef(null);
9498
10616
  const profileSettingsPollInFlightRef = React.useRef(false);
@@ -9501,14 +10619,72 @@ export function PilotSwarmWebApp({ controller }) {
9501
10619
  const defaultProfileSettingsRef = React.useRef(null);
9502
10620
  const [mobilePane, setMobilePane] = React.useState("workspace");
9503
10621
  const mobile = (viewport.width || window.innerWidth || 0) < MOBILE_BREAKPOINT;
9504
- const readOnlyChatPane = state.activeSessionIsGroup || state.chatViewMode === "summary";
10622
+ const readOnlyChatPane = state.activeSessionIsGroup;
9505
10623
  const effectivePromptRows = readOnlyChatPane ? 0 : state.promptRows;
9506
10624
 
9507
10625
  useKeyboardShortcuts(controller, mobile);
9508
10626
 
10627
+ // The agent-driven canvas flip, phone edition. The desktop column follows
10628
+ // ui.rightPaneMode by itself; the phone's pane lives in local tab state,
10629
+ // so it follows the flip TICK instead (which fires even when the mode was
10630
+ // already "canvas" — e.g. a persisted mode with the user on Main).
10631
+ const canvasShellView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
10632
+ // "canvas" is a fourth value of mobilePane, alongside workspace, activity
10633
+ // and inspector — the canvas is one more thing that can be showing in the
10634
+ // content region, not a screen layered over the app.
10635
+ const mobileCanvasOpen = mobilePane === "canvas";
10636
+ // Which pane the canvas was opened FROM. Toggling it off puts that back —
10637
+ // chat, activity or inspector — instead of dropping the user on Main.
10638
+ const paneBeforeCanvasRef = React.useRef("workspace");
10639
+ // Once opened, the canvas stays MOUNTED (hidden) for the rest of the
10640
+ // session selection — page state (typed input, scroll, game progress)
10641
+ // survives toggling. A session switch remounts fresh via the key below.
10642
+ const [mobileCanvasEverOpened, setMobileCanvasEverOpened] = React.useState(false);
10643
+ const [desktopCanvasEverOpened, setDesktopCanvasEverOpened] = React.useState(false);
10644
+ React.useEffect(() => {
10645
+ setMobilePane((current) => (current === "canvas" ? (paneBeforeCanvasRef.current || "workspace") : current));
10646
+ setMobileCanvasEverOpened(false);
10647
+ setDesktopCanvasEverOpened(false);
10648
+ }, [canvasShellView.sessionId]);
10649
+ // iOS scroll-chaining: while the canvas is up, a pan that Safari decides
10650
+ // is not the iframe's must not scroll the app shell behind it.
9509
10651
  React.useEffect(() => {
9510
- controller.setViewport(gridViewport);
9511
- }, [controller, gridViewport.height, gridViewport.width]);
10652
+ if (typeof document === "undefined") return undefined;
10653
+ document.body.classList.toggle("ps-canvas-lock", Boolean(mobile && mobileCanvasOpen));
10654
+ return () => document.body.classList.remove("ps-canvas-lock");
10655
+ }, [mobile, mobileCanvasOpen]);
10656
+ const openMobileCanvas = React.useCallback(() => {
10657
+ if (mobilePane !== "canvas") paneBeforeCanvasRef.current = mobilePane;
10658
+ setMobilePane("canvas");
10659
+ setMobileCanvasEverOpened(true);
10660
+ // Entering by hand un-opts-out, same as the desktop toggle.
10661
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasShellView.sessionId });
10662
+ }, [controller, mobilePane, canvasShellView.sessionId]);
10663
+ const closeMobileCanvas = React.useCallback(() => {
10664
+ setMobilePane(paneBeforeCanvasRef.current || "workspace");
10665
+ // Mirror the desktop ✕ exactly: a deliberate close is the opt-out
10666
+ // gesture — later draws badge the toggle instead of re-covering the
10667
+ // transcript, and reopening clears the opt-out.
10668
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: canvasShellView.sessionId, manual: true });
10669
+ }, [controller, canvasShellView.sessionId]);
10670
+ const canvasFlipSeqRef = React.useRef(canvasShellView.flipSeq);
10671
+ React.useEffect(() => {
10672
+ if (canvasShellView.flipSeq === canvasFlipSeqRef.current) return;
10673
+ canvasFlipSeqRef.current = canvasShellView.flipSeq;
10674
+ if (mobile) openMobileCanvas();
10675
+ }, [canvasShellView.flipSeq, mobile, openMobileCanvas]);
10676
+ const toggleMobileCanvas = React.useCallback(() => {
10677
+ if (mobileCanvasOpen) { closeMobileCanvas(); return; }
10678
+ openMobileCanvas();
10679
+ }, [mobileCanvasOpen, closeMobileCanvas, openMobileCanvas]);
10680
+
10681
+ // gridViewport used to be PUBLISHED here via controller.setViewport — a
10682
+ // second writer to ui.layout that derived columns from the hard-coded
10683
+ // GRID_CELL_WIDTH instead of the rendered font. The two publishers fought
10684
+ // (last writer wins), which is how a phone ended up laying out its
10685
+ // inspector for a column count no probe ever measured. The font-probe
10686
+ // effect is the only publisher now; gridViewport stays local to the
10687
+ // desktop pane splitter below.
9512
10688
 
9513
10689
  React.useEffect(() => {
9514
10690
  let active = true;
@@ -9547,22 +10723,46 @@ export function PilotSwarmWebApp({ controller }) {
9547
10723
  // so the "no persisted filter" fallback becomes the correct
9548
10724
  // principal-scoped default (Me+System) instead of {all:true}.
9549
10725
  const remoteNormalized = normalizeProfileSettings(profile?.profileSettings);
9550
- if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
9551
- otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
9552
- }
9553
10726
  if (typeof remoteNormalized[otherTouchScaleKey()] === "boolean") {
9554
10727
  otherTouchScaleRef.current = remoteNormalized[otherTouchScaleKey()];
9555
10728
  }
10729
+ if (remoteNormalized.rightPaneMode === "canvas" || remoteNormalized.rightPaneMode === "panes") {
10730
+ desktopRightPaneModeRef.current = remoteNormalized.rightPaneMode;
10731
+ }
10732
+ // Its own check: a profile written by this build carries
10733
+ // desktopPanes and need not carry the legacy enum at all.
10734
+ if (remoteNormalized.desktopPanes) {
10735
+ desktopPanesRef.current = remoteNormalized.desktopPanes;
10736
+ }
9556
10737
  defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
9557
- controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
10738
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
9558
10739
  );
10740
+ // First visit on a desktop wide enough to hold it: open the
10741
+ // canvas. A workspace that opens as chat alone hides the thing
10742
+ // most sessions are FOR, and the canvas needs real width to be
10743
+ // worth showing — below the breakpoint chat alone is better.
10744
+ //
10745
+ // Folded into the settings rather than dispatched separately.
10746
+ // A separate dispatch raced the re-baseline at the end of this
10747
+ // same poll: the new value was recorded as "already saved",
10748
+ // never written, and the next poll put it back. Going through
10749
+ // the settings means the normal apply-and-save path carries it.
10750
+ //
10751
+ // Only when NOTHING is stored. An explicit choice, including
10752
+ // closing the canvas, is a preference from then on. Not
10753
+ // live-responsive either: narrowing the window never closes a
10754
+ // canvas the user opened on purpose.
10755
+ const wantsDefaultCanvas = !hasStoredDesktopPanes(remoteNormalized)
10756
+ && !isNarrowViewport()
10757
+ && window.innerWidth >= PORTAL_CANVAS_DEFAULT_MIN_VIEWPORT_PX;
9559
10758
  const settings = materializeProfileSettings(
9560
10759
  profile?.profileSettings,
9561
10760
  defaultProfileSettingsRef.current,
9562
10761
  );
10762
+ if (wantsDefaultCanvas) settings.desktopPanes = { canvasOpen: true, diagnosticsOpen: false };
9563
10763
  const settingsJson = JSON.stringify(settings);
9564
10764
  const currentSettingsBeforeApply = profileSettingsFromViewState(
9565
- controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
10765
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
9566
10766
  );
9567
10767
  const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
9568
10768
  const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
@@ -9576,6 +10776,22 @@ export function PilotSwarmWebApp({ controller }) {
9576
10776
  appliedProfileSettingsJsonRef.current = settingsJson;
9577
10777
  }
9578
10778
 
10779
+ // First run on this account, on a desktop wide enough to hold
10780
+ // it: open the canvas. A workspace that opens as chat alone
10781
+ // hides the thing most sessions are FOR, and the canvas needs
10782
+ // real width to be worth showing — below the breakpoint chat
10783
+ // alone is the better default.
10784
+ //
10785
+ // Once only, and only when nothing is stored. An explicit
10786
+ // choice, including closing it, is a stored preference from
10787
+ // then on and is never second-guessed. Not live-responsive
10788
+ // either: narrowing the window does not close a canvas the
10789
+ // user opened on purpose.
10790
+ // remoteNormalized, NOT `settings`: the merged object always
10791
+ // carries desktopPanes because the defaults are synthesized
10792
+ // from current state, so it can never say "unset" and the
10793
+ // default below would never fire.
10794
+
9579
10795
  // lastProfileSettingsJson is what the SAVE effect diffs against
9580
10796
  // to decide whether anything needs writing. Refreshing it on
9581
10797
  // every poll made the poll adopt UNSAVED local state as if it
@@ -9589,7 +10805,7 @@ export function PilotSwarmWebApp({ controller }) {
9589
10805
  // remote state by definition and there is nothing to lose).
9590
10806
  if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
9591
10807
  const currentSettings = profileSettingsFromViewState(
9592
- controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
10808
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
9593
10809
  );
9594
10810
  lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
9595
10811
  }
@@ -9623,7 +10839,7 @@ export function PilotSwarmWebApp({ controller }) {
9623
10839
 
9624
10840
  React.useEffect(() => {
9625
10841
  if (!profileSettingsHydratedRef.current) return undefined;
9626
- const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current, otherTouchScaleRef.current);
10842
+ const settings = profileSettingsFromViewState(state, otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current);
9627
10843
  const settingsJson = JSON.stringify(settings);
9628
10844
  if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
9629
10845
  lastProfileSettingsJsonRef.current = settingsJson;
@@ -9640,7 +10856,7 @@ export function PilotSwarmWebApp({ controller }) {
9640
10856
  });
9641
10857
  }, 400);
9642
10858
  return undefined;
9643
- }, [controller, state.activeSessionId, state.activityPaneAdjust, state.chatViewMode, state.collapsedSessionIds, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.sessionPaneAdjust, state.themeId, state.touchScale]);
10859
+ }, [controller, state.activeSessionId, state.activityPaneAdjust, state.canvasPrefs, state.collapsedSessionIds, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.rightPaneMode, state.sessionPaneAdjust, state.themeId, state.touchScale]);
9644
10860
 
9645
10861
  React.useEffect(() => {
9646
10862
  applyDocumentTheme(state.themeId);
@@ -9660,12 +10876,13 @@ export function PilotSwarmWebApp({ controller }) {
9660
10876
  lastFocusRef.current = state.focusRegion;
9661
10877
  if (previous === null) return;
9662
10878
  if (previous === state.focusRegion) return;
9663
- if (mobile && state.focusRegion !== "prompt") {
9664
- setMobilePane(state.focusRegion === "activity"
9665
- ? "activity"
9666
- : state.focusRegion === "inspector"
9667
- ? "inspector"
9668
- : "workspace");
10879
+ // Follow focus ONLY into the two panes that are nothing but focus
10880
+ // targets. Mapping chat/sessions/prompt back to Main fought the
10881
+ // toolbar: the sessions-only layout's list reclaims focus, which
10882
+ // bounced the user out of Inspector the instant they tapped it.
10883
+ // Returning to Main is the Main button's job now.
10884
+ if (mobile && (state.focusRegion === "inspector" || state.focusRegion === "activity")) {
10885
+ setMobilePane(state.focusRegion);
9669
10886
  }
9670
10887
  }, [mobile, state.focusRegion]);
9671
10888
 
@@ -9695,6 +10912,71 @@ export function PilotSwarmWebApp({ controller }) {
9695
10912
  && Boolean(state.selectedArtifactId)
9696
10913
  && !filesFullscreenActive;
9697
10914
 
10915
+ // Canvas mode claims the column beneath the reader: the reader still takes
10916
+ // precedence while open, and its ✕ leaves rightPaneMode untouched — so
10917
+ // closing a preview returns to the canvas, restore-what-was-displaced.
10918
+ const canvasModeActive = !mobile
10919
+ && state.canvasOpen
10920
+ && !filesFullscreenActive;
10921
+ // sessionId is a dep ON PURPOSE, not just canvasModeActive. The reset
10922
+ // effect above wipes the gate whenever the session settles or switches —
10923
+ // and on a cold load the settings poll applies canvasOpen BEFORE the
10924
+ // sessions list has loaded, so activeSessionId lands in a LATER commit.
10925
+ // Keyed on canvasModeActive alone this never re-fired (the flag had not
10926
+ // changed), the gate stayed false, and a canvas that was open by stored
10927
+ // preference or by the wide-screen default simply never rendered.
10928
+ // Effects run in declaration order, so within one commit the reset runs
10929
+ // first and this one wins: active canvas => mounted canvas, always.
10930
+ React.useEffect(() => {
10931
+ if (canvasModeActive) setDesktopCanvasEverOpened(true);
10932
+ }, [canvasModeActive, canvasShellView.sessionId]);
10933
+
10934
+ // Diagnostics — Inspector + Activity as one column, beside the canvas
10935
+ // rather than instead of it. The artifact reader still takes the whole
10936
+ // right side while it is open, as it always did.
10937
+ const diagnosticsActive = !mobile
10938
+ && state.diagnosticsOpen
10939
+ && !filesFullscreenActive
10940
+ && !artifactPaneActive
10941
+ // Zen is chat + canvas only; diagnostics keeps its stored toggle and
10942
+ // returns when zen ends.
10943
+ && !(state.canvasZen && state.canvasOpen);
10944
+ // Whether the right side of the workspace exists at all. Both columns off
10945
+ // is the default: sessions and chat, nothing else. An absent column takes
10946
+ // no width and grows no resizer — off means gone, not collapsed.
10947
+ const rightSideActive = artifactPaneActive || canvasModeActive || diagnosticsActive;
10948
+
10949
+ // In the STORE, not local state: the header owns the way out (the rev
10950
+ // strip is promoted up there while full screen), and every other toolbar
10951
+ // button has to be able to drop it before doing its own job.
10952
+ const canvasMaximized = state.canvasMaximized && canvasModeActive;
10953
+ // Zen: chat rail + canvas workbench. Diagnostics steps aside but keeps
10954
+ // its stored toggle; the session list hides entirely.
10955
+ const zenActive = state.canvasZen && canvasModeActive && !artifactPaneActive;
10956
+ const zenRailPx = Math.max(260, Math.min(720,
10957
+ Number(state.canvasPrefs?.[state.activeSessionId]?.zenRailPx) || 380));
10958
+ // Escape steps down one rung: full screen -> zen -> normal workspace.
10959
+ React.useEffect(() => {
10960
+ if (!canvasMaximized && !zenActive) return undefined;
10961
+ const onKey = (e) => {
10962
+ if (e.key !== "Escape") return;
10963
+ controller.dispatch(canvasMaximized
10964
+ ? { type: "ui/canvasMaximized", on: false }
10965
+ : { type: "ui/canvasZen", on: false });
10966
+ };
10967
+ window.addEventListener("keydown", onKey);
10968
+ return () => window.removeEventListener("keydown", onKey);
10969
+ }, [controller, canvasMaximized, zenActive]);
10970
+
10971
+ // The right block is exactly as wide as the columns in it. Pixels, not a
10972
+ // share of the window: when a column closes, its pixels go to chat and
10973
+ // sessions — never to the surviving column. That is the whole model.
10974
+ const canvasColPx = Math.max(0, PORTAL_CANVAS_COL_DEFAULT_PX + (Number(state.canvasPaneAdjust) || 0));
10975
+ const diagColPx = Math.max(0, PORTAL_DIAG_COL_DEFAULT_PX + (Number(state.diagnosticsPaneAdjust) || 0));
10976
+ const rightSidePx = (canvasModeActive ? canvasColPx : 0)
10977
+ + (canvasModeActive && diagnosticsActive ? 1 : 0) // the hairline seam
10978
+ + (diagnosticsActive ? diagColPx : 0);
10979
+
9698
10980
  // Closing hands the column back — or collapses it again, if the user had
9699
10981
  // already resized it away before opening the pane. Both cases live in the
9700
10982
  // controller so the decision is testable without a DOM.
@@ -9702,44 +10984,37 @@ export function PilotSwarmWebApp({ controller }) {
9702
10984
  controller.closeArtifactPane().catch(() => {});
9703
10985
  }, [controller]);
9704
10986
 
9705
- React.useEffect(() => {
9706
- if (!filesFullscreenActive || !chatFocusMode) return;
9707
- setChatFocusMode(false);
9708
- setChatFocusPane(null);
9709
- }, [chatFocusMode, filesFullscreenActive]);
9710
-
9711
- const toggleChatFocusMode = React.useCallback(() => {
9712
- setChatFocusMode((current) => {
9713
- const next = !current;
9714
- if (!next) {
9715
- setChatFocusPane(null);
9716
- } else {
9717
- controller.setFocus("chat");
9718
- }
9719
- return next;
9720
- });
9721
- }, [controller]);
9722
-
9723
- const toggleChatFocusPane = React.useCallback((paneId) => {
9724
- setChatFocusPane((current) => {
9725
- const next = current === paneId ? null : paneId;
9726
- controller.setFocus(next || "chat");
9727
- return next;
9728
- });
9729
- }, [controller]);
9730
-
9731
10987
  const estimatedMainGridWidth = Math.max(0, (viewport.width || 0) * 0.68);
9732
10988
  const measuredMainGridWidth = mainGridViewport.width || estimatedMainGridWidth;
9733
10989
  const sessionColumnWidth = portalSessionColumnWidth(measuredMainGridWidth, state.portalSessionColumnAdjust);
9734
- const sessionColumnMode = portalSessionColumnMode(sessionColumnWidth);
10990
+ const sessionColumnMode = zenActive ? "hidden" : portalSessionColumnMode(sessionColumnWidth);
9735
10991
  const sessionColumnTrack = sessionColumnMode === "hidden"
9736
10992
  ? "0px"
9737
10993
  : `clamp(0px, calc(${PORTAL_SESSION_COLUMN_RATIO * 100}% + ${Number(state.portalSessionColumnAdjust) || 0}px), max(0px, calc(100% - 14rem - 32px)))`;
9738
10994
 
9739
10995
  const desktopWorkspace = React.createElement("div", {
9740
- className: "ps-workspace-grid",
10996
+ className: `ps-workspace-grid${rightSideActive ? "" : " is-right-hidden"}`,
9741
10997
  style: {
9742
- gridTemplateColumns: `minmax(0, ${layout.leftWidth}fr) var(--ps-resizer-track, 16px) minmax(0, ${layout.rightWidth}fr)`,
10998
+ // With both optional columns off there is no right side and no
10999
+ // resizer for one: chat takes the whole width. Leaving a 0fr track
11000
+ // and its 16px resizer behind would put a drag handle against the
11001
+ // window edge that reveals nothing.
11002
+ //
11003
+ // With the canvas up the right track gets a pixel FLOOR. The fr
11004
+ // ratio comes from computeLegacyLayout, which was tuned when the
11005
+ // right column only ever held the inspector/activity split — it
11006
+ // left the canvas around 200px, too narrow to read a page in.
11007
+ // Capped at 55% so the floor can never crush chat on a narrow
11008
+ // window; below that the user drags, or closes a column.
11009
+ gridTemplateColumns: artifactPaneActive
11010
+ ? `minmax(0, ${layout.leftWidth}fr) var(--ps-resizer-track, 16px) minmax(0, ${layout.rightWidth}fr)`
11011
+ : (zenActive
11012
+ // Zen: chat is a rail, the canvas is the workbench. The
11013
+ // rail width is remembered for THIS session only.
11014
+ ? `${zenRailPx}px var(--ps-resizer-track, 16px) minmax(0, 1fr)`
11015
+ : (rightSideActive
11016
+ ? `minmax(0, 1fr) var(--ps-resizer-track, 16px) min(${rightSidePx}px, 60%)`
11017
+ : "minmax(0, 1fr)")),
9743
11018
  },
9744
11019
  },
9745
11020
  React.createElement("div", {
@@ -9767,48 +11042,103 @@ export function PilotSwarmWebApp({ controller }) {
9767
11042
  style: { gridColumn: "3" },
9768
11043
  },
9769
11044
  React.createElement(ChatPane, { controller }))),
9770
- React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust }),
9771
- // The artifact reader takes the WHOLE right column — inspector, resizer and
9772
- // activity all step aside. The column keeps its width and resizer, so the
9773
- // chat/reader split is dragged exactly like the chat/inspector one was.
11045
+ rightSideActive
11046
+ ? (artifactPaneActive
11047
+ ? React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust })
11048
+ : (zenActive
11049
+ ? React.createElement(ZenRailResizeHandle, { controller, sessionId: state.activeSessionId, railPx: zenRailPx })
11050
+ : React.createElement(ChatRightResizeHandle, {
11051
+ controller,
11052
+ target: canvasModeActive ? "canvas" : "diagnostics",
11053
+ adjust: canvasModeActive ? state.canvasPaneAdjust : state.diagnosticsPaneAdjust,
11054
+ })))
11055
+ : null,
11056
+ // The right side of the workspace: canvas and diagnostics, side by side
11057
+ // and independent. Either, both, or neither.
11058
+ //
11059
+ // A flex ROW rather than more grid tracks. The outer grid's third track is
11060
+ // still sized against chat by the handle above, so that drag keeps meaning
11061
+ // exactly what it always meant: chat versus everything to its right.
11062
+ //
11063
+ // DOM order is the visual order: canvas, then diagnostics. Diagnostics is
11064
+ // always rightmost, whether or not the canvas is up.
11065
+ //
11066
+ // The artifact reader still takes the WHOLE right side while it is open —
11067
+ // canvas, inspector and activity all step aside — which is why
11068
+ // diagnosticsActive already excludes it.
11069
+ React.createElement("div", { className: `ps-workspace-column-host is-split${canvasMaximized ? " has-maximized" : ""}` },
9774
11070
  artifactPaneActive
9775
11071
  ? React.createElement("div", { className: "ps-workspace-column is-artifact" },
9776
11072
  React.createElement(ArtifactTakeoverPane, { controller, onClose: closeArtifactPane }))
9777
- : React.createElement("div", {
9778
- className: "ps-workspace-column",
9779
- style: { gridTemplateRows: `${layout.inspectorPaneHeight}fr var(--ps-resizer-track, 16px) ${layout.activityPaneHeight}fr` },
9780
- },
9781
- React.createElement("div", {
9782
- className: "ps-workspace-pane-slot",
9783
- style: { gridRow: "1" },
9784
- },
9785
- !layout.inspectorHidden ? React.createElement(InspectorPane, { controller, mobile: false }) : null),
9786
- React.createElement("div", {
9787
- style: {
9788
- gridRow: "2",
9789
- minHeight: 0,
9790
- display: "flex",
9791
- flexDirection: "column",
11073
+ : null,
11074
+ // The canvas keeps its DOCUMENT alive across toggles: once opened for this
11075
+ // session it stays mounted in a hidden layer — unmounting destroys typed
11076
+ // input, scroll position, and any running page. Session switches remount
11077
+ // fresh via the key. It is FIRST in the DOM so it sits left of
11078
+ // diagnostics; while hidden it is display:none and takes no width.
11079
+ desktopCanvasEverOpened
11080
+ ? React.createElement("div", {
11081
+ key: `canvas:${state.activeSessionId || ""}`,
11082
+ className: `ps-workspace-column is-artifact ps-canvas-layer${canvasModeActive && !artifactPaneActive ? "" : " is-hidden"}${canvasMaximized && canvasModeActive && !artifactPaneActive ? " is-maximized" : ""}`,
11083
+ // Sharing the right side: canvas takes a fixed slice and
11084
+ // diagnostics absorbs the rest, so the handle between them moves
11085
+ // one number. Alone, or full screen, it simply fills its box.
11086
+ style: canvasModeActive && !artifactPaneActive && diagnosticsActive && !canvasMaximized
11087
+ ? { flex: `0 0 ${canvasColPx}px`, minWidth: 0 }
11088
+ : undefined,
11089
+ ...(canvasModeActive && !artifactPaneActive ? {} : { inert: true, "aria-hidden": "true" }),
9792
11090
  },
9793
- },
9794
- React.createElement(ActivityRowResizeHandle, { controller, activityPaneAdjust: state.activityPaneAdjust })),
9795
- React.createElement("div", {
9796
- className: "ps-workspace-pane-slot",
9797
- style: { gridRow: "3" },
9798
- },
9799
- // The artifact preview takes over the activity slot while an artifact
9800
- // is selected, so it inherits the row resizer and can be sized freely.
9801
- // It yields back to Activity the moment the selection clears.
9802
- artifactPreviewDetached
9803
- ? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
9804
- : (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null))));
9805
- const chatFocusWorkspace = React.createElement(ChatFocusWorkspace, {
9806
- controller,
9807
- openPane: chatFocusPane,
9808
- onTogglePane: toggleChatFocusPane,
9809
- onExitFocus: toggleChatFocusMode,
9810
- mobile,
9811
- });
11091
+ React.createElement(CanvasPane, {
11092
+ controller,
11093
+ visible: canvasModeActive && !artifactPaneActive,
11094
+ maximized: canvasMaximized,
11095
+ }))
11096
+ : null,
11097
+ // Only when BOTH are up. With one column the outer chat/right handle is
11098
+ // already the only split there is, and a second handle would resize
11099
+ // nothing.
11100
+ canvasModeActive && diagnosticsActive
11101
+ ? React.createElement(CanvasDiagnosticsResizeHandle, { controller, canvasPaneAdjust: state.canvasPaneAdjust, diagnosticsPaneAdjust: state.diagnosticsPaneAdjust })
11102
+ : null,
11103
+ // Diagnostics: yesterday's inspector-over-activity column, unchanged
11104
+ // inside, now behind its own toggle and rightmost.
11105
+ diagnosticsActive
11106
+ ? React.createElement("div", {
11107
+ className: "ps-workspace-column",
11108
+ style: {
11109
+ // Continuous PIXEL split, minmax-clamped so neither pane can
11110
+ // be dragged away or collapse behind your back. The old
11111
+ // `${rows}fr` template rode the TUI's row-quantized layout —
11112
+ // ~20px notches and threshold-triggered collapse flips.
11113
+ // minmax(0,...): either pane may be dragged fully shut and
11114
+ // pulled back open with the same seam. The handle clamps the
11115
+ // adjust to the column's real height at drag time, so the bar
11116
+ // tracks the pointer 1:1 instead of detaching at a floor.
11117
+ gridTemplateRows: `minmax(0px, calc(50% + ${Math.round(Number(state.diagnosticsSplitAdjust) || 0)}px)) var(--ps-resizer-track, 16px) minmax(0px, 1fr)`,
11118
+ ...(canvasModeActive ? { flex: "1 1 0%", minWidth: 0 } : null),
11119
+ },
11120
+ },
11121
+ React.createElement("div", {
11122
+ className: "ps-workspace-pane-slot",
11123
+ style: { gridRow: "1" },
11124
+ },
11125
+ React.createElement(InspectorPane, { controller, mobile: false })),
11126
+ React.createElement("div", {
11127
+ style: { gridRow: "2", minHeight: 0, display: "flex", flexDirection: "column" },
11128
+ },
11129
+ React.createElement(DiagnosticsSplitResizeHandle, { controller, splitAdjust: state.diagnosticsSplitAdjust })),
11130
+ React.createElement("div", {
11131
+ className: "ps-workspace-pane-slot",
11132
+ style: { gridRow: "3" },
11133
+ },
11134
+ // The artifact preview takes over the activity slot while an
11135
+ // artifact is selected, so it inherits the row resizer and can
11136
+ // be sized freely. It yields back to Activity the moment the
11137
+ // selection clears.
11138
+ artifactPreviewDetached
11139
+ ? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
11140
+ : React.createElement(ActivityPane, { controller })))
11141
+ : null));
9812
11142
  const fullscreenWorkspace = React.createElement("div", { className: "ps-workspace-full" },
9813
11143
  React.createElement(InspectorPane, { controller, mobile: false }));
9814
11144
 
@@ -9831,34 +11161,57 @@ export function PilotSwarmWebApp({ controller }) {
9831
11161
  React.createElement(InspectorPane, { controller, mobile: true }));
9832
11162
  else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
9833
11163
  React.createElement(ActivityPane, { controller }));
9834
- else mobileContent = React.createElement(MobileWorkspace, { controller });
11164
+ // The canvas fills the whole region, so there is nothing to render under
11165
+ // it — the layer below is a SIBLING of this content and covers it. Drawing
11166
+ // a pane here would only be invisible work.
11167
+ else if (mobilePane === "canvas") mobileContent = null;
11168
+ else mobileContent = React.createElement(MobileWorkspace, { controller, layout: mobileMainLayout });
11169
+
11170
+ // The phone's canvas layer: a sibling of the content region's pane, NOT a
11171
+ // child of any pane. That is deliberate — panes mount and unmount as the
11172
+ // toolbar switches between Main, Activity and Inspector, and the canvas
11173
+ // frame has to survive all of it. Only a session change (which rekeys it)
11174
+ // or a reload may take a running page down.
11175
+ const mobileCanvasLayer = mobile && mobileCanvasEverOpened
11176
+ ? React.createElement(MobileCanvasLayer, {
11177
+ key: `canvas:${canvasShellView.sessionId || ""}`,
11178
+ controller,
11179
+ visible: mobileCanvasOpen,
11180
+ })
11181
+ : null;
9835
11182
 
9836
11183
  return React.createElement(ControllerContext.Provider, { value: controller },
9837
11184
  React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
9838
- // Hide the top toolbar on mobile inspector/activity panes (and in
9839
- // chat-focus mode). Those panes are pure read-only surfaces;
9840
- // session/model/theme actions stay reachable from the Main pane,
9841
- // and dropping the toolbar buys ~3 lines of vertical real estate
9842
- // on a phone, which matters for the fleet-skills card and the
9843
- // logs/sequence inspectors.
9844
- (mobile && (chatFocusMode || mobilePane === "inspector" || mobilePane === "activity"))
9845
- ? null
9846
- : React.createElement(Toolbar, {
11185
+ // The phone's toolbar carries its NAVIGATION (the three view modes,
11186
+ // plus the Main layout cycle), so it renders on every pane — hiding it
11187
+ // anywhere would trap the user with no way back.
11188
+ React.createElement(Toolbar, {
9847
11189
  controller,
9848
11190
  mobile,
9849
- chatFocusMode,
9850
- onToggleChatFocus: toggleChatFocusMode,
9851
- chatFocusDisabled: filesFullscreenActive,
11191
+ canvasPaneOpen: mobileCanvasOpen,
11192
+ onToggleCanvasPane: toggleMobileCanvas,
11193
+ mobilePane,
11194
+ mobileMainLayout,
11195
+ onSelectMobilePane: (paneId, focus) => {
11196
+ // Tapping Main while already on Main cycles its layout; from
11197
+ // another pane (the canvas included) it just returns you to
11198
+ // Main, unchanged.
11199
+ if (paneId === "workspace" && mobilePane === "workspace") {
11200
+ setMobileMainLayout((current) => (
11201
+ current === "split" ? "chat" : current === "chat" ? "sessions" : "split"
11202
+ ));
11203
+ }
11204
+ setMobilePane(paneId);
11205
+ if (focus) controller.setFocus(focus);
11206
+ },
9852
11207
  }),
9853
11208
  React.createElement("div", { className: "ps-workspace" },
9854
11209
  state.adminVisible
9855
11210
  ? React.createElement(AdminConsolePanel, { controller, mobile })
9856
11211
  : (filesFullscreenActive
9857
11212
  ? fullscreenWorkspace
9858
- : (chatFocusMode
9859
- ? chatFocusWorkspace
9860
- : (mobile ? mobileContent : desktopWorkspace)))),
9861
- mobile && !chatFocusMode ? React.createElement(MobileNav, { activePane: mobilePane, setActivePane: setMobilePane, controller }) : null,
11213
+ : (mobile ? mobileContent : desktopWorkspace)),
11214
+ mobileCanvasLayer),
9862
11215
  mobile && filesFullscreenActive
9863
11216
  ? React.createElement(MobileArtifactOverlay, { controller })
9864
11217
  : null,