pilotswarm 0.5.39 → 0.5.40

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 +369 -34
  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-C6FaYMB7.js} +1 -1
  15. package/web/dist/assets/{arc-DiQLL9vM.js → arc-iZBUHcPs.js} +1 -1
  16. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-B8ry8WNZ.js → architectureDiagram-ZJ3FMSHR-D4-wnpAG.js} +1 -1
  17. package/web/dist/assets/{blockDiagram-677ZJIJ3-Z4PcukIo.js → blockDiagram-677ZJIJ3-Dz-s103f.js} +1 -1
  18. package/web/dist/assets/{c4Diagram-LMCZKHZV-Cahkc0gc.js → c4Diagram-LMCZKHZV-DE7sGdHE.js} +1 -1
  19. package/web/dist/assets/channel-Bk68hLPV.js +1 -0
  20. package/web/dist/assets/{chunk-2Q5K7J3B-CtZuWH4J.js → chunk-2Q5K7J3B-DSFs_jRM.js} +1 -1
  21. package/web/dist/assets/{chunk-32BRIVSS-C0M390-k.js → chunk-32BRIVSS-8sBVtZ4f.js} +1 -1
  22. package/web/dist/assets/{chunk-5VM5RSS4-ClvX9u5j.js → chunk-5VM5RSS4-CbIX7k0G.js} +1 -1
  23. package/web/dist/assets/{chunk-EX3LRPZG-BVAet5KG.js → chunk-EX3LRPZG-ZfmPtWF8.js} +1 -1
  24. package/web/dist/assets/{chunk-JWPE2WC7-BccmZmrr.js → chunk-JWPE2WC7-DifZrqsQ.js} +1 -1
  25. package/web/dist/assets/{chunk-MOJQB5TN-BN5MKe94.js → chunk-MOJQB5TN-CEvg_lZT.js} +1 -1
  26. package/web/dist/assets/{chunk-RYQCIY6F-DfWw-ACP.js → chunk-RYQCIY6F-DmYOwk6-.js} +1 -1
  27. package/web/dist/assets/{chunk-V7JOEXUC-BgUEoLnN.js → chunk-V7JOEXUC-DOxG9Vxu.js} +1 -1
  28. package/web/dist/assets/{chunk-VR4S4FIN-k1eKfFNA.js → chunk-VR4S4FIN-ic-KB2tF.js} +1 -1
  29. package/web/dist/assets/{chunk-XXDRQBXY-DPyV10yR.js → chunk-XXDRQBXY-BqeYnem3.js} +1 -1
  30. package/web/dist/assets/classDiagram-OUVF2IWQ-arTJL-2y.js +1 -0
  31. package/web/dist/assets/classDiagram-v2-EOCWNBFH-arTJL-2y.js +1 -0
  32. package/web/dist/assets/{cose-bilkent-JH36ORCC-Bt_rpukK.js → cose-bilkent-JH36ORCC-uaIgs2-N.js} +1 -1
  33. package/web/dist/assets/{cynefin-VYW2F7L2-Z8eMzFMv.js → cynefin-VYW2F7L2-DzFeugVQ.js} +1 -1
  34. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BJSNawFi.js → cynefinDiagram-TSTJHNR4-NrAChuCW.js} +1 -1
  35. package/web/dist/assets/{dagre-VKFMJZFB-D6dNl8jc.js → dagre-VKFMJZFB-Djf9LD2t.js} +1 -1
  36. package/web/dist/assets/{diagram-FQU43EPY-Cy1nbGsU.js → diagram-FQU43EPY-BpIP2iD9.js} +1 -1
  37. package/web/dist/assets/{diagram-G47NLZAW-CmrbuA_d.js → diagram-G47NLZAW-D_DCw8Oy.js} +1 -1
  38. package/web/dist/assets/{diagram-NH7WQ7WH-PoLNQE2j.js → diagram-NH7WQ7WH-BztvRvrM.js} +1 -1
  39. package/web/dist/assets/{diagram-OA4YK3LP-BZwNFcSV.js → diagram-OA4YK3LP-BGIwSnbM.js} +1 -1
  40. package/web/dist/assets/{diagram-WEI45ONY-pKw0b_fN.js → diagram-WEI45ONY-Czj2ttCt.js} +1 -1
  41. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-UNctuQCV.js → ebnfDiagram-CCIWWBDH-0nqAhMvJ.js} +1 -1
  42. package/web/dist/assets/{erDiagram-Q63AITRT-DxIETMif.js → erDiagram-Q63AITRT-uLoGb9TG.js} +1 -1
  43. package/web/dist/assets/{flowDiagram-23GEKE2U-C06IdNKV.js → flowDiagram-23GEKE2U-Bgxy7CWO.js} +1 -1
  44. package/web/dist/assets/{ganttDiagram-NO4QXBWP-fU7aGyP1.js → ganttDiagram-NO4QXBWP-5q8zYzbu.js} +1 -1
  45. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-9UKkPJm6.js → gitGraphDiagram-IHSO6WYX-D6-i59JZ.js} +1 -1
  46. package/web/dist/assets/index-B3WlYEvi.css +1 -0
  47. package/web/dist/assets/index-BFa_e8zL.js +24 -0
  48. package/web/dist/assets/{infoDiagram-FWYZ7A6U-C6F06SQJ.js → infoDiagram-FWYZ7A6U-bdx9GrWW.js} +1 -1
  49. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CB-O9NfO.js → ishikawaDiagram-FXEZZL3T-xzL6Bssj.js} +1 -1
  50. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B-8MOf-H.js → journeyDiagram-5HDEW3XC-C1-18xOk.js} +1 -1
  51. package/web/dist/assets/{kanban-definition-HUTT4EX6-DX0XA3hI.js → kanban-definition-HUTT4EX6-BS4KDL4_.js} +1 -1
  52. package/web/dist/assets/{linear-D60PrjTW.js → linear-Dmv3ton3.js} +1 -1
  53. package/web/dist/assets/{mermaid.core-bonzwapf.js → mermaid.core-Nhzw7Fl2.js} +4 -4
  54. package/web/dist/assets/{mindmap-definition-LN4V7U3C-Y-6Dum33.js → mindmap-definition-LN4V7U3C-2DSMdlsp.js} +1 -1
  55. package/web/dist/assets/{papaparse.min-Cas1JfRG.js → papaparse.min-H4g6q6aH.js} +1 -1
  56. package/web/dist/assets/{pegDiagram-2B236MQR-_paO0yDw.js → pegDiagram-2B236MQR-CM_rgJ2s.js} +1 -1
  57. package/web/dist/assets/{pieDiagram-ENE6RG2P-xT1_Gpn4.js → pieDiagram-ENE6RG2P-Cbo8FtuT.js} +1 -1
  58. package/web/dist/assets/pilotswarm-ClLipXts.js +97 -0
  59. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-1usdLFOF.js → quadrantDiagram-ABIIQ3AL-CVsF_pZA.js} +1 -1
  60. package/web/dist/assets/{railroadDiagram-RFXS5EU6-R3lwHOz9.js → railroadDiagram-RFXS5EU6-CauKYIfy.js} +1 -1
  61. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BL8J1__Z.js → requirementDiagram-TGXJPOKE-DoXh8EL8.js} +1 -1
  62. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Dz6--0pl.js → sankeyDiagram-HTMAVEWB-gA_ej2MB.js} +1 -1
  63. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CG2Xpkct.js → sequenceDiagram-DBY2YBRQ-keYBa83Q.js} +1 -1
  64. package/web/dist/assets/{sizeCapture-X5ZJPWSS-BPbYfjxj.js → sizeCapture-X5ZJPWSS-Y6NCdqw8.js} +1 -1
  65. package/web/dist/assets/{stateDiagram-2N3HPSRC-46x_y_aA.js → stateDiagram-2N3HPSRC-MvJfZXqW.js} +1 -1
  66. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-xTZh36yB.js +1 -0
  67. package/web/dist/assets/{swimlanes-5IMT3BWC-SXdMo4zJ.js → swimlanes-5IMT3BWC-D41i7zcr.js} +2 -2
  68. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BjlBuuiD.js +8 -0
  69. package/web/dist/assets/{timeline-definition-FHXFAJF6-CGgT-b38.js → timeline-definition-FHXFAJF6-CUA2ENV8.js} +1 -1
  70. package/web/dist/assets/{vennDiagram-L72KCM5P-BGS3xBa_.js → vennDiagram-L72KCM5P-CBTp-2d6.js} +1 -1
  71. package/web/dist/assets/{wardleyDiagram-EHGQE667-m2U3wEZJ.js → wardleyDiagram-EHGQE667-BIFjtpCg.js} +1 -1
  72. package/web/dist/assets/{xychartDiagram-FW5EYKEG-DI6tQysg.js → xychartDiagram-FW5EYKEG-DiamOwOI.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; }
