pilotswarm 0.5.35 → 0.5.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +2 -2
  2. package/tui/src/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 +217 -19
  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 +209 -19
  10. package/ui/core/src/selectors.js +143 -154
  11. package/ui/core/src/state.js +51 -2
  12. package/ui/core/src/themes/duroxide.js +6 -1
  13. package/ui/core/src/themes/win95.js +4 -1
  14. package/ui/react/src/components.js +1 -7
  15. package/ui/react/src/web-app.js +853 -260
  16. package/web/dist/assets/{abnfDiagram-VRR7QNED-BwJEohLG.js → abnfDiagram-VRR7QNED-DlvPH-8O.js} +1 -1
  17. package/web/dist/assets/{arc-CWUJ_S5s.js → arc-r1liz5e5.js} +1 -1
  18. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-i6mrLxuE.js → architectureDiagram-ZJ3FMSHR-Bci5dNH3.js} +1 -1
  19. package/web/dist/assets/{blockDiagram-677ZJIJ3-C_KG-qPQ.js → blockDiagram-677ZJIJ3-Bug32AXp.js} +1 -1
  20. package/web/dist/assets/{c4Diagram-LMCZKHZV-BBJH7hbB.js → c4Diagram-LMCZKHZV-DwxSFqgE.js} +1 -1
  21. package/web/dist/assets/channel-jVpSYat3.js +1 -0
  22. package/web/dist/assets/{chunk-2Q5K7J3B-CXwaFcaM.js → chunk-2Q5K7J3B-B_dbI_xg.js} +1 -1
  23. package/web/dist/assets/{chunk-32BRIVSS-C8ujjkK-.js → chunk-32BRIVSS-BkF68W-R.js} +1 -1
  24. package/web/dist/assets/{chunk-5VM5RSS4-M9rz7YYk.js → chunk-5VM5RSS4-BSbNIbJu.js} +1 -1
  25. package/web/dist/assets/{chunk-EX3LRPZG-bk6tRxOA.js → chunk-EX3LRPZG-Blv_Iqa2.js} +1 -1
  26. package/web/dist/assets/{chunk-JWPE2WC7-DZ4WSHGH.js → chunk-JWPE2WC7-Dk_dBFKd.js} +1 -1
  27. package/web/dist/assets/{chunk-MOJQB5TN-B5-y6tMF.js → chunk-MOJQB5TN-DqNFBrfw.js} +1 -1
  28. package/web/dist/assets/{chunk-RYQCIY6F-yKcnHxm0.js → chunk-RYQCIY6F-Dlv7Svpb.js} +1 -1
  29. package/web/dist/assets/{chunk-V7JOEXUC-CpoNcATD.js → chunk-V7JOEXUC-DozGikwu.js} +1 -1
  30. package/web/dist/assets/{chunk-VR4S4FIN-Bf1yD2fn.js → chunk-VR4S4FIN-CmkqvGT4.js} +1 -1
  31. package/web/dist/assets/{chunk-XXDRQBXY-EnPvDVhP.js → chunk-XXDRQBXY-DyByai5N.js} +1 -1
  32. package/web/dist/assets/classDiagram-OUVF2IWQ-C78zSajE.js +1 -0
  33. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C78zSajE.js +1 -0
  34. package/web/dist/assets/{cose-bilkent-JH36ORCC-qhKoA2qj.js → cose-bilkent-JH36ORCC-Bv-gRVvA.js} +1 -1
  35. package/web/dist/assets/{cynefin-VYW2F7L2-jcyYelJS.js → cynefin-VYW2F7L2-DRnAsyZU.js} +1 -1
  36. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-D4RLufLP.js → cynefinDiagram-TSTJHNR4-B1fnUtjo.js} +1 -1
  37. package/web/dist/assets/{dagre-VKFMJZFB-C9BA3GTO.js → dagre-VKFMJZFB-DM6-QENB.js} +1 -1
  38. package/web/dist/assets/{diagram-FQU43EPY-DbSSrVUJ.js → diagram-FQU43EPY-BPxxnLdU.js} +1 -1
  39. package/web/dist/assets/{diagram-G47NLZAW-3egISQXQ.js → diagram-G47NLZAW-BgtJT3qF.js} +1 -1
  40. package/web/dist/assets/{diagram-NH7WQ7WH-8IKEMGRg.js → diagram-NH7WQ7WH-uekeIzsy.js} +1 -1
  41. package/web/dist/assets/{diagram-OA4YK3LP-VnaogqtK.js → diagram-OA4YK3LP-BVy3JZWE.js} +1 -1
  42. package/web/dist/assets/{diagram-WEI45ONY-DxigXH0T.js → diagram-WEI45ONY-BWdjUr32.js} +1 -1
  43. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-BSkNwJ9x.js → ebnfDiagram-CCIWWBDH-DD4fwhNK.js} +1 -1
  44. package/web/dist/assets/{erDiagram-Q63AITRT-CKN8JXl6.js → erDiagram-Q63AITRT-BllD_6Kh.js} +1 -1
  45. package/web/dist/assets/{flowDiagram-23GEKE2U-DDaD5ExP.js → flowDiagram-23GEKE2U-QEYKkAUN.js} +1 -1
  46. package/web/dist/assets/{ganttDiagram-NO4QXBWP-tOrBOuoI.js → ganttDiagram-NO4QXBWP-uZcfKTSW.js} +1 -1
  47. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Cdp867ON.js → gitGraphDiagram-IHSO6WYX-XPdbPaH-.js} +1 -1
  48. package/web/dist/assets/index-BuDSoRIL.css +1 -0
  49. package/web/dist/assets/{index-BmwU43_u.js → index-D1itTxvB.js} +4 -4
  50. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DwcKHD2h.js → infoDiagram-FWYZ7A6U-DXCnQOJs.js} +1 -1
  51. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CWffK5kz.js → ishikawaDiagram-FXEZZL3T-687_4FBy.js} +1 -1
  52. package/web/dist/assets/{journeyDiagram-5HDEW3XC-BEAgS-EZ.js → journeyDiagram-5HDEW3XC-GXlKuda8.js} +1 -1
  53. package/web/dist/assets/{kanban-definition-HUTT4EX6-xDm-mRRj.js → kanban-definition-HUTT4EX6-C38-2DvN.js} +1 -1
  54. package/web/dist/assets/{linear-oP9MA0as.js → linear-lGPXiBcz.js} +1 -1
  55. package/web/dist/assets/{mermaid.core-DgXXt1OS.js → mermaid.core-C_neEtab.js} +4 -4
  56. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BBQgVrYC.js → mindmap-definition-LN4V7U3C-BaJ9tpEG.js} +1 -1
  57. package/web/dist/assets/{papaparse.min-CR6JmBWm.js → papaparse.min-rDwKYbBf.js} +1 -1
  58. package/web/dist/assets/{pegDiagram-2B236MQR-C2O0cf3j.js → pegDiagram-2B236MQR-BdXJLYgY.js} +1 -1
  59. package/web/dist/assets/{pieDiagram-ENE6RG2P-D08pUtYU.js → pieDiagram-ENE6RG2P-DU19t6fM.js} +1 -1
  60. package/web/dist/assets/pilotswarm-BNKGG936.js +96 -0
  61. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js → quadrantDiagram-ABIIQ3AL-CBkOdPp0.js} +1 -1
  62. package/web/dist/assets/{railroadDiagram-RFXS5EU6-ClkdveLI.js → railroadDiagram-RFXS5EU6-D2DMcGDw.js} +1 -1
  63. package/web/dist/assets/{requirementDiagram-TGXJPOKE-IY4xbn-Q.js → requirementDiagram-TGXJPOKE-Bx9tF1pr.js} +1 -1
  64. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Cpt5i7KM.js → sankeyDiagram-HTMAVEWB-DcLlRfsi.js} +1 -1
  65. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-c07lALcz.js → sequenceDiagram-DBY2YBRQ-xr6UVRBO.js} +1 -1
  66. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CmCDiNbI.js → sizeCapture-X5ZJPWSS-0fUkSXmg.js} +1 -1
  67. package/web/dist/assets/{stateDiagram-2N3HPSRC-Dueu17zX.js → stateDiagram-2N3HPSRC-BWgJGGpg.js} +1 -1
  68. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DmAC8DdB.js +1 -0
  69. package/web/dist/assets/{swimlanes-5IMT3BWC-Csc2Fup8.js → swimlanes-5IMT3BWC-CZKya4_i.js} +2 -2
  70. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CVYx20_X.js +8 -0
  71. package/web/dist/assets/{timeline-definition-FHXFAJF6-4-lf702e.js → timeline-definition-FHXFAJF6-Df8BpeAu.js} +1 -1
  72. package/web/dist/assets/{vennDiagram-L72KCM5P-CmlaKpLI.js → vennDiagram-L72KCM5P-ChX5bjT6.js} +1 -1
  73. package/web/dist/assets/{wardleyDiagram-EHGQE667-B9Qe44pi.js → wardleyDiagram-EHGQE667-Dr48BgaJ.js} +1 -1
  74. package/web/dist/assets/{xychartDiagram-FW5EYKEG-BFcc0CLn.js → xychartDiagram-FW5EYKEG-Cia_MFfc.js} +1 -1
  75. package/web/dist/index.html +4 -4
  76. package/web/runtime.js +20 -1
  77. package/web/dist/assets/channel-5Ddqi9ib.js +0 -1
  78. package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +0 -1
  79. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +0 -1
  80. package/web/dist/assets/index-D-nA7gUe.css +0 -1
  81. package/web/dist/assets/pilotswarm-D7NLCbuc.js +0 -99
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +0 -1
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.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,18 @@ 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
+ if (hasOwn(candidate, "canvasPrefs")) {
256
+ normalized.canvasPrefs = normalizeStoredCanvasPrefs(candidate.canvasPrefs);
252
257
  }
253
258
  // Phones and desktops keep SEPARATE view preferences: rich prose reads well
254
259
  // on a wide transcript and poorly in a 390px column, so a single shared
255
260
  // value would have each device overwriting the other's choice.
256
- if (isChatViewMode(candidate.chatViewModeMobile)) {
257
- normalized.chatViewModeMobile = candidate.chatViewModeMobile;
258
- }
259
261
  return normalized;
260
262
  }
261
263
 
262
- function isChatViewMode(value) {
263
- return value === "summary" || value === "transcript" || value === "rich";
264
- }
265
264
 
266
265
  /** True on phone-sized viewports — the device class that owns the mobile slot. */
