pilotswarm 0.5.39 → 0.5.41

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/node-sdk-transport.js +25 -0
  3. package/ui/core/src/controller.js +34 -8
  4. package/ui/core/src/history.js +5 -1
  5. package/ui/core/src/index.js +1 -0
  6. package/ui/core/src/portal-links.js +46 -0
  7. package/ui/core/src/reducer.js +88 -2
  8. package/ui/core/src/selectors.js +2 -0
  9. package/ui/react/src/web-app.js +358 -36
  10. package/web/README.md +28 -0
  11. package/web/api/canvas-plane.js +116 -0
  12. package/web/api/ws.js +120 -5
  13. package/web/config.js +60 -0
  14. package/web/dist/assets/{abnfDiagram-VRR7QNED-Hsly1fgf.js → abnfDiagram-VRR7QNED-kpxu_PEa.js} +1 -1
  15. package/web/dist/assets/{arc-DiQLL9vM.js → arc-BnVQmtB5.js} +1 -1
  16. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-B8ry8WNZ.js → architectureDiagram-ZJ3FMSHR-CDynQ_6_.js} +1 -1
  17. package/web/dist/assets/{blockDiagram-677ZJIJ3-Z4PcukIo.js → blockDiagram-677ZJIJ3-BUpYaSDb.js} +1 -1
  18. package/web/dist/assets/{c4Diagram-LMCZKHZV-Cahkc0gc.js → c4Diagram-LMCZKHZV-ZL8eskDB.js} +1 -1
  19. package/web/dist/assets/channel-Bi5A6sHR.js +1 -0
  20. package/web/dist/assets/{chunk-2Q5K7J3B-CtZuWH4J.js → chunk-2Q5K7J3B-o0Nsyp1T.js} +1 -1
  21. package/web/dist/assets/{chunk-32BRIVSS-C0M390-k.js → chunk-32BRIVSS-CH5CSNRP.js} +1 -1
  22. package/web/dist/assets/{chunk-5VM5RSS4-ClvX9u5j.js → chunk-5VM5RSS4-C9wwtPWL.js} +1 -1
  23. package/web/dist/assets/{chunk-EX3LRPZG-BVAet5KG.js → chunk-EX3LRPZG--F9yFbhA.js} +1 -1
  24. package/web/dist/assets/{chunk-JWPE2WC7-BccmZmrr.js → chunk-JWPE2WC7-BPIstyWL.js} +1 -1
  25. package/web/dist/assets/{chunk-MOJQB5TN-BN5MKe94.js → chunk-MOJQB5TN-5Dm2u-0k.js} +1 -1
  26. package/web/dist/assets/{chunk-RYQCIY6F-DfWw-ACP.js → chunk-RYQCIY6F-Dbtf9iQK.js} +1 -1
  27. package/web/dist/assets/{chunk-V7JOEXUC-BgUEoLnN.js → chunk-V7JOEXUC-DrZEBJ5l.js} +1 -1
  28. package/web/dist/assets/{chunk-VR4S4FIN-k1eKfFNA.js → chunk-VR4S4FIN-AqAUynqK.js} +1 -1
  29. package/web/dist/assets/{chunk-XXDRQBXY-DPyV10yR.js → chunk-XXDRQBXY-DQlFaqCy.js} +1 -1
  30. package/web/dist/assets/classDiagram-OUVF2IWQ-D8dCxI54.js +1 -0
  31. package/web/dist/assets/classDiagram-v2-EOCWNBFH-D8dCxI54.js +1 -0
  32. package/web/dist/assets/{cose-bilkent-JH36ORCC-Bt_rpukK.js → cose-bilkent-JH36ORCC-RAtPG1Ou.js} +1 -1
  33. package/web/dist/assets/{cynefin-VYW2F7L2-Z8eMzFMv.js → cynefin-VYW2F7L2-CbeHaUWZ.js} +1 -1
  34. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BJSNawFi.js → cynefinDiagram-TSTJHNR4-B6paqF5Y.js} +1 -1
  35. package/web/dist/assets/{dagre-VKFMJZFB-D6dNl8jc.js → dagre-VKFMJZFB-DziTBO2K.js} +1 -1
  36. package/web/dist/assets/{diagram-FQU43EPY-Cy1nbGsU.js → diagram-FQU43EPY-CPRUiLcr.js} +1 -1
  37. package/web/dist/assets/{diagram-G47NLZAW-CmrbuA_d.js → diagram-G47NLZAW-CIMVdcL1.js} +1 -1
  38. package/web/dist/assets/{diagram-NH7WQ7WH-PoLNQE2j.js → diagram-NH7WQ7WH-CHPFllxR.js} +1 -1
  39. package/web/dist/assets/{diagram-OA4YK3LP-BZwNFcSV.js → diagram-OA4YK3LP-DwS6unBJ.js} +1 -1
  40. package/web/dist/assets/{diagram-WEI45ONY-pKw0b_fN.js → diagram-WEI45ONY-DKWgkObI.js} +1 -1
  41. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-UNctuQCV.js → ebnfDiagram-CCIWWBDH-zAVWV6-D.js} +1 -1
  42. package/web/dist/assets/{erDiagram-Q63AITRT-DxIETMif.js → erDiagram-Q63AITRT-D0tYX4h0.js} +1 -1
  43. package/web/dist/assets/{flowDiagram-23GEKE2U-C06IdNKV.js → flowDiagram-23GEKE2U-Cfq4I7R0.js} +1 -1
  44. package/web/dist/assets/{ganttDiagram-NO4QXBWP-fU7aGyP1.js → ganttDiagram-NO4QXBWP-wgBAHr5I.js} +1 -1
  45. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-9UKkPJm6.js → gitGraphDiagram-IHSO6WYX-BTIPX5PR.js} +1 -1
  46. package/web/dist/assets/index-OHj-gGHp.js +24 -0
  47. package/web/dist/assets/index-eTH1FBtN.css +1 -0
  48. package/web/dist/assets/{infoDiagram-FWYZ7A6U-C6F06SQJ.js → infoDiagram-FWYZ7A6U-CRLiQ87D.js} +1 -1
  49. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CB-O9NfO.js → ishikawaDiagram-FXEZZL3T-CwzwUis1.js} +1 -1
  50. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B-8MOf-H.js → journeyDiagram-5HDEW3XC-B9pqMeFw.js} +1 -1
  51. package/web/dist/assets/{kanban-definition-HUTT4EX6-DX0XA3hI.js → kanban-definition-HUTT4EX6-CZ5Xky4p.js} +1 -1
  52. package/web/dist/assets/{linear-D60PrjTW.js → linear-BLZ2eH4a.js} +1 -1
  53. package/web/dist/assets/{mermaid.core-bonzwapf.js → mermaid.core-CPN1IVjz.js} +4 -4
  54. package/web/dist/assets/{mindmap-definition-LN4V7U3C-Y-6Dum33.js → mindmap-definition-LN4V7U3C-MdC8d2tc.js} +1 -1
  55. package/web/dist/assets/{papaparse.min-Cas1JfRG.js → papaparse.min-CtrVJupf.js} +1 -1
  56. package/web/dist/assets/{pegDiagram-2B236MQR-_paO0yDw.js → pegDiagram-2B236MQR-B2DqAc-A.js} +1 -1
  57. package/web/dist/assets/{pieDiagram-ENE6RG2P-xT1_Gpn4.js → pieDiagram-ENE6RG2P-C3QAoXZh.js} +1 -1
  58. package/web/dist/assets/pilotswarm-CF1nmpMy.js +97 -0
  59. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-1usdLFOF.js → quadrantDiagram-ABIIQ3AL-BoUr5qFH.js} +1 -1
  60. package/web/dist/assets/{railroadDiagram-RFXS5EU6-R3lwHOz9.js → railroadDiagram-RFXS5EU6-bZzE4KFN.js} +1 -1
  61. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BL8J1__Z.js → requirementDiagram-TGXJPOKE-B2ENAqLS.js} +1 -1
  62. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Dz6--0pl.js → sankeyDiagram-HTMAVEWB-AJZ3h46Z.js} +1 -1
  63. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CG2Xpkct.js → sequenceDiagram-DBY2YBRQ-QUf0VVE7.js} +1 -1
  64. package/web/dist/assets/{sizeCapture-X5ZJPWSS-BPbYfjxj.js → sizeCapture-X5ZJPWSS-Cv9RcUy6.js} +1 -1
  65. package/web/dist/assets/{stateDiagram-2N3HPSRC-46x_y_aA.js → stateDiagram-2N3HPSRC-hDiye45t.js} +1 -1
  66. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Bu4ufvaU.js +1 -0
  67. package/web/dist/assets/{swimlanes-5IMT3BWC-SXdMo4zJ.js → swimlanes-5IMT3BWC-hSk_OF85.js} +2 -2
  68. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTFePb1Y.js +8 -0
  69. package/web/dist/assets/{timeline-definition-FHXFAJF6-CGgT-b38.js → timeline-definition-FHXFAJF6-ccOZEJ5e.js} +1 -1
  70. package/web/dist/assets/{vennDiagram-L72KCM5P-BGS3xBa_.js → vennDiagram-L72KCM5P-Bk5DLH_V.js} +1 -1
  71. package/web/dist/assets/{wardleyDiagram-EHGQE667-m2U3wEZJ.js → wardleyDiagram-EHGQE667-C20AX8ot.js} +1 -1
  72. package/web/dist/assets/{xychartDiagram-FW5EYKEG-DI6tQysg.js → xychartDiagram-FW5EYKEG-DaHqmi-R.js} +1 -1
  73. package/web/dist/index.html +3 -3
  74. package/web/runtime.js +71 -0
  75. package/web/server.js +51 -2
  76. package/web/dist/assets/channel-BHLBPE50.js +0 -1
  77. package/web/dist/assets/classDiagram-OUVF2IWQ-rj9D2TSL.js +0 -1
  78. package/web/dist/assets/classDiagram-v2-EOCWNBFH-rj9D2TSL.js +0 -1
  79. package/web/dist/assets/index-B8nGlHwb.js +0 -24
  80. package/web/dist/assets/index-DtL0lbis.css +0 -1
  81. package/web/dist/assets/pilotswarm-BT6tOknQ.js +0 -96
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-C2tnolSo.js +0 -1
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-Dh6Sn__t.js +0 -8
@@ -12,6 +12,8 @@ import {
12
12
  canStopSessionTurn,
13
13
  computeLegacyLayout,
14
14
  createInitialState,
15
+ buildPortalLinks,
16
+ formatPortalLinksForCopy,
15
17
  createStore,
16
18
  AUTO_HISTORY_EVENT_SOFT_CAP,
17
19
  formatCompactNumber,
@@ -174,8 +176,21 @@ function buildArtifactLinkUrl(sessionId, filename, { fullscreen = true } = {}) {
174
176
  return `${base}&artifact=${encodeURIComponent(filename)}${fullscreen ? "&view=full" : ""}`;
175
177
  }
176
178
 
179
+ // Multi-origin link generation (PORTAL_LINK_ORIGINS): set once by the host
180
+ // app when the portal config lands. Empty = single-origin behavior. A module
181
+ // singleton, deliberately — links are produced from deep inside component
182
+ // trees and the origin list is global, immutable config.
183
+ let portalLinkOrigins = [];
184
+ export function setPortalLinkOrigins(list) {
185
+ portalLinkOrigins = Array.isArray(list) ? list : [];
186
+ }
187
+ export function getPortalLinkOrigins() {
188
+ return portalLinkOrigins;
189
+ }
190
+
177
191
  function copySessionLinkText(url) {
178
- if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(url).catch(() => {});
192
+ const text = formatPortalLinksForCopy(buildPortalLinks(url, portalLinkOrigins));
193
+ if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(text).catch(() => {});
179
194
  }
180
195
 
181
196
  // Best-effort probe: is this session's deep link openable only by the owner/
@@ -634,6 +649,77 @@ function getPortalInspectorContentWidth(paneWidth, inspectorTab, mobile = false)
634
649
  return Math.max(20, paneWidth - (mobile ? 6 : 4));
635
650
  }