@@ -6683,6 +6930,15 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6683
6930
  React.createElement(CanvasSlotControls, { controller, view })),
6684
6931
  React.createElement("span", { className: "ps-artifact-pane-actions" },
6685
6932
  view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
6933
+ view.exists
6934
+ ? React.createElement("button", {
6935
+ type: "button",
6936
+ className: "ps-artifact-pane-btn",
6937
+ onClick: () => setShareOpen(true),
6938
+ title: "Share this canvas — copy a view link",
6939
+ "aria-label": "Share canvas",
6940
+ }, React.createElement(CanvasLinkGlyph))
6941
+ : null,
6686
6942
  // Zen: sessions step away, chat becomes a rail, the canvas is
6687
6943
  // the workbench. The mode for "the work happens on the canvas,
6688
6944
  // chat is for steering".
@@ -6727,11 +6983,20 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6727
6983
  focusOnPromote,
6728
6984
  dataRev: view.latestDataRev,
6729
6985
  dataPayload: view.dataPayload,
6986
+ dataPatch: view.dataPatch,
6730
6987
  })
6731
6988
  : React.createElement("div", { className: "ps-canvas-blank" },
6732
6989
  React.createElement("p", { className: "ps-canvas-blank-title" }, "Nothing on the canvas yet."),
6733
6990
  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."))));
6991
+ "Ask the agent to draw — a dashboard, a chart, a diagram — or it will draw when it has something worth showing."))),
6992
+ shareOpen && view.exists
6993
+ ? React.createElement(CanvasShareDialog, {
6994
+ controller,
6995
+ sessionId: view.sessionId,
6996
+ slot: view.slot,
6997
+ onClose: () => setShareOpen(false),
6998
+ })
6999
+ : null);
6735
7000
  }