267
266
  function isNarrowViewport() {
@@ -271,17 +270,11 @@ function isNarrowViewport() {
271
270
  }
272
271
 
273
272
  /** Profile key this device reads and writes. */
274
- function chatViewModeKey() {
275
- return isNarrowViewport() ? "chatViewModeMobile" : "chatViewMode";
276
- }
277
273
 
278
274
  /** The key belonging to the OTHER device class. */
279
- function otherChatViewModeKey() {
280
- return isNarrowViewport() ? "chatViewMode" : "chatViewModeMobile";
281
- }
282
275
 
283
276
  /**
284
- * The "Mobile" (touch-scale) preference is per DEVICE CLASS, like chatViewMode:
277
+ * The "Mobile" (touch-scale) preference is per DEVICE CLASS:
285
278
  * it describes how big the type and hit targets should be on the screen you are
286
279
  * actually looking at, so turning it on from a phone must not scale up the
287
280
  * desktop. It is otherwise orthogonal to the theme — it lives in the theme
@@ -326,7 +319,7 @@ function hasOwn(value, key) {
326
319
  return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
327
320
  }
328
321
 
329
- function profileSettingsFromViewState(state, preservedOtherChatViewMode = null, preservedOtherTouchScale = null) {
322
+ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null) {
330
323
  return normalizeProfileSettings({
331
324
  themeId: state.themeId,
332
325
  [touchScaleKey()]: state.touchScale,
@@ -341,20 +334,27 @@ function profileSettingsFromViewState(state, preservedOtherChatViewMode = null,
341
334
  sessionOrder: state.manualOrder,
342
335
  collapsedSessionIds: state.collapsedSessionIds,
343
336
  activeSessionId: state.activeSessionId,
344
- [chatViewModeKey()]: state.chatViewMode,
337
+ // rightPaneMode describes the DESKTOP right column; a phone has no
338
+ // such column (its canvas surface is a nav tab), so a phone must not
339
+ // write its own value — it preserves the last-known stored one, or a
340
+ // desktop that flipped to canvas would be flipped back by the phone's
341
+ // next save.
342
+ ...(isNarrowViewport()
343
+ ? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
344
+ ? { rightPaneMode: preservedRightPaneMode }
345
+ : {})
346
+ : { rightPaneMode: state.rightPaneMode }),
347
+ canvasPrefs: state.canvasPrefs,
345
348
  // setCurrentUserProfileSettings REPLACES the settings object, so the
346
349
  // other device class's slot has to be written back verbatim or saving
347
350
  // from a desktop would wipe the phone's preference (and vice versa).
348
- ...(isChatViewMode(preservedOtherChatViewMode)
349
- ? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
350
- : {}),
351
351
  ...(typeof preservedOtherTouchScale === "boolean"
352
352
  ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
353
353
  : {}),
354
354
  });
355
355
  }
356
356
 
357
- function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null, preservedOtherTouchScale = null) {
357
+ function buildDefaultProfileSettingsFromState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null) {
358
358
  return normalizeProfileSettings({
359
359
  themeId: state?.ui?.themeId,
360
360
  [touchScaleKey()]: state?.ui?.touchScale,
@@ -370,10 +370,12 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode
370
370
  sessionOrder: state?.sessions?.manualOrder,
371
371
  collapsedSessionIds: state?.sessions?.collapsedIds,
372
372
  activeSessionId: state?.sessions?.activeSessionId,
373
- [chatViewModeKey()]: state?.ui?.chatViewMode,
374
- ...(isChatViewMode(preservedOtherChatViewMode)
375
- ? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
376
- : {}),
373
+ ...(isNarrowViewport()
374
+ ? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
375
+ ? { rightPaneMode: preservedRightPaneMode }
376
+ : {})
377
+ : { rightPaneMode: state?.ui?.rightPaneMode }),
378
+ canvasPrefs: state?.canvas?.prefs,
377
379
  ...(typeof preservedOtherTouchScale === "boolean"
378
380
  ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
379
381
  : {}),
@@ -383,13 +385,11 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode
383
385
  function materializeProfileSettings(remoteSettings, defaults) {
384
386
  const normalizedRemote = normalizeProfileSettings(remoteSettings);
385
387
  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.
388
+ // No-defaults rule for per-device and toggle-like fields: when the remote
389
+ // profile lacks a key, do NOT synthesize a default — the poll runs every
390
+ // few seconds, and a synthesized value would clobber a fresh local change
391
+ // the server has not persisted yet. An omitted key leaves the
392
+ // profileSettings/apply guards preserving the current value.
393
393
  return normalizeProfileSettings({
394
394
  themeId: hasOwn(normalizedRemote, "themeId")
395
395
  ? normalizedRemote.themeId
@@ -419,13 +419,20 @@ function materializeProfileSettings(remoteSettings, defaults) {
419
419
  // Apply only the slot this device owns. The other is preserved by the
420
420
  // save path (see profileSettingsFromViewState), not applied here — it
421
421
  // describes a screen size we are not on.
422
- ...(hasOwn(normalizedRemote, chatViewModeKey())
423
- ? { chatViewMode: normalizedRemote[chatViewModeKey()] }
422
+ // Canvas keys follow the same no-defaults rule: the poll must not
423
+ // clobber a local flip/badge advance the server has not persisted yet.
424
+ // An omitted key leaves the profileSettings/apply guards preserving
425
+ // the current value.
426
+ ...(hasOwn(normalizedRemote, "rightPaneMode")
427
+ ? { rightPaneMode: normalizedRemote.rightPaneMode }
428
+ : {}),
429
+ ...(hasOwn(normalizedRemote, "canvasPrefs")
430
+ ? { canvasPrefs: normalizedRemote.canvasPrefs }
424
431
  : {}),
425
432
  // touchScale was missing from this merge entirely, which is why the
426
433
  // "Mobile" checkbox never survived a reload: the save path wrote it,
427
434
  // but nothing ever read it back, and the reducer's hasTouchScale guard
428
- // then preserved the default. Same no-defaults rule as chatViewMode —
435
+ // then preserved the default. Same no-defaults rule —
429
436
  // synthesizing one here would let a poll clobber a fresh local toggle.
430
437
  ...(hasOwn(normalizedRemote, touchScaleKey())
431
438
  ? { touchScale: normalizedRemote[touchScaleKey()] }
@@ -513,7 +520,7 @@ function computeStateLayout(state) {
513
520
  }, state.ui.layout?.paneAdjust ?? 0, getStatePromptRows(state), state.ui.layout?.sessionPaneAdjust ?? 0);
514
521
  }
515
522
 
516
- function getPortalInspectorContentWidth(paneWidth, inspectorTab) {
523
+ function getPortalInspectorContentWidth(paneWidth, inspectorTab, mobile = false) {
517
524
  // The sequence view once reserved 3 extra columns here so that "content
518
525
  // that fits visually does not trip a cosmetic x-scrollbar". That was a
519
526
  // fudge for a real bug: every sequence line was padded to the full width,
@@ -522,7 +529,14 @@ function getPortalInspectorContentWidth(paneWidth, inspectorTab) {
522
529
  // and any rounding tipped it into a permanent scrollbar. The padding is now
523
530
  // trimmed at the source (see trimTrailingRunPad in ui-core selectors), so
524
531
  // the guard would only throw away 3 usable columns.
525
- return Math.max(20, paneWidth - 4);
532
+ //
533
+ // A PHONE still needs a margin, for a different reason: the column count
534
+ // is derived from an assumed character cell, and iOS Safari's monospace
535
+ // fallback renders wider than the desktop's. A grid sized to the exact
536
+ // pane there overflows by a chunk, and the reader meets a view scrolled
537
+ // sideways with its timestamps and STATS header sliced off. Two columns of
538
+ // slack absorb the metric difference.
539
+ return Math.max(20, paneWidth - (mobile ? 6 : 4));
526
540
  }
527
541
 
528
542
  function normalizeLines(lines) {
@@ -2819,6 +2833,10 @@ function HtmlArtifactPreviewPanel({
2819
2833
  title: filename || "HTML artifact",
2820
2834
  // No allow-same-origin — see the block comment above.
2821
2835
  sandbox: "allow-scripts allow-popups allow-popups-to-escape-sandbox",
2836
+ // Opaque origin ≠ "self", so the autoplay policy would
2837
+ // silently mute an interactive artifact (same fix the
2838
+ // canvas frame carries).
2839
+ allow: "autoplay *",
2822
2840
  referrerPolicy: "no-referrer",
2823
2841
  onLoad: () => frames.promote(nonce),
2824
2842
  }))
@@ -3197,7 +3215,14 @@ function parseStructuredChatBlocks(lines = []) {
3197
3215
  }
3198
3216
 
3199
3217
  function StructuredChatBlocks({ lines, theme, controller = null }) {
3200
- const blocks = React.useMemo(() => parseStructuredChatBlocks(lines), [lines]);
3218
+ // canvasUpdate-flagged lines are the TUI's affordance (an artifact link
3219
+ // for a host that cannot render the canvas). In the browser the canvas
3220
+ // pane updating IS the signal, so the chat skips them outright — desktop
3221
+ // and mobile share this renderer, so one filter covers both.
3222
+ const blocks = React.useMemo(
3223
+ () => parseStructuredChatBlocks((lines || []).filter((line) => !line?.canvasUpdate && !line?.canvasAction)),
3224
+ [lines],
3225
+ );
3201
3226
  return React.createElement(StructuredBlockList, { blocks, theme, controller });
3202
3227
  }
3203
3228
 
@@ -3626,6 +3651,28 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
3626
3651
  const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
3627
3652
  const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
3628
3653
 
3654
+ // A stale sideways offset is how a fitted grid greets the reader with its
3655
+ // timestamps and header sliced off: pan once, switch tabs or sessions, and
3656
+ // the new content inherits the old scrollLeft. Reset it whenever the pane's
3657
+ // identity changes — the user's own pan within a view still stands.
3658
+ const horizontalResetKey = `${paneKey}:${title}`;
3659
+ React.useEffect(() => {
3660
+ // Two passes, deliberately: the first runs before the new view's lines
3661
+ // have laid out, and the sticky-header scroll sync can copy a stale
3662
+ // offset back onto the body afterwards. The rAF pass lands after both
3663
+ // and is what actually sticks (observed on Stats, whose header row
3664
+ // syncs horizontally with the body).
3665
+ const reset = () => {
3666
+ if (ref.current) ref.current.scrollLeft = 0;
3667
+ if (stickyRef.current) stickyRef.current.scrollLeft = 0;
3668
+ };
3669
+ reset();
3670
+ const frame = typeof requestAnimationFrame === "function"
3671
+ ? requestAnimationFrame(() => { reset(); })
3672
+ : null;
3673
+ return () => { if (frame != null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame); };
3674
+ }, [horizontalResetKey]);
3675
+
3629
3676
  // Clicking (or touching) a scrollable pane makes the keyboard scroll keys
3630
3677
  // act on THAT pane. Fires on mousedown rather than click so a drag-select
3631
3678
  // inside the pane claims focus too.
@@ -4151,9 +4198,11 @@ function useHorizontalPanOptIn(ref) {
4151
4198
  }, [ref]);
4152
4199
  }
4153
4200
 
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.
4201
+ function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false, showDetailBox = null }) {
4202
+ // Mobile keeps its inline detail line and normally gets no detail box — a
4203
+ // reserved footer would eat a meaningful slice of a phone screen. The
4204
+ // sessions-ONLY layout is the exception: it has the whole screen and the
4205
+ // detail box is the point of asking for it.
4157
4206
  const isMobilePane = String(panelClassName).includes("ps-mobile-session-pane");
4158
4207
  // Vertical is the primary scroll axis; sideways takes a deliberate swipe.
4159
4208
  const sessionListRef = React.useRef(null);
@@ -4936,12 +4985,12 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4936
4985
  // touch hijacks the finger that should be scrolling the list.
4937
4986
  drag: touchInput ? null : dragHandlers,
4938
4987
  }))),
4939
- isMobilePane
4940
- ? null
4941
- : React.createElement(SessionDetailBox, {
4988
+ (showDetailBox === null ? !isMobilePane : showDetailBox)
4989
+ ? React.createElement(SessionDetailBox, {
4942
4990
  session: activeSession,
4943
4991
  childCount: activeRow?.childCount || 0,
4944
- })),
4992
+ })
4993
+ : null),
4945
4994
  (manageOpen && activeSession && !activeSession.isGroup)
4946
4995
  ? React.createElement(SessionModifyModal, {
4947
4996
  controller,
@@ -5032,20 +5081,9 @@ function FunnelGlyph() {
5032
5081
  }
5033
5082
 
5034
5083
  // 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
5084
 
5043
5085
  // Back to the transcript. Replaces the 💬 emoji, which iOS force-renders in
5044
5086
  // 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
5087
 
5050
5088
  function PlusGlyph() {
5051
5089
  return React.createElement(Glyph, null,
@@ -5130,6 +5168,33 @@ function SidebarGlyph() {
5130
5168
  React.createElement("path", { d: "M9.5 3.5v17" }));
5131
5169
  }
5132
5170
 
5171
+ // The phone's view modes live in the top bar, not a bottom nav; each reads at
5172
+ // icon size without a label.
5173
+ // The Main pane, whatever layout it is in: a frame with a list rail and a
5174
+ // conversation beside it. Constant across the three-way cycle.
5175
+ function MainLayoutGlyph() {
5176
+ return React.createElement(Glyph, null,
5177
+ React.createElement("rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }),
5178
+ React.createElement("line", { x1: "9.5", y1: "4", x2: "9.5", y2: "20" }),
5179
+ React.createElement("line", { x1: "12.5", y1: "9", x2: "18", y2: "9" }),
5180
+ React.createElement("line", { x1: "12.5", y1: "13", x2: "18", y2: "13" }));
5181
+ }
5182
+
5183
+ // Inspector takes the pulse — the trace/sequence view is its default face.
5184
+ function InspectorPaneGlyph() {
5185
+ return React.createElement(Glyph, null,
5186
+ React.createElement("path", { d: "M3 12h4l2.5-6 4 12 2.5-6H21" }));
5187
+ }
5188
+
5189
+ // Activity reads a log: a page with lines on it.
5190
+ function ActivityPaneGlyph() {
5191
+ return React.createElement(Glyph, null,
5192
+ React.createElement("path", { d: "M6 3h8l4 4v14H6z" }),
5193
+ React.createElement("path", { d: "M14 3v4h4" }),
5194
+ React.createElement("line", { x1: "9", y1: "12", x2: "15", y2: "12" }),
5195
+ React.createElement("line", { x1: "9", y1: "16", x2: "15", y2: "16" }));
5196
+ }
5197
+
5133
5198
  // Logs are console output; a prompt chevron says that and nothing else.
5134
5199
  function LogsGlyph() {
5135
5200
  return React.createElement(Glyph, null,
@@ -5574,7 +5639,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5574
5639
  sessionsById: state.sessions.byId,
5575
5640
  sessionsFlat: state.sessions.flat,
5576
5641
  inspectorTab: state.ui.inspectorTab,
5577
- chatViewMode: state.ui.chatViewMode || "transcript",
5578
5642
  activeSessionIsGroup: Boolean(activeSessionId && state.sessions.byId[activeSessionId]?.isGroup),
5579
5643
  activeSessionStatus: activeSessionId ? String(state.sessions.byId[activeSessionId]?.status || "").toLowerCase() : "",
5580
5644
  focused: state.ui.focusRegion === "chat",
@@ -5606,7 +5670,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5606
5670
  },
5607
5671
  ui: {
5608
5672
  inspectorTab: viewState.inspectorTab,
5609
- chatViewMode: viewState.chatViewMode,
5610
5673
  },
5611
5674
  }), [
5612
5675
  viewState.activeHistory,
@@ -5615,7 +5678,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5615
5678
  viewState.authPrincipal,
5616
5679
  viewState.branding,
5617
5680
  viewState.connection,
5618
- viewState.chatViewMode,
5619
5681
  viewState.inspectorTab,
5620
5682
  viewState.sessionsById,
5621
5683
  viewState.sessionsFlat,
@@ -5667,7 +5729,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5667
5729
  controller, viewState.activeSessionId, viewState.activeSessionIsGroup,
5668
5730
  );
5669
5731
  const navigationError = useControllerSelector(controller, selectNavigationError, shallowEqualObject);
5670
- const composerBase = showComposer && !viewState.activeSessionIsGroup && viewState.chatViewMode !== "summary";
5732
+ const composerBase = showComposer && !viewState.activeSessionIsGroup;
5671
5733
  // Service sessions (⚗ tree-scoped machinery, e.g. the regen distiller) are
5672
5734
  // read-only BY KIND: their transcript is the trace of runtime machinery,
5673
5735
  // never a conversation surface — no prompt for anyone, owner included.
@@ -5887,6 +5949,19 @@ function ArtifactTakeoverPane({ controller, onClose }) {
5887
5949
  * phone should feel like pushing a detail screen, the way a native app does,
5888
5950
  * not like shrinking content into one more box. Desktop never mounts this.
5889
5951
  */
5952
+ /**
5953
+ * The phone's canvas: FULL-SCREEN, opened by the same toolbar toggle the
5954
+ * desktop has, closed by the pane's ✕ back to whatever was on screen. The
5955
+ * former bottom-nav tab is gone — one affordance on every device class.
5956
+ */
5957
+ function MobileCanvasOverlay({ controller, onClose, visible = true }) {
5958
+ // Kept MOUNTED while hidden: unmounting destroys the sandboxed document,
5959
+ // and with it everything the user typed and scrolled. visibility (not
5960
+ // display) keeps layout real so no resize-reload fires on re-show.
5961
+ return React.createElement("div", { className: `ps-artifact-overlay ps-canvas-overlay${visible ? "" : " is-hidden"}`, ...(visible ? {} : { inert: true, "aria-hidden": "true" }) },
5962
+ React.createElement(CanvasPane, { controller, mobile: true, onClose, visible, focusOnPromote: true }));
5963
+ }
5964
+
5890
5965
  function MobileArtifactOverlay({ controller }) {
5891
5966
  const view = useControllerSelector(controller, (state) => {
5892
5967
  const id = state.files.selectedArtifactId || "";
@@ -5952,18 +6027,370 @@ function MobileArtifactOverlay({ controller }) {
5952
6027
  })));
5953
6028
  }