636
651
 
652
+ /**
653
+ * The pane's own truth for content width, in character columns.
654
+ *
655
+ * Character-cell panes (chat code fences and tables, the sequence grid, the
656
+ * stats boxes) were sized from the legacy TUI layout model's split of a
657
+ * window-level probe — a model that knows nothing about the portal's
658
+ * draggable grid columns, so its imagined pane widths drift arbitrarily far
659
+ * from the real ones. Content rendered for the imagined width then spills
660
+ * out of the real pane (sequence), arrives pre-sliced behind a sideways
661
+ * scroll (stats), or wraps absurdly narrow (a JSON fence at ~19 columns).
662
+ *
663
+ * This hook measures the actual panel node: its content box, and a probe in
664
+ * its own rendered line font (the same technique as the mobile inspector
665
+ * clamp below, which proved the window-level probe "able to lie"). Returns
666
+ * null until the first measurement lands — callers fall back to the legacy
667
+ * width for that first paint. Re-measures on real size changes, trailing
668
+ * debounced so a splitter drag re-wraps once at the end, not per tick.
669
+ */
670
+ function useMeasuredPaneColumns({ slack = 1, debounceMs = 120 } = {}) {
671
+ const [cols, setCols] = React.useState(null);
672
+ const stateRef = React.useRef({ node: null, observer: null, timer: null, frame: null });
673
+ const attachRef = React.useCallback((node) => {
674
+ const st = stateRef.current;
675
+ if (st.observer) { st.observer.disconnect(); st.observer = null; }
676
+ if (st.timer) { window.clearTimeout(st.timer); st.timer = null; }
677
+ if (st.frame) { cancelAnimationFrame(st.frame); st.frame = null; }
678
+ st.node = node;
679
+ if (!node || typeof window === "undefined" || typeof ResizeObserver === "undefined") return;
680
+ const measure = () => {
681
+ st.timer = null;
682
+ if (st.node !== node || !node.isConnected) return;
683
+ const target = node.querySelector(".ps-line") || node;
684
+ const cs = window.getComputedStyle(target);
685
+ const probe = document.createElement("span");
686
+ probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
687
+ probe.style.fontFamily = cs.fontFamily;
688
+ probe.style.fontSize = cs.fontSize;
689
+ probe.style.fontWeight = cs.fontWeight;
690
+ probe.style.letterSpacing = cs.letterSpacing;
691
+ probe.textContent = "0".repeat(100);
692
+ node.appendChild(probe);
693
+ const charWidth = (probe.getBoundingClientRect().width / 100) || 0;
694
+ probe.remove();
695
+ if (!(charWidth > 0)) return;
696
+ const panelStyle = window.getComputedStyle(node);
697
+ const contentPx = node.clientWidth
698
+ - (parseFloat(panelStyle.paddingLeft) || 0)
699
+ - (parseFloat(panelStyle.paddingRight) || 0);
700
+ // A collapsed/hidden pane measures ~0; keep the last good value.
701
+ if (!(contentPx > 50)) return;
702
+ const next = Math.max(20, Math.floor(contentPx / charWidth) - slack);
703
+ setCols((current) => (current === next ? current : next));
704
+ };
705
+ const schedule = () => {
706
+ if (st.timer) window.clearTimeout(st.timer);
707
+ st.timer = window.setTimeout(measure, debounceMs);
708
+ };
709
+ st.frame = requestAnimationFrame(measure);
710
+ st.observer = new ResizeObserver(schedule);
711
+ // content-box: a classic (non-overlay) vertical scrollbar appearing
712
+ // shrinks the content box without touching the border box — observe
713
+ // the box that actually bounds the text.
714
+ try {
715
+ st.observer.observe(node, { box: "content-box" });
716
+ } catch {
717
+ st.observer.observe(node);
718
+ }
719
+ }, [slack, debounceMs]);
720
+ return [cols, attachRef];
721
+ }
722
+
637
723
  function normalizeLines(lines) {
638
724
  const normalized = [];
639
725
 
@@ -3735,10 +3821,17 @@ function focusRegionForPaneKey(paneKey, override = null) {
3735
3821
  return PANE_KEY_FOCUS_REGIONS[String(paneKey || "")] || null;
3736
3822
  }
3737
3823
 
3738
- function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, lines, stickyLines = [], bottomStickyLines = [], scrollOffset = 0, scrollMode = "top", paneKey, controller, className = "", panelClassName = "", topContent = null, bottomContent = null, structuredBlocks = false, stickyBottom = false, renderBody = null, focusRegion = null }) {
3824
+ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, lines, stickyLines = [], bottomStickyLines = [], scrollOffset = 0, scrollMode = "top", paneKey, controller, className = "", panelClassName = "", topContent = null, bottomContent = null, structuredBlocks = false, stickyBottom = false, renderBody = null, focusRegion = null, panelRef = null }) {
3739
3825
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
3740
3826
  const theme = getTheme(themeId);
3741
3827
  const ref = React.useRef(null);
3828
+ // Mirror the scroll-panel node into the caller's ref too (panes measure
3829
+ // their own content width from it — see useMeasuredPaneColumns).
3830
+ const setPanelNode = React.useCallback((node) => {
3831
+ ref.current = node;
3832
+ if (typeof panelRef === "function") panelRef(node);
3833
+ else if (panelRef) panelRef.current = node;
3834
+ }, [panelRef]);
3742
3835
  const stickyRef = React.useRef(null);
3743
3836
  const syncingHorizontalRef = React.useRef(false);
3744
3837
  const { normalizedLines, onScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd } = useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom });
@@ -3828,7 +3921,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
3828
3921
  normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
3829
3922
  )
3830
3923
  : null,
3831
- React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrollShadow.down ? " is-scrolled-down" : ""}${scrollShadow.up ? " is-scrolled-up" : ""}`.trim(), "data-session-scroll": focusRegion === "sessions" ? "1" : undefined, onScroll: handleBodyScroll, onMouseDown: claimFocus, onTouchStart, onWheel, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
3924
+ React.createElement("div", { ref: setPanelNode, className: `ps-scroll-panel ${className}${scrollShadow.down ? " is-scrolled-down" : ""}${scrollShadow.up ? " is-scrolled-up" : ""}`.trim(), "data-session-scroll": focusRegion === "sessions" ? "1" : undefined, onScroll: handleBodyScroll, onMouseDown: claimFocus, onTouchStart, onWheel, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
3832
3925
  typeof renderBody === "function"
3833
3926
  ? renderBody(normalizedLines, theme)
3834
3927
  : structuredBlocks
@@ -5138,15 +5231,24 @@ function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
5138
5231
  return React.createElement("div", { className: "ps-share-overlay", onClick: onClose },
5139
5232
  React.createElement("div", { className: "ps-link-modal", onClick: stop },
5140
5233
  React.createElement("div", { className: "ps-share-modal-head" },
5141
- React.createElement("span", null, "Link copied"),
5234
+ React.createElement("span", null, portalLinkOrigins.length >= 2 ? "Session links copied" : "Link copied"),
5142
5235
  React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
5143
5236
  React.createElement("div", { className: "ps-share-section-sub" },
5144
- "Copied to your clipboard — anyone with access can open the session from this link."),
5237
+ portalLinkOrigins.length >= 2
5238
+ ? "All entry-point links were copied — use the one reachable from the recipient's network."
5239
+ : "Copied to your clipboard — anyone with access can open the session from this link."),
5145
5240
  React.createElement("div", { className: "ps-link-row" },
5146
- React.createElement("input", {
5147
- ref: inputRef, className: "ps-link-input", readOnly: true, value: url,
5148
- onFocus: () => inputRef.current?.select(),
5149
- }),
5241
+ portalLinkOrigins.length >= 2
5242
+ ? React.createElement("textarea", {
5243
+ ref: inputRef, className: "ps-link-input", readOnly: true,
5244
+ rows: buildPortalLinks(url, portalLinkOrigins).length,
5245
+ value: formatPortalLinksForCopy(buildPortalLinks(url, portalLinkOrigins)),
5246
+ onFocus: () => inputRef.current?.select(),
5247
+ })
5248
+ : React.createElement("input", {
5249
+ ref: inputRef, className: "ps-link-input", readOnly: true, value: url,
5250
+ onFocus: () => inputRef.current?.select(),
5251
+ }),
5150
5252
  React.createElement("button", { className: "ps-mini-button", onClick: onCopyAgain }, "Copy")),
5151
5253
  warn
5152
5254
  ? React.createElement("div", { className: "ps-link-warn" }, SESSION_LINK_PRIVATE_WARNING)
@@ -5790,9 +5892,14 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5790
5892
  viewState.sessionsById,
5791
5893
  viewState.sessionsFlat,
5792
5894
  ]);
5895
+ // Character-cell content (code fences, tables, box art) wraps at the
5896
+ // pane's MEASURED width; the legacy-model width only covers the first
5897
+ // paint, before the panel node exists to measure.
5898
+ const [measuredCols, panelRef] = useMeasuredPaneColumns();
5899
+ const contentWidth = measuredCols ?? viewState.contentWidth;
5793
5900
  const chrome = React.useMemo(
5794
- () => selectChatPaneChrome(selectorState, { width: viewState.contentWidth }),
5795
- [selectorState, viewState.contentWidth],
5901
+ () => selectChatPaneChrome(selectorState, { width: contentWidth }),
5902
+ [selectorState, contentWidth],
5796
5903
  );
5797
5904
  const animatedDots = useAnimatedDots(Boolean(chrome.animateTitleRight));
5798
5905
  const titleRight = React.useMemo(
@@ -5809,21 +5916,21 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5809
5916
  && Number(viewState.activeHistory?.loadedEventCount || 0) >= AUTO_HISTORY_EVENT_SOFT_CAP,
5810
5917
  );
5811
5918
  const lines = React.useMemo(
5812
- () => selectChatLines(selectorState, viewState.contentWidth, { tableMode: "sentinel" }),
5813
- [selectorState, viewState.contentWidth],
5919
+ () => selectChatLines(selectorState, contentWidth, { tableMode: "sentinel" }),
5920
+ [selectorState, contentWidth],
5814
5921
  );
5815
5922
  const spinnerFrame = useSpinnerFrame(viewState.activeSessionStatus === "running");
5816
5923
  const liveActivityLines = React.useMemo(
5817
- () => selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: viewState.contentWidth }),
5818
- [selectorState, spinnerFrame, viewState.contentWidth],
5924
+ () => selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: contentWidth }),
5925
+ [selectorState, spinnerFrame, contentWidth],
5819
5926
  );
5820
5927
  // The live-activity line is pinned in the bottom-sticky strip (with the
5821
5928
  // outbox overlay), NOT appended to the transcript — it must stay put while
5822
5929
  // chat content scrolls, and it drops the instant the turn ends.
5823
5930
  const pinnedActivityLines = liveActivityLines;
5824
5931
  const outboxLines = React.useMemo(
5825
- () => selectOutboxOverlayLines(selectorState, viewState.contentWidth, { tableMode: "sentinel" }),
5826
- [selectorState, viewState.contentWidth],
5932
+ () => selectOutboxOverlayLines(selectorState, contentWidth, { tableMode: "sentinel" }),
5933
+ [selectorState, contentWidth],
5827
5934
  );
5828
5935
  const stickyBottom = React.useMemo(
5829
5936
  () => (pinnedActivityLines.length > 0 ? [...outboxLines, ...pinnedActivityLines] : outboxLines),
@@ -5894,6 +6001,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5894
6001
  paneKey: "chat",
5895
6002
  className: "is-wrapped",
5896
6003
  panelClassName: "ps-chat-panel",
6004
+ panelRef,
5897
6005
  bottomContent: composer,
5898
6006
  topContent: showLoadOlder
5899
6007
  ? React.createElement("div", { className: "ps-load-older-bar" },
@@ -6256,6 +6364,135 @@ function SessionCanvasMark({ mark }) {
6256
6364
  : null));
6257
6365
  }
6258
6366
 
6367
+ /** Chain-link glyph for the canvas share button. */
6368
+ function CanvasLinkGlyph() {
6369
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" };
6370
+ return React.createElement("svg", { viewBox: "0 0 20 20", width: 14, height: 14, "aria-hidden": "true" },
6371
+ React.createElement("path", { d: "M8.5 11.5l3-3", ...stroke }),
6372
+ React.createElement("path", { d: "M7 13l-1.8 1.8a2.8 2.8 0 01-4-4l3-3a2.8 2.8 0 014 0", ...stroke }),
6373
+ React.createElement("path", { d: "M13 7l1.8-1.8a2.8 2.8 0 014 4l-3 3a2.8 2.8 0 01-4 0", ...stroke }));
6374
+ }
6375
+
6376
+ /**
6377
+ * The canvas share dialog: two audiences, one live public token.
6378
+ *
6379
+ * - Session access: a plain deep link; portal sign-in + the existing
6380
+ * visibility check do the authz. Always available.
6381
+ * - Anyone with link: the ONE public view token for this canvas. Created
6382
+ * on demand, RESET rotates it (the old link dies instantly), Remove
6383
+ * returns to unlinked. The raw token appears exactly once, right after
6384
+ * mint — the server stores only its hash.
6385
+ *
6386
+ * Link management is a session:manage operation server-side; anyone else
6387
+ * sees the server's refusal inline.
6388
+ */
6389
+ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
6390
+ const [info, setInfo] = React.useState(null); // { exists, createdAt } | null while loading
6391
+ const [freshLink, setFreshLink] = React.useState(null); // the just-minted URL, shown once
6392
+ const [busy, setBusy] = React.useState(false);
6393
+ const [error, setError] = React.useState("");
6394
+ const [copied, setCopied] = React.useState("");
6395
+
6396
+ const transport = controller.transport;
6397
+ const sessionLink = `${window.location.origin}/?session=${encodeURIComponent(sessionId)}&view=canvas&slot=${slot}&max=1`;
6398
+ // Multi-entry-point deployments: ONE text box holding every labeled
6399
+ // variant, ONE copy that takes all of them — the sender does not know
6400
+ // which entry point the recipient can reach, so they paste both. The
6401
+ // token variants all resolve the same hashed row: one token, N doors,
6402
+ // single-switch revocation whichever door the bearer uses.
6403
+ const linkRows = (url, keyPrefix) => {
6404
+ const links = buildPortalLinks(url, portalLinkOrigins);
6405
+ if (links.length <= 1) {
6406
+ return React.createElement("div", { className: "ps-canvas-share-row" },
6407
+ React.createElement("code", { className: "ps-canvas-share-url" }, url),
6408
+ React.createElement("button", {
6409
+ type: "button", className: "ps-mini-button",
6410
+ onClick: () => copy(keyPrefix, url),
6411
+ }, copied === keyPrefix ? "Copied" : "Copy"));
6412
+ }
6413
+ const text = formatPortalLinksForCopy(links);
6414
+ return React.createElement("div", { className: "ps-canvas-share-row" },
6415
+ React.createElement("textarea", {
6416
+ className: "ps-link-input",
6417
+ readOnly: true,
6418
+ rows: links.length,
6419
+ value: text,
6420
+ onFocus: (event) => event.currentTarget.select(),
6421
+ }),
6422
+ React.createElement("button", {
6423
+ type: "button", className: "ps-mini-button",
6424
+ onClick: () => copy(keyPrefix, text),
6425
+ }, copied === keyPrefix ? "Copied" : "Copy"));
6426
+ };
6427
+
6428
+ const refresh = React.useCallback(() => {
6429
+ transport.getCanvasShareLink?.(sessionId, slot)
6430
+ .then((state) => setInfo(state || { exists: false }))
6431
+ .catch((err) => { setInfo({ exists: false }); setError(err?.message || String(err)); });
6432
+ }, [transport, sessionId, slot]);
6433
+ React.useEffect(() => { refresh(); }, [refresh]);
6434
+
6435
+ const copy = (label, text) => {
6436
+ navigator.clipboard?.writeText(text)
6437
+ .then(() => { setCopied(label); window.setTimeout(() => setCopied(""), 1500); })
6438
+ .catch(() => setError("Could not reach the clipboard — copy the link manually."));
6439
+ };
6440
+
6441
+ const mint = (isReset) => {
6442
+ setBusy(true); setError("");
6443
+ transport.resetCanvasShareLink(sessionId, slot)
6444
+ .then(({ token }) => {
6445
+ setFreshLink(`${window.location.origin}/?canvasShare=${encodeURIComponent(token)}`);
6446
+ setInfo({ exists: true });
6447
+ })
6448
+ .catch((err) => setError(err?.message || String(err)))
6449
+ .finally(() => setBusy(false));
6450
+ void isReset;
6451
+ };
6452
+
6453
+ const remove = () => {
6454
+ setBusy(true); setError("");
6455
+ transport.removeCanvasShareLink(sessionId, slot)
6456
+ .then(() => { setFreshLink(null); setInfo({ exists: false }); })
6457
+ .catch((err) => setError(err?.message || String(err)))
6458
+ .finally(() => setBusy(false));
6459
+ };
6460
+
6461
+ return React.createElement("div", { className: "ps-canvas-share-overlay", onClick: onClose },
6462
+ React.createElement("div", { className: "ps-canvas-share-dialog", onClick: (e) => e.stopPropagation(), role: "dialog", "aria-label": "Share canvas" },
6463
+ React.createElement("div", { className: "ps-canvas-share-title" },
6464
+ "Share this canvas",
6465
+ React.createElement("button", { type: "button", className: "ps-mini-button", onClick: onClose, "aria-label": "Close" }, "✕")),
6466
+
6467
+ React.createElement("div", { className: "ps-canvas-share-section" },
6468
+ React.createElement("div", { className: "ps-canvas-share-head" }, "Anyone with session access"),
6469
+ React.createElement("div", { className: "ps-canvas-share-sub" }, "Opens the portal signed in, canvas full screen. Session visibility rules apply."),
6470
+ linkRows(sessionLink, "session")),
6471
+
6472
+ React.createElement("div", { className: "ps-canvas-share-section" },
6473
+ React.createElement("div", { className: "ps-canvas-share-head" }, "Anyone with the link"),
6474
+ React.createElement("div", { className: "ps-canvas-share-sub" },
6475
+ "View only, live, no sign-in. One link at a time — resetting makes the previous link stop working immediately."),
6476
+ info === null
6477
+ ? React.createElement("div", { className: "ps-canvas-share-sub" }, "Checking…")
6478
+ : freshLink
6479
+ ? React.createElement(React.Fragment, null,
6480
+ linkRows(freshLink, "public"),
6481
+ React.createElement("div", { className: "ps-canvas-share-sub is-warn" },
6482
+ "Copy it now — the link is shown only this once."))
6483
+ : info.exists
6484
+ ? React.createElement("div", { className: "ps-canvas-share-sub" },
6485
+ `A link exists${info.createdAt ? ` (created ${new Date(info.createdAt).toLocaleString()})` : ""}. For safety it cannot be re-shown — Reset to get a new one.`)
6486
+ : React.createElement("div", { className: "ps-canvas-share-sub" }, "No public link yet."),
6487
+ React.createElement("div", { className: "ps-canvas-share-actions" },
6488
+ React.createElement("button", { type: "button", className: "ps-mini-button", disabled: busy, onClick: () => mint(info?.exists) },
6489
+ info?.exists ? "Reset link" : "Create link"),
6490
+ info?.exists
6491
+ ? React.createElement("button", { type: "button", className: "ps-mini-button", disabled: busy, onClick: remove }, "Remove link")
6492
+ : null)),
6493
+ error ? React.createElement("div", { className: "ps-canvas-share-error" }, error) : null));
6494
+ }
6495
+
6259
6496
  /**
6260
6497
  * The canvas slot controls: a dropdown when the session has more than one
6261
6498
  * drawn canvas, plus prev/next arrows. Hidden entirely with one canvas —
@@ -6322,7 +6559,7 @@ function RestoreGlyph() {
6322
6559
  * loads hidden-but-painted behind the live frame and is promoted on load, so
6323
6560
  * a live redraw is a composite swap of something already drawn.
6324
6561
  */
6325
- function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null }) {
6562
+ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null, dataPatch = null }) {
6326
6563
  const [live, setLive] = React.useState(null); // { url, rev }
6327
6564
  const [staging, setStaging] = React.useState(null); // { url, rev }
6328
6565
  const [loadError, setLoadError] = React.useState(null); // { rev }
@@ -6471,13 +6708,22 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
6471
6708
  liveIframeElRef.current?.contentWindow?.postMessage(
6472
6709
  { type: "canvas-data", data: dataPayload, dataRev }, "*",
6473
6710
  );
6711
+ // The delta that produced this state, for pages that registered a
6712
+ // canvas-patch listener (targeted DOM updates, transitions). The
6713
+ // whole-state message above ALWAYS accompanies it — applyData
6714
+ // stays the mandatory floor; the patch is the opt-in fast path.
6715
+ if (dataPatch) {
6716
+ liveIframeElRef.current?.contentWindow?.postMessage(
6717
+ { type: "canvas-patch", patch: dataPatch, dataRev }, "*",
6718
+ );
6719
+ }
6474
6720
  } catch { /* frame mid-teardown */ }
6475
6721
  // On-screen injection = seen; hidden injection leaves the badge lit
6476
6722
  // until the user actually looks (the catch-up below files it then).
6477
6723
  if (controller && sessionId && visibleRef.current) {
6478
6724
  controller.dispatch({ type: "canvas/viewed", sessionId, slot, dataRev });
6479
6725
  }
6480
- }, [live, dataRev, dataPayload, controller, sessionId]);
6726
+ }, [live, dataRev, dataPayload, dataPatch, controller, sessionId]);
6481
6727
 
6482
6728
  // Catch-up receipt: becoming visible with a live revision on screen IS
6483
6729
  // viewing it — this is what clears a badge earned while hidden.
@@ -6601,6 +6847,7 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6601
6847
  }, [controller, view.sessionId]);
6602
6848
 
6603
6849
  const zenOn = useControllerSelector(controller, (s) => s?.ui?.canvasZen === true);
6850
+ const [shareOpen, setShareOpen] = React.useState(false);
6604
6851
  const [toolbarSlot, setToolbarSlot] = React.useState(null);
6605
6852
  React.useEffect(() => {
6606
6853
  if (!maximized) { setToolbarSlot(null); return undefined; }
@@ -6635,6 +6882,15 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6635
6882
  React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
6636
6883
  view.exists ? `${view.name ? `${view.name} \u00b7 ` : ""}rev ${view.latestRev}` : ""),
6637
6884
  React.createElement(CanvasSlotControls, { controller, view, compact: true }),
6885
+ view.exists
6886
+ ? React.createElement("button", {
6887
+ type: "button",
6888
+ className: "ps-canvas-max-btn",
6889
+ onClick: () => setShareOpen(true),
6890
+ title: "Share this canvas — copy a view link",
6891
+ "aria-label": "Share canvas",
6892
+ }, React.createElement(CanvasLinkGlyph))
6893
+ : null,
6638
6894
  onToggleMaximized
6639
6895
  ? React.createElement("button", {
6640
6896
  type: "button",
@@ -6683,6 +6939,15 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6683
6939
  React.createElement(CanvasSlotControls, { controller, view })),
6684
6940
  React.createElement("span", { className: "ps-artifact-pane-actions" },
6685
6941
  view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6942
+ view.exists
6943
+ ? React.createElement("button", {
6944
+ type: "button",
6945
+ className: "ps-artifact-pane-btn",
6946
+ onClick: () => setShareOpen(true),
6947
+ title: "Share this canvas — copy a view link",
6948
+ "aria-label": "Share canvas",
6949
+ }, React.createElement(CanvasLinkGlyph))
6950
+ : null,
6686
6951
  // Zen: sessions step away, chat becomes a rail, the canvas is
6687
6952
  // the workbench. The mode for "the work happens on the canvas,
6688
6953
  // chat is for steering".
@@ -6727,11 +6992,20 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6727
6992
  focusOnPromote,
6728
6993
  dataRev: view.latestDataRev,
6729
6994
  dataPayload: view.dataPayload,
6995
+ dataPatch: view.dataPatch,
6730
6996
  })
6731
6997
  : React.createElement("div", { className: "ps-canvas-blank" },
6732
6998
  React.createElement("p", { className: "ps-canvas-blank-title" }, "Nothing on the canvas yet."),
6733
6999
  React.createElement("p", null,
6734
- "Ask the agent to draw — a dashboard, a chart, a diagram — or it will draw when it has something worth showing."))));
7000
+ "Ask the agent to draw — a dashboard, a chart, a diagram — or it will draw when it has something worth showing."))),
7001
+ shareOpen && view.exists
7002
+ ? React.createElement(CanvasShareDialog, {
7003
+ controller,
7004
+ sessionId: view.sessionId,
7005
+ slot: view.slot,
7006
+ onClose: () => setShareOpen(false),
7007
+ })
7008
+ : null);
6735
7009
  }
6736
7010
 
6737
7011
  function InspectorTabs({ activeTab, controller }) {
@@ -7239,9 +7513,16 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
7239
7513
  // could pan it — in practice it rendered clipped on BOTH edges (the
7240
7514
  // timestamps and the STATS header sliced off) and read as broken. Fitting
7241
7515
  // to width collapses surplus lanes into "…" instead, which is legible.
7516
+ //
7517
+ // The width itself is the pane's MEASURED column count wherever a
7518
+ // measurement exists — the legacy-model rightWidth imagines a split the
7519
+ // portal's draggable grid does not honor, which is how the sequence grid
7520
+ // used to spill past the pane edge and the stats boxes arrived pre-sliced.
7521
+ const [measuredCols, panelRef] = useMeasuredPaneColumns();
7522
+ const contentWidth = measuredCols ?? viewState.contentWidth;
7242
7523
  const inspector = React.useMemo(() => selectInspector(selectorState, {
7243
- width: viewState.contentWidth,
7244
- }), [selectorState, viewState.contentWidth]);
7524
+ width: contentWidth,
7525
+ }), [selectorState, contentWidth]);
7245
7526
  const completionByTurn = React.useMemo(() => {
7246
7527
  const history = viewState.activeSessionId
7247
7528
  ? viewState.historyBySessionId?.get(viewState.activeSessionId) || null
@@ -7391,6 +7672,7 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
7391
7672
  : "top",
7392
7673
  stickyBottom: inspector.activeTab === "logs",
7393
7674
  paneKey: "inspector",
7675
+ panelRef,
7394
7676
  className: inspector.activeTab === "history" || inspector.activeTab === "logs" ? "is-wrapped" : "is-preserve",
7395
7677
  panelClassName: `ps-inspector-pane${inspector.activeTab === "sequence" ? " has-preserved-sticky" : ""}${panelClassName ? ` ${panelClassName}` : ""}`.trim(),
7396
7678
  });
@@ -7565,6 +7847,28 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7565
7847
  const attachInputRef = React.useRef(null);
7566
7848
  const [dragOver, setDragOver] = React.useState(false);
7567
7849
 
7850
+ // Auto-grow: one line idle, sized to the RENDERED content (scrollHeight
7851
+ // sees soft wrap; counting "\n" does not). CSS max-height provides the
7852
+ // cap, after which the textarea scrolls internally. The measurement
7853
+ // zeroes the height first — NOT "auto": iOS Safari keeps a textarea's
7854
+ // scrollHeight at its high-water mark under "auto", so a box that grew
7855
+ // never shrank back after the text was sent or deleted.
7856
+ const growInput = React.useCallback(() => {
7857
+ const node = inputRef.current;
7858
+ if (!node) return;
7859
+ node.style.height = "0px";
7860
+ node.style.height = `${node.scrollHeight + 2}px`;
7861
+ }, []);
7862
+ React.useLayoutEffect(() => {
7863
+ growInput();
7864
+ }, [growInput, promptState.value]);
7865
+ React.useEffect(() => {
7866
+ // Width changes re-wrap the content; re-measure on viewport resizes
7867
+ // (covers rotation and the on-screen keyboard shrinking the pane).
7868
+ window.addEventListener("resize", growInput);
7869
+ return () => window.removeEventListener("resize", growInput);
7870
+ }, [growInput]);
7871
+
7568
7872
  const canAttachImages = typeof controller.transport?.supportsPromptImageAttachments === "function"
7569
7873
  && controller.transport.supportsPromptImageAttachments()
7570
7874
  && typeof controller.transport?.uploadArtifactFromFile === "function";
@@ -7609,14 +7913,26 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7609
7913
  const inputNode = inputRef.current;
7610
7914
  if (!active || promptState.modalOpen || !promptState.focused || !inputNode) return;
7611
7915
  if (document.activeElement !== inputNode) {
7916
+ // Programmatic focus pops the on-screen keyboard on touch devices;
7917
+ // there, focus only ever comes from the user's own tap.
7918
+ if (mobile) return;
7612
7919
  try {
7613
7920
  inputNode.focus({ preventScroll: true });
7614
7921
  } catch {
7615
7922
  inputNode.focus();
7616
7923
  }
7617
7924
  }
7618
- inputNode.setSelectionRange(promptState.cursor, promptState.cursor);
7619
- }, [active, promptState.cursor, promptState.focused, promptState.modalOpen]);
7925
+ // Apply the model's caret ONLY when it disagrees with the DOM — that
7926
+ // is a programmatic intervention (pending-message recall, reference
7927
+ // autocomplete insert, draft restore). While the user edits, the DOM
7928
+ // is the source of truth and onSelect/onChange mirror it into state;
7929
+ // unconditionally echoing state back used to collapse in-progress
7930
+ // backward selections (mouse drag right-to-left, Shift+Left, and the
7931
+ // start handle of a touch selection).
7932
+ if (inputNode.value === promptState.value && inputNode.selectionStart !== promptState.cursor) {
7933
+ inputNode.setSelectionRange(promptState.cursor, promptState.cursor);
7934
+ }
7935
+ }, [active, mobile, promptState.cursor, promptState.value, promptState.focused, promptState.modalOpen]);
7620
7936
 
7621
7937
  const sendPrompt = React.useCallback(() => {
7622
7938
  controller.handleCommand(UI_COMMANDS.SEND_PROMPT)
@@ -7720,7 +8036,9 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7720
8036
  React.createElement("textarea", {
7721
8037
  ref: inputRef,
7722
8038
  className: "ps-prompt-input",
7723
- rows: mobile ? 2 : Math.max(2, getPromptInputRows(promptState.value)),
8039
+ // One line at rest; a layout effect grows it to the rendered
8040
+ // content and CSS max-height caps it. Never a manual resize grip.
8041
+ rows: 1,
7724
8042
  value: promptState.value,
7725
8043
  readOnly: selectedReadOnly,
7726
8044
  placeholder: promptState.answerMode
@@ -7734,7 +8052,9 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7734
8052
  : promptState.hasOutbox
7735
8053
  ? "Type a message and press Enter to queue it behind the pending batch"
7736
8054
  : "Type a message and press Enter",
7737
- enterKeyHint: "send",
8055
+ // On touch, Enter inserts a newline (the chevron sends) — the
8056
+ // keyboard must not advertise a send that will not happen.
8057
+ enterKeyHint: mobile ? "enter" : "send",
7738
8058
  onFocus: () => controller.setFocus("prompt"),
7739
8059
  onSelect: (event) => controller.setPromptCursor(event.currentTarget.selectionStart || 0),
7740
8060
  onChange: (event) => controller.setPrompt(event.currentTarget.value, event.currentTarget.selectionStart || event.currentTarget.value.length),
@@ -7758,16 +8078,11 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7758
8078
  event.preventDefault();
7759
8079
  return;
7760
8080
  }
7761
- if (event.key === "ArrowUp" && !event.shiftKey && !event.metaKey && !event.altKey) {
7762
- event.preventDefault();
7763
- controller.movePromptCursorVertical(-1);
7764
- return;
7765
- }
7766
- if (event.key === "ArrowDown" && !event.shiftKey && !event.metaKey && !event.altKey) {
7767
- event.preventDefault();
7768
- controller.movePromptCursorVertical(1);
7769
- return;
7770
- }
8081
+ // Arrow keys are NOT intercepted: the textarea's native cursor
8082
+ // movement understands soft-wrapped lines and goal columns;
8083
+ // onSelect mirrors every move into the shared model. (The old
8084
+ // hijack routed through the TUI's logical-line cursor and made
8085
+ // Down jump whole paragraphs inside wrapped text.)
7771
8086
  if (event.key === "Escape" && promptState.editingPending) {
7772
8087
  event.preventDefault();
7773
8088
  if (selectedReadOnly) {
@@ -7784,6 +8099,9 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7784
8099
  },
7785
8100
  }),
7786
8101
  React.createElement("div", { className: "ps-prompt-actions" },
8102
+ // pointerdown preventDefault on every action keeps focus in the
8103
+ // textarea — on a phone, tapping Send must not collapse the
8104
+ // keyboard between messages.
7787
8105
  canAttachImages
7788
8106
  ? React.createElement(React.Fragment, null,
7789
8107
  React.createElement("input", {
@@ -7807,6 +8125,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7807
8125
  className: "ps-mini-button ps-attach-button",
7808
8126
  title: "Attach images (or paste / drop them into the composer)",
7809
8127
  "aria-label": "Attach images",
8128
+ onPointerDown: (event) => event.preventDefault(),
7810
8129
  onClick: () => attachInputRef.current?.click(),
7811
8130
  }, "📎"))
7812
8131
  : null,
@@ -7816,6 +8135,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7816
8135
  className: "ps-mini-button",
7817
8136
  title: selectedQueued ? "Delete selected queued prompt" : "Cancel selected pending prompt",
7818
8137
  "aria-label": selectedQueued ? "Delete selected queued prompt" : "Cancel selected pending prompt",
8138
+ onPointerDown: (event) => event.preventDefault(),
7819
8139
  onClick: cancelPending,
7820
8140
  }, selectedQueued ? "Delete" : "Cancel")
7821
8141
  : null,
@@ -7826,6 +8146,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7826
8146
  title: "Stop the current turn (the session stays alive and returns to idle)",
7827
8147
  "aria-label": "Stop the current turn",
7828
8148
  disabled: stoppingTurn,
8149
+ onPointerDown: (event) => event.preventDefault(),
7829
8150
  onClick: stopTurn,
7830
8151
  }, "■")
7831
8152
  : null,
@@ -7842,6 +8163,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7842
8163
  : promptState.hasOutbox
7843
8164
  ? "Queue prompt"
7844
8165
  : "Send prompt",
8166
+ onPointerDown: (event) => event.preventDefault(),
7845
8167
  onClick: sendPrompt,
7846
8168
  }, sendLabel),
7847
8169
  ),
package/web/README.md CHANGED
@@ -196,3 +196,31 @@ pilotswarm (the app package; this dir is its web/ tree)
196
196
  `pilotswarm-cli` rather than importing monorepo-relative source files. That
197
197
  keeps the publishable package graph explicit and lets the portal reuse the same
198
198
  Node transport and plugin-config behavior as the TUI.
199
+
200
+ ## Multi-origin links (PORTAL_LINK_ORIGINS)
201
+
202
+ A deployment reachable through several entry points (a public edge and a
203
+ private/VPN hostname, say) sets:
204
+
205
+ ```
206
+ PORTAL_LINK_ORIGINS="Corporate=https://corp.example.com,Private/VPN=https://vpn.example.com"
207
+ ```
208
+
209
+ With two or more entries, every link-producing surface — the session
210
+ copy-link, artifact links, and the canvas share dialog (both the
211
+ session-access and the public-token link) — offers one labeled variant per
212
+ origin, and copy puts all of them on the clipboard as labeled lines. Token
213
+ links all resolve the same stored hash, so a reset revokes every variant at
214
+ once. Unset (or empty) means single-origin behavior, unchanged.
215
+
216
+ Validation is fail-loud at portal startup: 2-6 entries, `Label=Origin`
217
+ pairs, unique labels and origins, bare https origins (http allowed only for
218
+ localhost). A single entry is refused as a probable mistake.
219
+
220
+ Note: configured origins are served on the pre-auth /api/portal-config
221
+ payload (the same visibility the waldemort patch had, which baked both
222
+ hostnames into the public JS bundle) — treat entry-point hostnames as
223
+ non-secret.
224
+
225
+ This replaces the waldemort `patch-pilotswarm-dual-session-links.mjs`
226
+ deploy patch: set the env var, delete the patch.