6736
7001
 
6737
7002
  function InspectorTabs({ activeTab, controller }) {
@@ -7239,9 +7504,16 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
7239
7504
  // could pan it — in practice it rendered clipped on BOTH edges (the
7240
7505
  // timestamps and the STATS header sliced off) and read as broken. Fitting
7241
7506
  // to width collapses surplus lanes into "…" instead, which is legible.
7507
+ //
7508
+ // The width itself is the pane's MEASURED column count wherever a
7509
+ // measurement exists — the legacy-model rightWidth imagines a split the
7510
+ // portal's draggable grid does not honor, which is how the sequence grid
7511
+ // used to spill past the pane edge and the stats boxes arrived pre-sliced.
7512
+ const [measuredCols, panelRef] = useMeasuredPaneColumns();
7513
+ const contentWidth = measuredCols ?? viewState.contentWidth;
7242
7514
  const inspector = React.useMemo(() => selectInspector(selectorState, {
7243
- width: viewState.contentWidth,
7244
- }), [selectorState, viewState.contentWidth]);
7515
+ width: contentWidth,
7516
+ }), [selectorState, contentWidth]);
7245
7517
  const completionByTurn = React.useMemo(() => {
7246
7518
  const history = viewState.activeSessionId
7247
7519
  ? viewState.historyBySessionId?.get(viewState.activeSessionId) || null
@@ -7391,6 +7663,7 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
7391
7663
  : "top",
7392
7664
  stickyBottom: inspector.activeTab === "logs",
7393
7665
  paneKey: "inspector",
7666
+ panelRef,
7394
7667
  className: inspector.activeTab === "history" || inspector.activeTab === "logs" ? "is-wrapped" : "is-preserve",
7395
7668
  panelClassName: `ps-inspector-pane${inspector.activeTab === "sequence" ? " has-preserved-sticky" : ""}${panelClassName ? ` ${panelClassName}` : ""}`.trim(),
7396
7669
  });
@@ -7564,6 +7837,33 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7564
7837
  const inputRef = React.useRef(null);
7565
7838
  const attachInputRef = React.useRef(null);
7566
7839
  const [dragOver, setDragOver] = React.useState(false);