5954
6029
 
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).
6030
+ /**
6031
+ * The phone's Main pane, in one of three layouts cycled by the Main toolbar
6032
+ * button: split (sessions + chat), chat only, sessions only (list + the
6033
+ * detail sub-panel). This replaced chat-focus mode, which was a second way to
6034
+ * say "chat only" with its own chrome and its own exit.
6035
+ */
6036
+ function MobileWorkspace({ controller, layout = "split" }) {
6037
+ const sessionPane = React.createElement(SessionPane, {
6038
+ controller,
6039
+ panelClassName: "ps-mobile-session-pane",
6040
+ });
6041
+ if (layout === "chat") {
6042
+ return React.createElement("div", { className: "ps-mobile-workspace is-chat-only" },
6043
+ React.createElement("div", { className: "ps-mobile-chat-pane" },
6044
+ React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
6045
+ }
6046
+ if (layout === "sessions") {
6047
+ return React.createElement("div", { className: "ps-mobile-workspace is-sessions-only" },
6048
+ React.createElement(SessionPane, {
6049
+ controller,
6050
+ panelClassName: "ps-mobile-session-pane",
6051
+ showDetailBox: true,
6052
+ }));
6053
+ }
5958
6054
  return React.createElement("div", { className: "ps-mobile-workspace" },
5959
- React.createElement(SessionPane, {
5960
- controller,
5961
- panelClassName: "ps-mobile-session-pane",
5962
- }),
6055
+ sessionPane,
5963
6056
  React.createElement("div", { className: "ps-mobile-chat-pane" },
5964
6057
  React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
5965
6058
  }
5966
6059
 
6060
+ /** Frame-and-easel glyph for the Canvas toggle. */
6061
+ function CanvasGlyph() {
6062
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" };
6063
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 16, height: 16, "aria-hidden": "true" },
6064
+ React.createElement("rect", { x: 2.5, y: 3.5, width: 15, height: 11, rx: 1.5, ...stroke }),
6065
+ React.createElement("path", { d: "M5.5 11.5l3-3.5 2.5 2.5 3.5-4", ...stroke }),
6066
+ React.createElement("path", { d: "M7 17.5h6M10 14.5v3", ...stroke }));
6067
+ }
6068
+
6069
+ /**
6070
+ * Double-buffered canvas frame with PER-REVISION blob URLs.
6071
+ *
6072
+ * Deliberately not the artifact preview panel: that component nulls its frame
6073
+ * URL when its fetch key changes (right for a DIFFERENT document, a blank
6074
+ * flash for a redraw of the same one) and shares one URL between live and
6075
+ * staging frames. Here each fetched revision owns its URL; the replacement
6076
+ * loads hidden-but-painted behind the live frame and is promoted on load, so
6077
+ * a live redraw is a composite swap of something already drawn.
6078
+ */
6079
+ function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null }) {
6080
+ const [live, setLive] = React.useState(null); // { url, rev }
6081
+ const [staging, setStaging] = React.useState(null); // { url, rev }
6082
+ const [loadError, setLoadError] = React.useState(null); // { rev }
6083
+ const [retryTick, setRetryTick] = React.useState(0);
6084
+ // Blob URLs tracked in a ref so cleanup can revoke directly — setState
6085
+ // updaters are neither a reliable post-unmount hook nor a pure place for
6086
+ // side effects (StrictMode double-invokes them).
6087
+ const urlsRef = React.useRef({ live: null, staging: null });
6088
+ const autoRetryRef = React.useRef({ rev: 0, used: false });
6089
+ const hostRef = React.useRef(null);
6090
+ const liveIframeElRef = React.useRef(null);
6091
+ const hostBox = useElementBox(hostRef);
6092
+ // The frame geometry each revision LOADED at. A sandboxed page cannot be
6093
+ // poked after the fact (no reaching into an opaque origin), and pages
6094
+ // routinely measure the viewport exactly once — so when the pane settles
6095
+ // at a materially different size than the page loaded at (boot-time
6096
+ // hydration, a column resize, zoom), the only honest fix is the reader's:
6097
+ // reload through the double buffer at the new size. Verified live: a page
6098
+ // loading during boot measured the iframe's INTRINSIC 150px default and
6099
+ // baked it in, which is how a full-height game renders as a bottom sliver
6100
+ // under a black void.
6101
+ const loadedBoxRef = React.useRef(null);
6102
+ const zoomRef = React.useRef(zoom);
6103
+ zoomRef.current = zoom;
6104
+ const visibleRef = React.useRef(visible);
6105
+ visibleRef.current = visible;
6106
+
6107
+ // The interactive-canvas bridge. Provenance is THIS check: a message is
6108
+ // accepted only when its source window is the LIVE canvas iframe — the
6109
+ // staging frame, the artifact reader, file previews, and every other
6110
+ // window on the page fail the identity test. Contract validation and
6111
+ // rate limiting happen in controller.submitCanvasAction; with no
6112
+ // contract on the current revision, nothing is accepted at all.
6113
+ React.useEffect(() => {
6114
+ if (!controller || !sessionId) return undefined;
6115
+ const onMessage = (event) => {
6116
+ const frameEl = liveIframeElRef.current;
6117
+ if (!frameEl || !event.source || event.source !== frameEl.contentWindow) return;
6118
+ const payload = event.data;
6119
+ if (!payload || typeof payload !== "object" || payload.type !== "canvas-action") return;
6120
+ controller.submitCanvasAction(sessionId, payload).then((result) => {
6121
+ if (result && result.ok === false && typeof console !== "undefined") {
6122
+ console.warn(`canvas action rejected: ${result.reason}`);
6123
+ }
6124
+ }).catch(() => {});
6125
+ };
6126
+ window.addEventListener("message", onMessage);
6127
+ return () => window.removeEventListener("message", onMessage);
6128
+ }, [controller, sessionId]);
6129
+
6130
+ React.useEffect(() => {
6131
+ if (!controller || !sessionId || !latestRev) return undefined;
6132
+ let cancelled = false;
6133
+ let retryTimer = null;
6134
+ fetchArtifactHtmlObjectUrl(controller, sessionId, "canvas.html")
6135
+ .then((url) => {
6136
+ if (cancelled) { URL.revokeObjectURL(url); return; }
6137
+ // A staging revision that never loaded is superseded here —
6138
+ // revoke it, or every rapid redraw leaks a document-sized blob.
6139
+ if (urlsRef.current.staging && urlsRef.current.staging !== urlsRef.current.live) {
6140
+ URL.revokeObjectURL(urlsRef.current.staging);
6141
+ }
6142
+ urlsRef.current.staging = url;
6143
+ setLoadError(null);
6144
+ setStaging({ url, rev: latestRev });
6145
+ })
6146
+ .catch(() => {
6147
+ if (cancelled) return;
6148
+ // One quiet retry for transient blips; after that, surface it.
6149
+ // The next draw event or a pane re-open also refetches.
6150
+ if (autoRetryRef.current.rev !== latestRev) autoRetryRef.current = { rev: latestRev, used: false };
6151
+ if (!autoRetryRef.current.used) {
6152
+ autoRetryRef.current.used = true;
6153
+ retryTimer = setTimeout(() => { if (!cancelled) setRetryTick((t) => t + 1); }, 1500);
6154
+ return;
6155
+ }
6156
+ setLoadError({ rev: latestRev });
6157
+ });
6158
+ return () => {
6159
+ cancelled = true;
6160
+ if (retryTimer) clearTimeout(retryTimer);
6161
+ };
6162
+ }, [controller, sessionId, latestRev, retryTick]);
6163
+
6164
+ // Session switch / unmount: drop both frames — a different session is a
6165
+ // different document, and showing the old canvas under a new session is a
6166
+ // lie. Revocation goes through the ref, not state updaters.
6167
+ React.useEffect(() => () => {
6168
+ if (urlsRef.current.staging && urlsRef.current.staging !== urlsRef.current.live) {
6169
+ URL.revokeObjectURL(urlsRef.current.staging);
6170
+ }
6171
+ if (urlsRef.current.live) URL.revokeObjectURL(urlsRef.current.live);
6172
+ urlsRef.current = { live: null, staging: null };
6173
+ setLive(null);
6174
+ setStaging(null);
6175
+ setLoadError(null);
6176
+ }, [sessionId]);
6177
+
6178
+ const promote = React.useCallback((frame) => {
6179
+ // Only the CURRENT staging revision promotes — a load event from a
6180
+ // frame the fetch effect already superseded must not go live again.
6181
+ if (!frame || urlsRef.current.staging !== frame.url) return;
6182
+ const oldLive = urlsRef.current.live;
6183
+ urlsRef.current.live = frame.url;
6184
+ urlsRef.current.staging = null;
6185
+ if (oldLive && oldLive !== frame.url) URL.revokeObjectURL(oldLive);
6186
+ setLive(frame);
6187
+ setStaging((current) => (current && current.url === frame.url ? null : current));
6188
+ // Record the geometry this revision loaded at (in page-viewport
6189
+ // units, i.e. divided by zoom) — the settle effect below compares
6190
+ // against it to decide when a reload is owed.
6191
+ const hostEl = hostRef.current;
6192
+ if (hostEl) {
6193
+ const rect = hostEl.getBoundingClientRect();
6194
+ const z = zoomRef.current || 1;
6195
+ loadedBoxRef.current = { w: Math.round(rect.width / z), h: Math.round(rect.height / z) };
6196
+ }
6197
+ // The surface's receipt: THIS is the one place a revision is known to
6198
+ // be truly on screen, so this is where viewed-marking lives. Mode
6199
+ // transitions marking revs viewed claimed phones saw drawings their
6200
+ // layout never showed. A frame kept MOUNTED but hidden (the sticky
6201
+ // toggle) still promotes new revisions in the background — those are
6202
+ // NOT viewed; the badge must light until the user actually looks.
6203
+ if (controller && sessionId && visibleRef.current) {
6204
+ controller.dispatch({ type: "canvas/viewed", sessionId, rev: frame.rev });
6205
+ }
6206
+ // iOS: a programmatic focus into the freshly promoted frame is the
6207
+ // synchronous interaction that registers its scroller — the code
6208
+ // equivalent of the tap users discovered by accident. preventScroll
6209
+ // so the viewport never jumps. Phone-only (the desktop must not have
6210
+ // its composer focus stolen by a background draw).
6211
+ if (focusOnPromote && visibleRef.current) {
6212
+ try { liveIframeElRef.current?.focus({ preventScroll: true }); } catch { /* best effort */ }
6213
+ }
6214
+ }, [controller, sessionId, focusOnPromote]);
6215
+
6216
+ // The LLM→page half of the duplex: post the latest data tick INTO the
6217
+ // live frame. Keyed on BOTH the tick and the live frame identity, so a
6218
+ // freshly promoted document (redraw, reload, cold open) replays the
6219
+ // current state without the agent doing anything — the page just needs
6220
+ // one idempotent applyData(). Posting into a cross-origin frame is
6221
+ // allowed; a page with no listener ignores it.
6222
+ React.useEffect(() => {
6223
+ if (!live || !dataRev || dataPayload === null) return;
6224
+ try {
6225
+ liveIframeElRef.current?.contentWindow?.postMessage(
6226
+ { type: "canvas-data", data: dataPayload, dataRev }, "*",
6227
+ );
6228
+ } catch { /* frame mid-teardown */ }
6229
+ // On-screen injection = seen; hidden injection leaves the badge lit
6230
+ // until the user actually looks (the catch-up below files it then).
6231
+ if (controller && sessionId && visibleRef.current) {
6232
+ controller.dispatch({ type: "canvas/viewed", sessionId, dataRev });
6233
+ }
6234
+ }, [live, dataRev, dataPayload, controller, sessionId]);
6235
+
6236
+ // Catch-up receipt: becoming visible with a live revision on screen IS
6237
+ // viewing it — this is what clears a badge earned while hidden.
6238
+ React.useEffect(() => {
6239
+ if (visible && live && controller && sessionId) {
6240
+ controller.dispatch({ type: "canvas/viewed", sessionId, rev: live.rev, ...(dataRev ? { dataRev } : {}) });
6241
+ if (focusOnPromote) {
6242
+ try { liveIframeElRef.current?.focus({ preventScroll: true }); } catch { /* best effort */ }
6243
+ }
6244
+ }
6245
+ }, [visible, live, controller, sessionId, focusOnPromote, dataRev]);
6246
+
6247
+ // Settle-reload: once the pane's size (or zoom) stops moving somewhere
6248
+ // materially different from where the live page loaded, refetch the same
6249
+ // revision through the staging buffer so the page re-runs its layout at
6250
+ // the true viewport. Debounced past the resize gesture; a couple of px
6251
+ // of tolerance so subpixel wobble never churns it.
6252
+ React.useEffect(() => {
6253
+ if (!live || !visible) return undefined;
6254
+ const z = zoom || 1;
6255
+ const w = Math.round((hostBox.width || 0) / z);
6256
+ const h = Math.round((hostBox.height || 0) / z);
6257
+ if (!w || !h) return undefined;
6258
+ const loadedAt = loadedBoxRef.current;
6259
+ if (loadedAt) {
6260
+ // A page that loaded against a REAL box owns its layout from then
6261
+ // on and is NEVER auto-reloaded: the inner window receives genuine
6262
+ // resize events when this element changes size (sandboxing blocks
6263
+ // DOM poking, not resize), and a reload would cost a stateful app
6264
+ // — a game, a half-filled form — everything it holds, for a
6265
+ // purely cosmetic guarantee. Observed live: dragging the desktop
6266
+ // window reset a farm game to day 1. Settle-reload exists ONLY to
6267
+ // rescue documents that loaded against a degenerate box — the
6268
+ // iframe's 300×150 intrinsic default before layout, or a hidden
6269
+ // host — and baked that measurement in.
6270
+ const loadedDegenerate = loadedAt.w < 220 || loadedAt.h < 170;
6271
+ if (!loadedDegenerate) return undefined;
6272
+ const dw = Math.abs(loadedAt.w - w);
6273
+ const dh = Math.abs(loadedAt.h - h);
6274
+ if (dw <= 2 && dh <= 2) return undefined;
6275
+ // Height-only jitter is iOS toolbar churn (the URL bar collapses
6276
+ // and expands, ±6% or so) — a reload for that costs the user
6277
+ // their scroll position and page state for nothing.
6278
+ if (dw <= 2 && dh / Math.max(1, loadedAt.h) < 0.15) return undefined;
6279
+ }
6280
+ const timer = setTimeout(() => {
6281
+ loadedBoxRef.current = { w, h };
6282
+ setRetryTick((t) => t + 1);
6283
+ }, 350);
6284
+ return () => clearTimeout(timer);
6285
+ }, [hostBox.width, hostBox.height, zoom, live, visible]);
6286
+
6287
+ const zoomStyle = zoom !== 1 && hostBox.width > 0
6288
+ ? {
6289
+ flex: "none",
6290
+ width: `${Math.max(1, Math.round(hostBox.width / zoom))}px`,
6291
+ height: `${Math.max(1, Math.round(hostBox.height / zoom))}px`,
6292
+ transform: `scale(${zoom})`,
6293
+ transformOrigin: "top left",
6294
+ }
6295
+ : undefined;
6296
+
6297
+ return React.createElement("div", { ref: hostRef, className: "ps-html-preview-host is-fitting" },
6298
+ [live, staging].filter(Boolean).map((frame) => React.createElement("iframe", {
6299
+ key: `canvas:${frame.rev}:${frame.url}`,
6300
+ ref: frame === live ? ((el) => { liveIframeElRef.current = el; }) : undefined,
6301
+ className: `ps-html-preview-frame${frame === live ? "" : " is-staging"}`,
6302
+ style: zoomStyle,
6303
+ src: frame.url,
6304
+ title: "Session canvas",
6305
+ // Same sandbox contract as artifact previews — no allow-same-origin.
6306
+ sandbox: "allow-scripts allow-popups allow-popups-to-escape-sandbox",
6307
+ // The autoplay Permissions Policy defaults to `self`, and a
6308
+ // sandboxed frame has an OPAQUE origin — so a canvas page could
6309
+ // build an AudioContext, get no error, and emit nothing. Granting
6310
+ // autoplay is what lets a game's sound effects actually play.
6311
+ allow: "autoplay *",
6312
+ referrerPolicy: "no-referrer",
6313
+ onLoad: frame === staging ? () => promote(frame) : undefined,
6314
+ })),
6315
+ loadError
6316
+ ? React.createElement("div", { className: "ps-canvas-load-error" },
6317
+ React.createElement("span", null,
6318
+ live
6319
+ ? `Couldn't load rev ${loadError.rev} — showing rev ${live.rev}.`
6320
+ : `Couldn't load rev ${loadError.rev}.`),
6321
+ React.createElement("button", {
6322
+ type: "button",
6323
+ className: "ps-artifact-pane-btn",
6324
+ onClick: () => { autoRetryRef.current = { rev: 0, used: false }; setLoadError(null); setRetryTick((t) => t + 1); },
6325
+ }, "Retry"))
6326
+ : null);
6327
+ }
6328
+
6329
+ /**
6330
+ * Canvas mode for the right column: the session's standing display, rendered
6331
+ * from the reserved canvas.html artifact and converging live on
6332
+ * canvas_updated events. Replaces the inspector/activity split while active;
6333
+ * the artifact reader still takes precedence while open and returns here.
6334
+ */
6335
+ function CanvasPane({ controller, mobile = false, onClose = null, visible = true, focusOnPromote = false }) {
6336
+ const view = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
6337
+ const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
6338
+ const setZoom = React.useCallback((next) => {
6339
+ controller.dispatch({ type: "files/htmlZoom", zoom: next });
6340
+ }, [controller]);
6341
+
6342
+ // Belt to the selection-burst braces: a pane mounted before the burst's
6343
+ // snapshot resolves (deep link straight into canvas mode) fetches it here;
6344
+ // the memo makes the duplicate call free.
6345
+ React.useEffect(() => {
6346
+ if (view.sessionId) controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
6347
+ }, [controller, view.sessionId]);
6348
+
6349
+ const backToPanes = React.useCallback(() => {
6350
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: view.sessionId, manual: true });
6351
+ }, [controller, view.sessionId]);
6352
+ const close = onClose || (!mobile ? backToPanes : null);
6353
+
6354
+ return React.createElement("section", {
6355
+ className: "ps-artifact-pane ps-canvas-pane",
6356
+ role: "region",
6357
+ "aria-label": "Session canvas",
6358
+ },
6359
+ React.createElement("header", { className: "ps-artifact-pane-bar" },
6360
+ React.createElement("span", { className: "ps-artifact-pane-heading" },
6361
+ React.createElement("span", { className: "ps-artifact-pane-name ps-canvas-pane-glyph", title: view.note || "Canvas" },
6362
+ React.createElement(CanvasGlyph)),
6363
+ view.exists
6364
+ ? React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6365
+ `rev ${view.latestRev}`)
6366
+ : null),
6367
+ React.createElement("span", { className: "ps-artifact-pane-actions" },
6368
+ view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6369
+ close ? React.createElement("button", {
6370
+ type: "button",
6371
+ className: "ps-artifact-pane-btn",
6372
+ onClick: close,
6373
+ title: mobile ? "Close the canvas" : "Back to the inspector and activity panes",
6374
+ "aria-label": mobile ? "Close canvas" : "Back to panes",
6375
+ }, "✕") : null)),
6376
+ React.createElement("div", { className: "ps-artifact-pane-body" },
6377
+ view.exists
6378
+ ? React.createElement(CanvasFrame, {
6379
+ controller,
6380
+ sessionId: view.sessionId,
6381
+ latestRev: view.latestRev,
6382
+ zoom,
6383
+ visible,
6384
+ focusOnPromote,
6385
+ dataRev: view.latestDataRev,
6386
+ dataPayload: view.dataPayload,
6387
+ })
6388
+ : React.createElement("div", { className: "ps-canvas-blank" },
6389
+ React.createElement("p", { className: "ps-canvas-blank-title" }, "Nothing on the canvas yet."),
6390
+ React.createElement("p", null,
6391
+ "Ask the agent to draw — a dashboard, a chart, a diagram — or it will draw when it has something worth showing."))));
6392
+ }
6393
+
5967
6394
  function InspectorTabs({ activeTab, controller }) {
5968
6395
  const visibleTabs = React.useMemo(() => getVisibleInspectorTabs(controller), [controller]);
5969
6396
  // Icon-only tabs: the full label (e.g. "Node Map") lives in the IconButton
@@ -6410,7 +6837,7 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
6410
6837
  followBottom: state.ui.followBottom?.inspector !== false,
6411
6838
  logsTailing: state.logs.tailing,
6412
6839
  filesFullscreen: Boolean(state.files.fullscreen),
6413
- contentWidth: getPortalInspectorContentWidth(paneWidth, inspectorTab),
6840
+ contentWidth: getPortalInspectorContentWidth(paneWidth, inspectorTab, mobile),
6414
6841
  };
6415
6842
  }, shallowEqualObject);
6416
6843
  const selectorState = React.useMemo(() => ({
@@ -6465,10 +6892,13 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
6465
6892
  viewState.sessionsById,
6466
6893
  viewState.sessionsFlat,
6467
6894
  ]);
6895
+ // allowWideColumns let the sequence grid exceed the viewport so a phone
6896
+ // could pan it — in practice it rendered clipped on BOTH edges (the
6897
+ // timestamps and the STATS header sliced off) and read as broken. Fitting
6898
+ // to width collapses surplus lanes into "…" instead, which is legible.
6468
6899
  const inspector = React.useMemo(() => selectInspector(selectorState, {
6469
6900
  width: viewState.contentWidth,
6470
- allowWideColumns: mobile,
6471
- }), [mobile, selectorState, viewState.contentWidth]);
6901
+ }), [selectorState, viewState.contentWidth]);
6472
6902
  const completionByTurn = React.useMemo(() => {
6473
6903
  const history = viewState.activeSessionId
6474
6904
  ? viewState.historyBySessionId?.get(viewState.activeSessionId) || null
@@ -6499,6 +6929,54 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
6499
6929
  return () => window.clearInterval(timer);
6500
6930
  }, [controller, viewState.inspectorTab]);
6501
6931
 