7840
+ // Expand mode: the same buffer, editor-sized. Web-local view state — the
7841
+ // shared core never needs to know the box got taller.
7842
+ const [expanded, setExpanded] = React.useState(false);
7843
+
7844
+ // Auto-grow: one line idle, sized to the RENDERED content (scrollHeight
7845
+ // sees soft wrap; counting "\n" does not). CSS max-height provides the
7846
+ // cap, after which the textarea scrolls internally. In expand mode the
7847
+ // height belongs to CSS entirely.
7848
+ const growInput = React.useCallback(() => {
7849
+ const node = inputRef.current;
7850
+ if (!node) return;
7851
+ if (expanded) {
7852
+ node.style.height = "";
7853
+ return;
7854
+ }
7855
+ node.style.height = "auto";
7856
+ node.style.height = `${node.scrollHeight + 2}px`;
7857
+ }, [expanded]);
7858
+ React.useLayoutEffect(() => {
7859
+ growInput();
7860
+ }, [growInput, promptState.value]);
7861
+ React.useEffect(() => {
7862
+ // Width changes re-wrap the content; re-measure on viewport resizes
7863
+ // (covers rotation and the on-screen keyboard shrinking the pane).
7864
+ window.addEventListener("resize", growInput);
7865
+ return () => window.removeEventListener("resize", growInput);
7866
+ }, [growInput]);
7567
7867
 
7568
7868
  const canAttachImages = typeof controller.transport?.supportsPromptImageAttachments === "function"
7569
7869
  && controller.transport.supportsPromptImageAttachments()
@@ -7609,16 +7909,31 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7609
7909
  const inputNode = inputRef.current;
7610
7910
  if (!active || promptState.modalOpen || !promptState.focused || !inputNode) return;
7611
7911
  if (document.activeElement !== inputNode) {
7912
+ // Programmatic focus pops the on-screen keyboard on touch devices;
7913
+ // there, focus only ever comes from the user's own tap.
7914
+ if (mobile) return;
7612
7915
  try {
7613
7916
  inputNode.focus({ preventScroll: true });
7614
7917
  } catch {
7615
7918
  inputNode.focus();
7616
7919
  }
7617
7920
  }
7618
- inputNode.setSelectionRange(promptState.cursor, promptState.cursor);
7619
- }, [active, promptState.cursor, promptState.focused, promptState.modalOpen]);
7921
+ // Apply the model's caret ONLY when it disagrees with the DOM — that
7922
+ // is a programmatic intervention (pending-message recall, reference
7923
+ // autocomplete insert, draft restore). While the user edits, the DOM
7924
+ // is the source of truth and onSelect/onChange mirror it into state;
7925
+ // unconditionally echoing state back used to collapse in-progress
7926
+ // backward selections (mouse drag right-to-left, Shift+Left, and the
7927
+ // start handle of a touch selection).
7928
+ if (inputNode.value === promptState.value && inputNode.selectionStart !== promptState.cursor) {
7929
+ inputNode.setSelectionRange(promptState.cursor, promptState.cursor);
7930
+ }
7931
+ }, [active, mobile, promptState.cursor, promptState.value, promptState.focused, promptState.modalOpen]);
7620
7932
 