6932
+ // Ground-truth clamp, phones only. The global column count comes from a
6933
+ // window-level probe that phones have repeatedly proven able to lie to
6934
+ // (pinch state, desktop-site layout viewports, probe-context fonts). The
6935
+ // pane itself cannot lie: it measures its own content box and its own
6936
+ // rendered line font, and when the published count disagrees materially it
6937
+ // republishes from those measurements. Runs after paint on mount and tab
6938
+ // switches; the ±2 tolerance keeps it from ping-ponging with publish().
6939
+ const clampFrameRef = React.useRef(null);
6940
+ React.useEffect(() => {
6941
+ if (!mobile) return undefined;
6942
+ clampFrameRef.current = requestAnimationFrame(() => {
6943
+ try {
6944
+ const panel = document.querySelector(".ps-mobile-pane-fill .ps-inspector-pane .ps-scroll-panel");
6945
+ const line = panel ? panel.querySelector(".ps-line") : null;
6946
+ if (!panel || !line) return;
6947
+ const cs = window.getComputedStyle(line);
6948
+ const probe = document.createElement("span");
6949
+ probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
6950
+ probe.style.fontFamily = cs.fontFamily;
6951
+ probe.style.fontSize = cs.fontSize;
6952
+ probe.style.fontWeight = cs.fontWeight;
6953
+ probe.style.letterSpacing = cs.letterSpacing;
6954
+ probe.textContent = "0".repeat(100);
6955
+ line.parentElement.appendChild(probe);
6956
+ const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
6957
+ probe.remove();
6958
+ const panelStyle = window.getComputedStyle(panel);
6959
+ const contentPx = panel.clientWidth
6960
+ - (parseFloat(panelStyle.paddingLeft) || 0)
6961
+ - (parseFloat(panelStyle.paddingRight) || 0);
6962
+ if (!(contentPx > 50)) return;
6963
+ const contentCols = Math.floor(contentPx / charWidth);
6964
+ // getPortalInspectorContentWidth hands the selector paneWidth-6
6965
+ // on mobile, so publishing contentCols+6 makes the selector see
6966
+ // exactly what the pane measured.
6967
+ const paneCols = contentCols + 6;
6968
+ const currentCols = controller.getState().ui.layout?.viewportWidth ?? 120;
6969
+ if (Math.abs(paneCols - currentCols) <= 2) return;
6970
+ controller.dispatch({ type: "ui/viewport", width: paneCols, height: null });
6971
+ } catch {
6972
+ // Measurement is best-effort; the publish path still stands.
6973
+ }
6974
+ });
6975
+ return () => {
6976
+ if (clampFrameRef.current) cancelAnimationFrame(clampFrameRef.current);
6977
+ };
6978
+ }, [controller, mobile, viewState.inspectorTab, viewState.activeSessionId]);
6979
+
6502
6980
  if (viewState.inspectorTab === "files" && !inspector.disabled) {
6503
6981
  return React.createElement(FilesPane, { controller, focused: viewState.focused, mobile });
6504
6982
  }
@@ -6705,51 +7183,6 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
6705
7183
  }, content);
6706
7184
  }
6707
7185
 
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
7186
  const EMPTY_ARRAY = Object.freeze([]);
6754
7187
 
6755
7188
  function formatAttachmentSize(sizeBytes) {
@@ -7226,7 +7659,7 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
7226
7659
  tooltipNode);
7227
7660
  }
7228
7661
 
7229
- function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus = null, chatFocusDisabled = false }) {
7662
+ function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvasOverlay = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
7230
7663
  const [headerSlot, setHeaderSlot] = React.useState(null);
7231
7664
  React.useEffect(() => {
7232
7665
  if (typeof document === "undefined") return;
@@ -7237,10 +7670,7 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
7237
7670
  setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
7238
7671
  }, [mobile]);
7239
7672
  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);
7673
+ const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
7244
7674
 
7245
7675
  // Icon-first toolbar: the glyph is the affordance, the label rides a
7246
7676
  // tooltip (desktop hover via title; mobile long-press via IconButton).
@@ -7267,24 +7697,38 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
7267
7697
  label: "Theme",
7268
7698
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
7269
7699
  },
7700
+ // Canvas toggle, both device classes: the desktop flips the right
7701
+ // column, the phone toggles a full-screen overlay — one affordance,
7702
+ // one badge. While the canvas
7703
+ // is hidden, an undisplayed revision lights the badge.
7270
7704
  {
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",
7705
+ key: "canvas",
7706
+ icon: React.createElement(CanvasGlyph),
7707
+ label: (mobile ? canvasOverlayOpen : canvasView.mode === "canvas")
7708
+ ? (mobile ? "Close the canvas" : "Show inspector and activity")
7709
+ : "Show canvas",
7710
+ onClick: () => {
7711
+ if (mobile) {
7712
+ if (onToggleCanvasOverlay) onToggleCanvasOverlay();
7713
+ return;
7714
+ }
7715
+ if (canvasView.mode === "canvas") {
7716
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: canvasView.sessionId, manual: true });
7717
+ } else {
7718
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasView.sessionId });
7719
+ }
7720
+ },
7721
+ active: mobile ? canvasOverlayOpen : canvasView.mode === "canvas",
7722
+ badge: mobile
7723
+ ? (canvasView.exists
7724
+ && (canvasView.latestRev > (canvasView.lastViewedRev || 0)
7725
+ || (canvasView.latestDataRev || 0) > (canvasView.lastViewedDataRev || 0))
7726
+ && !canvasOverlayOpen)
7727
+ : canvasView.unseen,
7728
+ // Three states: plain outline (empty), green dot bottom-left
7729
+ // (loaded), green + yellow (loaded with unseen changes).
7730
+ loadedDot: canvasView.exists,
7279
7731
  },
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
- }] : []),
7288
7732
  // Admin console is desktop-only: its settings tree, package detail
7289
7733
  // and file preview need width the phone layout cannot give them, so
7290
7734
  // the button is omitted rather than shipped half-working.
@@ -7297,22 +7741,56 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
7297
7741
  }]),
7298
7742
  ];
7299
7743
 
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
- });
7744
+ const renderButton = (def) => {
7745
+ const button = React.createElement(IconButton, {
7746
+ key: def.badge ? undefined : def.key,
7747
+ icon: def.icon,
7748
+ label: def.label,
7749
+ onClick: def.onClick,
7750
+ disabled: Boolean(def.disabled),
7751
+ active: Boolean(def.active),
7752
+ });
7753
+ // Defs with corner markers wrap in the positioning span: yellow
7754
+ // top-right = unseen changes, green bottom-left = canvas has content.
7755
+ return (def.badge || def.loadedDot)
7756
+ ? React.createElement("span", { key: def.key, className: "ps-canvas-toggle" },
7757
+ button,
7758
+ def.loadedDot ? React.createElement("span", { className: "ps-canvas-loaded-dot", "aria-hidden": "true" }) : null,
7759
+ def.badge ? React.createElement("span", { className: "ps-canvas-badge", "aria-hidden": "true" }) : null)
7760
+ : button;
7761
+ };
7308
7762
 
7309
7763
  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.
7764
+ // One row, two groups: actions left, the three VIEW MODES right —
7765
+ // they used to be word tabs pinned to the bottom of the screen, and
7766
+ // folding them up here hands that whole strip back to the chat pane.
7767
+ // The Main button is a CYCLE once you are already on Main: split →
7768
+ // chat only → sessions only. ONE glyph for all three states (a
7769
+ // layout/panes mark) — a shape-shifting icon reads as three different
7770
+ // buttons; only the tooltip names what the next tap gives you.
7771
+ const mainLabels = {
7772
+ split: "Main — sessions and chat (tap for chat only)",
7773
+ chat: "Main — chat only (tap for sessions only)",
7774
+ sessions: "Main — sessions only (tap for both)",
7775
+ };
7776
+ const paneDefs = [
7777
+ { id: "workspace", icon: MainLayoutGlyph, label: mainLabels[mobileMainLayout] || mainLabels.split, focus: "chat" },
7778
+ { id: "activity", icon: ActivityPaneGlyph, label: "Activity", focus: "activity" },
7779
+ { id: "inspector", icon: InspectorPaneGlyph, label: "Inspector", focus: "inspector" },
7780
+ ];
7313
7781
  return React.createElement("div", { className: "ps-toolbar is-mobile" },
7314
7782
  React.createElement("div", { className: "ps-toolbar-row ps-toolbar-row-primary" },
7315
- React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton))),
7783
+ React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton)),
7784
+ React.createElement("div", { className: "ps-toolbar-row-actions is-panes" },
7785
+ paneDefs.map((def) => React.createElement(IconButton, {
7786
+ key: def.id,
7787
+ icon: React.createElement(def.icon),
7788
+ label: def.label,
7789
+ active: mobilePane === def.id,
7790
+ onClick: () => {
7791
+ if (onSelectMobilePane) onSelectMobilePane(def.id, def.focus);
7792
+ },
7793
+ })))),
7316
7794
  );
7317
7795
  }
7318
7796
 
@@ -7329,6 +7807,30 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
7329
7807
  return toolbar;
7330
7808
  }
7331
7809
 
7810
+ /**
7811
+ * Pin a drag to the element that started it.
7812
+ *
7813
+ * Without capture, a resizer listens for pointerup on `window` — and never
7814
+ * hears it if the button is released over a CROSS-ORIGIN iframe. The artifact
7815
+ * reader is exactly that, and it sits directly beside the column resizer, so
7816
+ * releasing over a rendered artifact left the drag armed: the seam kept
7817
+ * following the pointer with no button held and the cursor stayed stuck in
7818
+ * col-resize.
7819
+ *
7820
+ * Pointer capture routes every subsequent event for this pointerId to the
7821
+ * capturing element regardless of what is underneath, iframes included. The
7822
+ * events still bubble to the window listeners the handles already use, so
7823
+ * this is additive.
7824
+ */
7825
+ function capturePointerForDrag(event) {
7826
+ try {
7827
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
7828
+ } catch {
7829
+ // Capture is best-effort; the body class below is the second line of
7830
+ // defence and the window listeners still work for same-origin drags.
7831
+ }
7832
+ }
7833
+
7332
7834
  function ColumnResizeHandle({ controller, paneAdjust = 0 }) {
7333
7835
  const dragStateRef = React.useRef(null);
7334
7836
  const [dragging, setDragging] = React.useState(false);
@@ -7386,6 +7888,7 @@ function ColumnResizeHandle({ controller, paneAdjust = 0 }) {
7386
7888
  onPointerDown: (event) => {
7387
7889
  if (event.button !== 0) return;
7388
7890
  event.preventDefault();
7891
+ capturePointerForDrag(event);
7389
7892
  dragStateRef.current = {
7390
7893
  startX: event.clientX,
7391
7894
  appliedCells: 0,
@@ -7458,6 +7961,7 @@ function RowResizeHandle({ controller, sessionPaneAdjust = 0 }) {
7458
7961
  onPointerDown: (event) => {
7459
7962
  if (event.button !== 0) return;
7460
7963
  event.preventDefault();
7964
+ capturePointerForDrag(event);
7461
7965
  dragStateRef.current = {
7462
7966
  startY: event.clientY,
7463
7967
  appliedCells: 0,
@@ -7534,6 +8038,7 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
7534
8038
  onPointerDown: (event) => {
7535
8039
  if (event.button !== 0) return;
7536
8040
  event.preventDefault();
8041
+ capturePointerForDrag(event);
7537
8042
  const gridNode = event.currentTarget.closest(".ps-workspace-main-grid");
7538
8043
  const bounds = portalSessionColumnBounds(gridNode?.getBoundingClientRect?.().width);
7539
8044
  dragStateRef.current = {
@@ -7579,6 +8084,36 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
7579
8084
  React.createElement("span", { className: "ps-column-resizer-dot" })));
7580
8085
  }
7581
8086
 
8087
+ /**
8088
+ * How many pixels the inspector/activity seam actually moves per row of adjust.
8089
+ *
8090
+ * The split is stored in character ROWS, but the column renders them as `fr`
8091
+ * — so one row of adjustment moves the seam by (column height / total rows)
8092
+ * pixels, whatever that happens to be. Dividing the pointer delta by a fixed
8093
+ * GRID_CELL_HEIGHT only tracks if the column is exactly rows × 19px tall, and
8094
+ * it is not: the column excludes the header and toolbar above it, and the row
8095
+ * height moves with the type scale. So the seam ran ahead of the pointer or
8096
+ * trailed behind it, and the error grew with the drag.
8097
+ *
8098
+ * Both operands are fixed for the duration of a drag — the split moves, the
8099
+ * total does not — so this is measured once at pointerdown rather than on
8100
+ * every pointermove.
8101
+ */
8102
+ function measureActivityRowPx(controller, handleEl) {
8103
+ try {
8104
+ const layout = controller.getCurrentLayout?.();
8105
+ const rows = (layout?.inspectorPaneHeight || 0) + (layout?.activityPaneHeight || 0);
8106
+ const column = handleEl?.closest?.(".ps-workspace-column");
8107
+ if (!column || rows <= 0) return GRID_CELL_HEIGHT;
8108
+ // The resizer's own track is not part of either pane's share.
8109
+ const usable = column.clientHeight - (handleEl.getBoundingClientRect().height || 0);
8110
+ const pxPerRow = usable / rows;
8111
+ return Number.isFinite(pxPerRow) && pxPerRow > 0 ? pxPerRow : GRID_CELL_HEIGHT;
8112
+ } catch {
8113
+ return GRID_CELL_HEIGHT;
8114
+ }
8115
+ }
8116
+
7582
8117
  function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
7583
8118
  const dragStateRef = React.useRef(null);
7584
8119
  const [dragging, setDragging] = React.useState(false);
@@ -7595,7 +8130,7 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
7595
8130
  const onPointerMove = (event) => {
7596
8131
  const dragState = dragStateRef.current;
7597
8132
  if (!dragState) return;
7598
- const deltaCells = Math.round((event.clientY - dragState.startY) / GRID_CELL_HEIGHT);
8133
+ const deltaCells = Math.round((event.clientY - dragState.startY) / dragState.pxPerRow);
7599
8134
  const deltaIncrement = deltaCells - dragState.appliedCells;
7600
8135
  if (!deltaIncrement) return;
7601
8136
  controller.adjustActivityPaneSplit(-deltaIncrement);
@@ -7622,9 +8157,12 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
7622
8157
  onPointerDown: (event) => {
7623
8158
  if (event.button !== 0) return;
7624
8159
  event.preventDefault();
8160
+ capturePointerForDrag(event);
7625
8161
  dragStateRef.current = {
7626
8162
  startY: event.clientY,
7627
8163
  appliedCells: 0,
8164
+ // MEASURED, not assumed — see measureActivityRowPx.
8165
+ pxPerRow: measureActivityRowPx(controller, event.currentTarget),
7628
8166
  };
7629
8167
  setDragging(true);
7630
8168
  document.body.classList.add("is-resizing-pane-y");
@@ -7651,23 +8189,6 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
7651
8189
  React.createElement("span", { className: "ps-row-resizer-dot" })));
7652
8190
  }
7653
8191
 
7654
- function MobileNav({ activePane, setActivePane, controller }) {
7655
- const tabs = [
7656
- { id: "workspace", label: "Main", focus: "chat" },
7657
- { id: "inspector", label: "Inspector", focus: "inspector" },
7658
- { id: "activity", label: "Activity", focus: "activity" },
7659
- ];
7660
- return React.createElement("div", { className: "ps-mobile-nav" },
7661
- tabs.map((tab) => React.createElement("button", {
7662
- key: tab.id,
7663
- type: "button",
7664
- className: `ps-mobile-nav-button${activePane === tab.id ? " is-active" : ""}`,
7665
- onClick: () => {
7666
- setActivePane(tab.id);
7667
- controller.setFocus(tab.focus);
7668
- },
7669
- }, tab.label)));
7670
- }
7671
8192
 
7672
8193
  function ModalLayer({ controller }) {
7673
8194
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
@@ -8089,7 +8610,7 @@ function ModalLayer({ controller }) {
8089
8610
  type: "button",
8090
8611
  className: "ps-modal-button is-primary",
8091
8612
  onClick: () => controller.handleCommand(UI_COMMANDS.MODAL_CONFIRM).catch(() => {}),
8092
- }, "Create and Move")),
8613
+ }, modalState.sessionGroupName.mode === "rename" ? "Rename" : "Create and Move")),
8093
8614
  ));
8094
8615
  }
8095
8616
  if (modal.type === "terminatePicker") {
@@ -8378,11 +8899,6 @@ function useKeyboardShortcuts(controller, mobile) {
8378
8899
  controller.handleCommand(UI_COMMANDS.REFRESH).catch(() => {});
8379
8900
  return;
8380
8901
  }
8381
- if (focusRegion === "chat" && event.key === "s" && isPlainShortcut) {
8382
- event.preventDefault();
8383
- controller.handleCommand(UI_COMMANDS.TOGGLE_CHAT_VIEW).catch(() => {});
8384
- return;
8385
- }
8386
8902
  if (event.key === "n" && isPlainShortcut) {
8387
8903
  event.preventDefault();
8388
8904
  controller.handleCommand(UI_COMMANDS.NEW_SESSION).catch(() => {});
@@ -9350,7 +9866,6 @@ export function createWebPilotSwarmController({ transport, mode = "remote", bran
9350
9866
  mode,
9351
9867
  branding,
9352
9868
  docs,
9353
- chatViewMode: isNarrowViewport ? "transcript" : "rich",
9354
9869
  }));
9355
9870
  return new PilotSwarmUiController({ store, transport });
9356
9871
  }