7621
7933
  const sendPrompt = React.useCallback(() => {
7934
+ // Sending collapses expand mode — the tall editor was for composing
7935
+ // THIS message; the reply deserves the transcript space back.
7936
+ setExpanded(false);
7622
7937
  controller.handleCommand(UI_COMMANDS.SEND_PROMPT)
7623
7938
  .catch(() => {})
7624
7939
  .finally(() => {
@@ -7658,7 +7973,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7658
7973
  const selectedReadOnly = selectedQueued || selectedCancelling;
7659
7974
 
7660
7975
  return React.createElement("div", {
7661
- className: `ps-prompt-shell${mobile ? " is-mobile" : ""}${dragOver ? " is-drag-over" : ""}`,
7976
+ className: `ps-prompt-shell${mobile ? " is-mobile" : ""}${dragOver ? " is-drag-over" : ""}${expanded ? " is-expanded" : ""}`,
7662
7977
  ...(canAttachImages ? {
7663
7978
  onDragOver: (event) => {
7664
7979
  if (event.dataTransfer?.types?.includes?.("Files")) {
@@ -7720,7 +8035,9 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7720
8035
  React.createElement("textarea", {
7721
8036
  ref: inputRef,
7722
8037
  className: "ps-prompt-input",
7723
- rows: mobile ? 2 : Math.max(2, getPromptInputRows(promptState.value)),
8038
+ // One line at rest; a layout effect grows it to the rendered
8039
+ // content and CSS max-height caps it. Never a manual resize grip.
8040
+ rows: 1,
7724
8041
  value: promptState.value,
7725
8042
  readOnly: selectedReadOnly,
7726
8043
  placeholder: promptState.answerMode
@@ -7734,7 +8051,9 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7734
8051
  : promptState.hasOutbox
7735
8052
  ? "Type a message and press Enter to queue it behind the pending batch"
7736
8053
  : "Type a message and press Enter",
7737
- enterKeyHint: "send",
8054
+ // On touch, Enter inserts a newline (the chevron sends) — the
8055
+ // keyboard must not advertise a send that will not happen.
8056
+ enterKeyHint: mobile ? "enter" : "send",
7738
8057
  onFocus: () => controller.setFocus("prompt"),
7739
8058
  onSelect: (event) => controller.setPromptCursor(event.currentTarget.selectionStart || 0),
7740
8059
  onChange: (event) => controller.setPrompt(event.currentTarget.value, event.currentTarget.selectionStart || event.currentTarget.value.length),
@@ -7758,14 +8077,14 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7758
8077
  event.preventDefault();
7759
8078
  return;
7760
8079
  }
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) {
8080
+ // Arrow keys are NOT intercepted: the textarea's native cursor
8081
+ // movement understands soft-wrapped lines and goal columns;
8082
+ // onSelect mirrors every move into the shared model. (The old
8083
+ // hijack routed through the TUI's logical-line cursor and made
8084
+ // Down jump whole paragraphs inside wrapped text.)
8085
+ if (event.key === "Escape" && expanded && !promptState.editingPending) {
7767
8086
  event.preventDefault();
7768
- controller.movePromptCursorVertical(1);
8087
+ setExpanded(false);
7769
8088
  return;
7770
8089
  }
7771
8090
  if (event.key === "Escape" && promptState.editingPending) {
@@ -7784,6 +8103,18 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7784
8103
  },
7785
8104
  }),
7786
8105
  React.createElement("div", { className: "ps-prompt-actions" },
8106
+ // pointerdown preventDefault on every action keeps focus in the
8107
+ // textarea — on a phone, tapping Send must not collapse the
8108
+ // keyboard between messages.
8109
+ React.createElement("button", {
8110
+ type: "button",
8111
+ className: `ps-mini-button ps-expand-button${expanded ? " is-active" : ""}`,
8112
+ title: expanded ? "Collapse the editor (Esc)" : "Expand into a larger editor",
8113
+ "aria-label": expanded ? "Collapse the editor" : "Expand the editor",
8114
+ "aria-pressed": expanded,
8115
+ onPointerDown: (event) => event.preventDefault(),
8116
+ onClick: () => setExpanded((current) => !current),
8117
+ }, expanded ? "⤡" : "⤢"),
7787
8118
  canAttachImages
7788
8119
  ? React.createElement(React.Fragment, null,
7789
8120
  React.createElement("input", {
@@ -7807,6 +8138,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7807
8138
  className: "ps-mini-button ps-attach-button",
7808
8139
  title: "Attach images (or paste / drop them into the composer)",
7809
8140
  "aria-label": "Attach images",
8141
+ onPointerDown: (event) => event.preventDefault(),
7810
8142
  onClick: () => attachInputRef.current?.click(),
7811
8143
  }, "📎"))
7812
8144
  : null,
@@ -7816,6 +8148,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7816
8148
  className: "ps-mini-button",
7817
8149
  title: selectedQueued ? "Delete selected queued prompt" : "Cancel selected pending prompt",
7818
8150
  "aria-label": selectedQueued ? "Delete selected queued prompt" : "Cancel selected pending prompt",
8151
+ onPointerDown: (event) => event.preventDefault(),
7819
8152
  onClick: cancelPending,
7820
8153
  }, selectedQueued ? "Delete" : "Cancel")
7821
8154
  : null,
@@ -7826,6 +8159,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7826
8159
  title: "Stop the current turn (the session stays alive and returns to idle)",
7827
8160
  "aria-label": "Stop the current turn",
7828
8161
  disabled: stoppingTurn,
8162
+ onPointerDown: (event) => event.preventDefault(),
7829
8163
  onClick: stopTurn,
7830
8164
  }, "■")
7831
8165
  : null,
@@ -7842,6 +8176,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7842
8176
  : promptState.hasOutbox
7843
8177
  ? "Queue prompt"
7844
8178
  : "Send prompt",
8179
+ onPointerDown: (event) => event.preventDefault(),
7845
8180
  onClick: sendPrompt,
7846
8181
  }, sendLabel),
7847
8182
  ),