@@ -9369,21 +9884,42 @@ export function PilotSwarmWebApp({ controller }) {
9369
9884
  // device — phones kept getting desktop splash art narrower than ~76 cols.
9370
9885
  React.useEffect(() => {
9371
9886
  const publish = () => {
9372
- const probe = document.createElement("span");
9373
- probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
9374
- // Measure with the font real lines render in — an off-context
9375
- // probe can inherit a different size and skew the column count.
9376
- const sample = document.querySelector(".ps-scroll-panel .ps-line") || document.querySelector(".ps-line");
9377
- if (sample) probe.style.font = window.getComputedStyle(sample).font;
9378
- probe.textContent = "0".repeat(100);
9379
- (sample?.parentElement || document.body).appendChild(probe);
9380
- const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
9381
- probe.remove();
9382
- controller.dispatch({
9383
- type: "ui/viewport",
9384
- width: Math.floor(window.innerWidth / charWidth),
9385
- height: Math.floor(window.innerHeight / SCROLL_ROW_HEIGHT),
9386
- });
9887
+ try {
9888
+ const probe = document.createElement("span");
9889
+ probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
9890
+ // Measure with the font real lines render in — an off-context
9891
+ // probe can inherit a different size and skew the column count.
9892
+ // Longhands, not the `font` shorthand: Safari serializes the
9893
+ // shorthand as "" whenever the longhands do not reduce to a
9894
+ // canonical form, which silently reverted the probe to its
9895
+ // parent's font.
9896
+ const sample = document.querySelector(".ps-scroll-panel .ps-line") || document.querySelector(".ps-line");
9897
+ if (sample) {
9898
+ const cs = window.getComputedStyle(sample);
9899
+ probe.style.fontFamily = cs.fontFamily;
9900
+ probe.style.fontSize = cs.fontSize;
9901
+ probe.style.fontWeight = cs.fontWeight;
9902
+ probe.style.letterSpacing = cs.letterSpacing;
9903
+ }
9904
+ probe.textContent = "0".repeat(100);
9905
+ (sample?.parentElement || document.body).appendChild(probe);
9906
+ const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
9907
+ probe.remove();
9908
+ // Layout viewport, NOT window.innerWidth: on iOS innerWidth tracks
9909
+ // the VISUAL viewport, so a probe taken while pinch-zoomed (or with
9910
+ // the keyboard up) publishes a wildly wrong column count and the
9911
+ // sequence grid is built for a viewport that doesn't exist.
9912
+ const layoutWidth = document.documentElement.clientWidth || window.innerWidth;
9913
+ const layoutHeight = document.documentElement.clientHeight || window.innerHeight;
9914
+ controller.dispatch({
9915
+ type: "ui/viewport",
9916
+ width: Math.floor(layoutWidth / charWidth),
9917
+ height: Math.floor(layoutHeight / SCROLL_ROW_HEIGHT),
9918
+ });
9919
+ } catch {
9920
+ // A probe crash keeps the last published (or fallback) size;
9921
+ // the pane-clamp below self-corrects the inspector regardless.
9922
+ }
9387
9923
  };
9388
9924
  publish();
9389
9925
  let timer;
@@ -9396,8 +9932,9 @@ export function PilotSwarmWebApp({ controller }) {
9396
9932
  window.removeEventListener("orientationchange", onResize);
9397
9933
  };
9398
9934
  }, [controller]);
9399
- const [chatFocusMode, setChatFocusMode] = React.useState(false);
9400
- const [chatFocusPane, setChatFocusPane] = React.useState(null);
9935
+ // The phone's Main layout: split | chat | sessions (cycled by the Main
9936
+ // toolbar button). Desktop has real columns and needs no such cycle.
9937
+ const [mobileMainLayout, setMobileMainLayout] = React.useState("split");
9401
9938
  const state = useControllerSelector(controller, (rootState) => ({
9402
9939
  themeId: rootState.ui.themeId,
9403
9940
  touchScale: Boolean(rootState.ui.touchScale),
@@ -9413,7 +9950,10 @@ export function PilotSwarmWebApp({ controller }) {
9413
9950
  collapsedSessionIds: rootState.sessions.collapsedIds,
9414
9951
  activeSessionId: rootState.sessions.activeSessionId || null,
9415
9952
  promptRows: getStatePromptRows(rootState),
9416
- chatViewMode: rootState.ui.chatViewMode || "transcript",
9953
+ rightPaneMode: rootState.ui.rightPaneMode || "panes",
9954
+ // Live reference — reducer updates replace the object, so
9955
+ // shallow-equal sees changes and the save effect fires.
9956
+ canvasPrefs: rootState.canvas?.prefs,
9417
9957
  activeSessionIsGroup: Boolean(rootState.sessions.activeSessionId && rootState.sessions.byId[rootState.sessions.activeSessionId]?.isGroup),
9418
9958
  paneAdjust: rootState.ui.layout?.paneAdjust ?? 0,
9419
9959
  sessionPaneAdjust: rootState.ui.layout?.sessionPaneAdjust ?? 0,
@@ -9431,8 +9971,10 @@ export function PilotSwarmWebApp({ controller }) {
9431
9971
  // Last-known value of the OTHER device class's chat-view slot. The save
9432
9972
  // endpoint replaces the whole settings object, so this is what keeps a
9433
9973
  // desktop save from erasing the phone's preference.
9434
- const otherChatViewModeRef = React.useRef(null);
9435
9974
  const otherTouchScaleRef = React.useRef(null);
9975
+ // Last-known stored rightPaneMode — the desktop-owned slot a phone must
9976
+ // carry through its saves untouched (see profileSettingsFromViewState).
9977
+ const desktopRightPaneModeRef = React.useRef(null);
9436
9978
  const profileSettingsSaveTimerRef = React.useRef(null);
9437
9979
  const profileSettingsPollTimerRef = React.useRef(null);
9438
9980
  const profileSettingsPollInFlightRef = React.useRef(false);
@@ -9441,14 +9983,62 @@ export function PilotSwarmWebApp({ controller }) {
9441
9983
  const defaultProfileSettingsRef = React.useRef(null);
9442
9984
  const [mobilePane, setMobilePane] = React.useState("workspace");
9443
9985
  const mobile = (viewport.width || window.innerWidth || 0) < MOBILE_BREAKPOINT;
9444
- const readOnlyChatPane = state.activeSessionIsGroup || state.chatViewMode === "summary";
9986
+ const readOnlyChatPane = state.activeSessionIsGroup;
9445
9987
  const effectivePromptRows = readOnlyChatPane ? 0 : state.promptRows;
9446
9988
 
9447
9989
  useKeyboardShortcuts(controller, mobile);
9448
9990
 
9991
+ // The agent-driven canvas flip, phone edition. The desktop column follows
9992
+ // ui.rightPaneMode by itself; the phone's pane lives in local tab state,
9993
+ // so it follows the flip TICK instead (which fires even when the mode was
9994
+ // already "canvas" — e.g. a persisted mode with the user on Main).
9995
+ const canvasShellView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
9996
+ const [mobileCanvasOpen, setMobileCanvasOpen] = React.useState(false);
9997
+ // Once opened, the canvas stays MOUNTED (hidden) for the rest of the
9998
+ // session selection — page state (typed input, scroll, game progress)
9999
+ // survives toggling. A session switch remounts fresh via the key below.
10000
+ const [mobileCanvasEverOpened, setMobileCanvasEverOpened] = React.useState(false);
10001
+ const [desktopCanvasEverOpened, setDesktopCanvasEverOpened] = React.useState(false);
10002
+ React.useEffect(() => {
10003
+ setMobileCanvasOpen(false);
10004
+ setMobileCanvasEverOpened(false);
10005
+ setDesktopCanvasEverOpened(false);
10006
+ }, [canvasShellView.sessionId]);
10007
+ // iOS scroll-chaining: while the overlay is up, a pan that Safari decides
10008
+ // is not the iframe's must not scroll the app shell behind it.
10009
+ React.useEffect(() => {
10010
+ if (typeof document === "undefined") return undefined;
10011
+ document.body.classList.toggle("ps-canvas-lock", Boolean(mobile && mobileCanvasOpen));
10012
+ return () => document.body.classList.remove("ps-canvas-lock");
10013
+ }, [mobile, mobileCanvasOpen]);
10014
+ const canvasFlipSeqRef = React.useRef(canvasShellView.flipSeq);
9449
10015
  React.useEffect(() => {
9450
- controller.setViewport(gridViewport);
9451
- }, [controller, gridViewport.height, gridViewport.width]);
10016
+ if (canvasShellView.flipSeq === canvasFlipSeqRef.current) return;
10017
+ canvasFlipSeqRef.current = canvasShellView.flipSeq;
10018
+ if (mobile) { setMobileCanvasOpen(true); setMobileCanvasEverOpened(true); }
10019
+ }, [canvasShellView.flipSeq, mobile]);
10020
+ const closeMobileCanvas = React.useCallback(() => {
10021
+ setMobileCanvasOpen(false);
10022
+ // Mirror the desktop ✕ exactly: a deliberate close is the opt-out
10023
+ // gesture — later draws badge the toggle instead of re-covering the
10024
+ // screen, and reopening clears the opt-out.
10025
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: canvasShellView.sessionId, manual: true });
10026
+ }, [controller, canvasShellView.sessionId]);
10027
+ const toggleMobileCanvas = React.useCallback(() => {
10028
+ if (mobileCanvasOpen) { closeMobileCanvas(); return; }
10029
+ setMobileCanvasOpen(true);
10030
+ setMobileCanvasEverOpened(true);
10031
+ // Entering by hand un-opts-out, same as the desktop toggle.
10032
+ controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasShellView.sessionId });
10033
+ }, [mobileCanvasOpen, closeMobileCanvas, controller, canvasShellView.sessionId]);
10034
+
10035
+ // gridViewport used to be PUBLISHED here via controller.setViewport — a
10036
+ // second writer to ui.layout that derived columns from the hard-coded
10037
+ // GRID_CELL_WIDTH instead of the rendered font. The two publishers fought
10038
+ // (last writer wins), which is how a phone ended up laying out its
10039
+ // inspector for a column count no probe ever measured. The font-probe
10040
+ // effect is the only publisher now; gridViewport stays local to the
10041
+ // desktop pane splitter below.
9452
10042
 
9453
10043
  React.useEffect(() => {
9454
10044
  let active = true;
@@ -9487,14 +10077,14 @@ export function PilotSwarmWebApp({ controller }) {
9487
10077
  // so the "no persisted filter" fallback becomes the correct
9488
10078
  // principal-scoped default (Me+System) instead of {all:true}.
9489
10079
  const remoteNormalized = normalizeProfileSettings(profile?.profileSettings);
9490
- if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
9491
- otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
9492
- }
9493
10080
  if (typeof remoteNormalized[otherTouchScaleKey()] === "boolean") {
9494
10081
  otherTouchScaleRef.current = remoteNormalized[otherTouchScaleKey()];
9495
10082
  }
10083
+ if (remoteNormalized.rightPaneMode === "canvas" || remoteNormalized.rightPaneMode === "panes") {
10084
+ desktopRightPaneModeRef.current = remoteNormalized.rightPaneMode;
10085
+ }
9496
10086
  defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
9497
- controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
10087
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
9498
10088
  );
9499
10089
  const settings = materializeProfileSettings(
9500
10090
  profile?.profileSettings,
@@ -9502,7 +10092,7 @@ export function PilotSwarmWebApp({ controller }) {
9502
10092
  );
9503
10093
  const settingsJson = JSON.stringify(settings);
9504
10094
  const currentSettingsBeforeApply = profileSettingsFromViewState(
9505
- controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
10095
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
9506
10096
  );
9507
10097
  const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
9508
10098
  const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
@@ -9529,7 +10119,7 @@ export function PilotSwarmWebApp({ controller }) {
9529
10119
  // remote state by definition and there is nothing to lose).
9530
10120
  if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
9531
10121
  const currentSettings = profileSettingsFromViewState(
9532
- controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
10122
+ controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
9533
10123
  );
9534
10124
  lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
9535
10125
  }
@@ -9563,7 +10153,7 @@ export function PilotSwarmWebApp({ controller }) {
9563
10153
 
9564
10154
  React.useEffect(() => {
9565
10155
  if (!profileSettingsHydratedRef.current) return undefined;
9566
- const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current, otherTouchScaleRef.current);
10156
+ const settings = profileSettingsFromViewState(state, otherTouchScaleRef.current, desktopRightPaneModeRef.current);
9567
10157
  const settingsJson = JSON.stringify(settings);
9568
10158
  if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
9569
10159
  lastProfileSettingsJsonRef.current = settingsJson;
@@ -9580,7 +10170,7 @@ export function PilotSwarmWebApp({ controller }) {
9580
10170
  });
9581
10171
  }, 400);
9582
10172
  return undefined;
9583
- }, [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]);
10173
+ }, [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]);
9584
10174
 
9585
10175
  React.useEffect(() => {
9586
10176
  applyDocumentTheme(state.themeId);
@@ -9600,12 +10190,13 @@ export function PilotSwarmWebApp({ controller }) {
9600
10190
  lastFocusRef.current = state.focusRegion;
9601
10191
  if (previous === null) return;
9602
10192
  if (previous === state.focusRegion) return;
9603
- if (mobile && state.focusRegion !== "prompt") {
9604
- setMobilePane(state.focusRegion === "activity"
9605
- ? "activity"
9606
- : state.focusRegion === "inspector"
9607
- ? "inspector"
9608
- : "workspace");
10193
+ // Follow focus ONLY into the two panes that are nothing but focus
10194
+ // targets. Mapping chat/sessions/prompt back to Main fought the
10195
+ // toolbar: the sessions-only layout's list reclaims focus, which
10196
+ // bounced the user out of Inspector the instant they tapped it.
10197
+ // Returning to Main is the Main button's job now.
10198
+ if (mobile && (state.focusRegion === "inspector" || state.focusRegion === "activity")) {
10199
+ setMobilePane(state.focusRegion);
9609
10200
  }
9610
10201
  }, [mobile, state.focusRegion]);
9611
10202
 
@@ -9635,6 +10226,16 @@ export function PilotSwarmWebApp({ controller }) {
9635
10226
  && Boolean(state.selectedArtifactId)
9636
10227
  && !filesFullscreenActive;
9637
10228
 
10229
+ // Canvas mode claims the column beneath the reader: the reader still takes
10230
+ // precedence while open, and its ✕ leaves rightPaneMode untouched — so
10231
+ // closing a preview returns to the canvas, restore-what-was-displaced.
10232
+ const canvasModeActive = !mobile
10233
+ && state.rightPaneMode === "canvas"
10234
+ && !filesFullscreenActive;
10235
+ React.useEffect(() => {
10236
+ if (canvasModeActive) setDesktopCanvasEverOpened(true);
10237
+ }, [canvasModeActive]);
10238
+
9638
10239
  // Closing hands the column back — or collapses it again, if the user had
9639
10240
  // already resized it away before opening the pane. Both cases live in the
9640
10241
  // controller so the decision is testable without a DOM.
@@ -9642,32 +10243,6 @@ export function PilotSwarmWebApp({ controller }) {
9642
10243
  controller.closeArtifactPane().catch(() => {});
9643
10244
  }, [controller]);
9644
10245
 
9645
- React.useEffect(() => {
9646
- if (!filesFullscreenActive || !chatFocusMode) return;
9647
- setChatFocusMode(false);
9648
- setChatFocusPane(null);
9649
- }, [chatFocusMode, filesFullscreenActive]);
9650
-
9651
- const toggleChatFocusMode = React.useCallback(() => {
9652
- setChatFocusMode((current) => {
9653
- const next = !current;
9654
- if (!next) {
9655
- setChatFocusPane(null);
9656
- } else {
9657
- controller.setFocus("chat");
9658
- }
9659
- return next;
9660
- });
9661
- }, [controller]);
9662
-
9663
- const toggleChatFocusPane = React.useCallback((paneId) => {
9664
- setChatFocusPane((current) => {
9665
- const next = current === paneId ? null : paneId;
9666
- controller.setFocus(next || "chat");
9667
- return next;
9668
- });
9669
- }, [controller]);
9670
-
9671
10246
  const estimatedMainGridWidth = Math.max(0, (viewport.width || 0) * 0.68);
9672
10247
  const measuredMainGridWidth = mainGridViewport.width || estimatedMainGridWidth;
9673
10248
  const sessionColumnWidth = portalSessionColumnWidth(measuredMainGridWidth, state.portalSessionColumnAdjust);
@@ -9711,6 +10286,7 @@ export function PilotSwarmWebApp({ controller }) {
9711
10286
  // The artifact reader takes the WHOLE right column — inspector, resizer and
9712
10287
  // activity all step aside. The column keeps its width and resizer, so the
9713
10288
  // chat/reader split is dragged exactly like the chat/inspector one was.
10289
+ React.createElement("div", { className: "ps-workspace-column-host" },
9714
10290
  artifactPaneActive
9715
10291
  ? React.createElement("div", { className: "ps-workspace-column is-artifact" },
9716
10292
  React.createElement(ArtifactTakeoverPane, { controller, onClose: closeArtifactPane }))
@@ -9741,14 +10317,19 @@ export function PilotSwarmWebApp({ controller }) {
9741
10317
  // It yields back to Activity the moment the selection clears.
9742
10318
  artifactPreviewDetached
9743
10319
  ? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
9744
- : (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null))));
9745
- const chatFocusWorkspace = React.createElement(ChatFocusWorkspace, {
9746
- controller,
9747
- openPane: chatFocusPane,
9748
- onTogglePane: toggleChatFocusPane,
9749
- onExitFocus: toggleChatFocusMode,
9750
- mobile,
9751
- });
10320
+ : (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null))),
10321
+ // The canvas keeps its DOCUMENT alive across toggles: once opened for
10322
+ // this session it stays mounted in a hidden layer — unmounting destroys
10323
+ // typed input, scroll position, and any running page. Session switches
10324
+ // remount fresh via the key.
10325
+ desktopCanvasEverOpened
10326
+ ? React.createElement("div", {
10327
+ key: `canvas:${state.activeSessionId || ""}`,
10328
+ className: `ps-workspace-column is-artifact ps-canvas-layer${canvasModeActive && !artifactPaneActive ? "" : " is-hidden"}`,
10329
+ ...(canvasModeActive && !artifactPaneActive ? {} : { inert: true, "aria-hidden": "true" }),
10330
+ },
10331
+ React.createElement(CanvasPane, { controller, visible: canvasModeActive && !artifactPaneActive }))
10332
+ : null));
9752
10333
  const fullscreenWorkspace = React.createElement("div", { className: "ps-workspace-full" },
9753
10334
  React.createElement(InspectorPane, { controller, mobile: false }));
9754
10335
 
@@ -9771,36 +10352,48 @@ export function PilotSwarmWebApp({ controller }) {
9771
10352
  React.createElement(InspectorPane, { controller, mobile: true }));
9772
10353
  else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
9773
10354
  React.createElement(ActivityPane, { controller }));
9774
- else mobileContent = React.createElement(MobileWorkspace, { controller });
10355
+ else mobileContent = React.createElement(MobileWorkspace, { controller, layout: mobileMainLayout });
9775
10356
 
9776
10357
  return React.createElement(ControllerContext.Provider, { value: controller },
9777
10358
  React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
9778
- // Hide the top toolbar on mobile inspector/activity panes (and in
9779
- // chat-focus mode). Those panes are pure read-only surfaces;
9780
- // session/model/theme actions stay reachable from the Main pane,
9781
- // and dropping the toolbar buys ~3 lines of vertical real estate
9782
- // on a phone, which matters for the fleet-skills card and the
9783
- // logs/sequence inspectors.
9784
- (mobile && (chatFocusMode || mobilePane === "inspector" || mobilePane === "activity"))
9785
- ? null
9786
- : React.createElement(Toolbar, {
10359
+ // The phone's toolbar carries its NAVIGATION (the three view modes,
10360
+ // plus the Main layout cycle), so it renders on every pane — hiding it
10361
+ // anywhere would trap the user with no way back.
10362
+ React.createElement(Toolbar, {
9787
10363
  controller,
9788
10364
  mobile,
9789
- chatFocusMode,
9790
- onToggleChatFocus: toggleChatFocusMode,
9791
- chatFocusDisabled: filesFullscreenActive,
10365
+ canvasOverlayOpen: mobileCanvasOpen,
10366
+ onToggleCanvasOverlay: toggleMobileCanvas,
10367
+ mobilePane,
10368
+ mobileMainLayout,
10369
+ onSelectMobilePane: (paneId, focus) => {
10370
+ // Tapping Main while already on Main cycles its layout; from
10371
+ // another pane it just returns you to Main, unchanged.
10372
+ if (paneId === "workspace" && mobilePane === "workspace") {
10373
+ setMobileMainLayout((current) => (
10374
+ current === "split" ? "chat" : current === "chat" ? "sessions" : "split"
10375
+ ));
10376
+ }
10377
+ setMobilePane(paneId);
10378
+ if (focus) controller.setFocus(focus);
10379
+ },
9792
10380
  }),
9793
10381
  React.createElement("div", { className: "ps-workspace" },
9794
10382
  state.adminVisible
9795
10383
  ? React.createElement(AdminConsolePanel, { controller, mobile })
9796
10384
  : (filesFullscreenActive
9797
10385
  ? fullscreenWorkspace
9798
- : (chatFocusMode
9799
- ? chatFocusWorkspace
9800
- : (mobile ? mobileContent : desktopWorkspace)))),
9801
- mobile && !chatFocusMode ? React.createElement(MobileNav, { activePane: mobilePane, setActivePane: setMobilePane, controller }) : null,
10386
+ : (mobile ? mobileContent : desktopWorkspace))),
9802
10387
  mobile && filesFullscreenActive
9803
10388
  ? React.createElement(MobileArtifactOverlay, { controller })
9804
10389
  : null,
10390
+ mobile && mobileCanvasEverOpened
10391
+ ? React.createElement(MobileCanvasOverlay, {
10392
+ key: `canvas:${canvasShellView.sessionId || ""}`,
10393
+ controller,
10394
+ onClose: closeMobileCanvas,
10395
+ visible: mobileCanvasOpen,
10396
+ })
10397
+ : null,
9805
10398
  React.createElement(ModalLayer, { controller })));
9806
10399
  }