pilotswarm 0.5.38 → 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 (87) hide show
  1. package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
  2. package/mcp/dist/src/tools/agent-packages.js +34 -14
  3. package/mcp/dist/src/tools/agent-packages.js.map +1 -1
  4. package/package.json +2 -2
  5. package/tui/src/node-sdk-transport.js +138 -18
  6. package/ui/core/src/controller.js +133 -37
  7. package/ui/core/src/history.js +5 -1
  8. package/ui/core/src/index.js +1 -0
  9. package/ui/core/src/layout.js +10 -0
  10. package/ui/core/src/portal-links.js +46 -0
  11. package/ui/core/src/reducer.js +140 -7
  12. package/ui/core/src/selectors.js +51 -7
  13. package/ui/react/src/web-app.js +445 -53
  14. package/web/README.md +28 -0
  15. package/web/api/canvas-plane.js +116 -0
  16. package/web/api/ws.js +120 -5
  17. package/web/config.js +60 -0
  18. package/web/dist/assets/{abnfDiagram-VRR7QNED-DrJhXhSQ.js → abnfDiagram-VRR7QNED-C6FaYMB7.js} +1 -1
  19. package/web/dist/assets/{arc-BEaG9DWd.js → arc-iZBUHcPs.js} +1 -1
  20. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-D9fc1Imx.js → architectureDiagram-ZJ3FMSHR-D4-wnpAG.js} +1 -1
  21. package/web/dist/assets/{blockDiagram-677ZJIJ3-rS5Eikvt.js → blockDiagram-677ZJIJ3-Dz-s103f.js} +1 -1
  22. package/web/dist/assets/{c4Diagram-LMCZKHZV-HKLlX5cR.js → c4Diagram-LMCZKHZV-DE7sGdHE.js} +1 -1
  23. package/web/dist/assets/channel-Bk68hLPV.js +1 -0
  24. package/web/dist/assets/{chunk-2Q5K7J3B-kq0TyLSB.js → chunk-2Q5K7J3B-DSFs_jRM.js} +1 -1
  25. package/web/dist/assets/{chunk-32BRIVSS-O-LMarT5.js → chunk-32BRIVSS-8sBVtZ4f.js} +1 -1
  26. package/web/dist/assets/{chunk-5VM5RSS4-Di4h0CAu.js → chunk-5VM5RSS4-CbIX7k0G.js} +1 -1
  27. package/web/dist/assets/{chunk-EX3LRPZG-C8rgqUOt.js → chunk-EX3LRPZG-ZfmPtWF8.js} +1 -1
  28. package/web/dist/assets/{chunk-JWPE2WC7-BSH41q4T.js → chunk-JWPE2WC7-DifZrqsQ.js} +1 -1
  29. package/web/dist/assets/{chunk-MOJQB5TN-BAFuQHAC.js → chunk-MOJQB5TN-CEvg_lZT.js} +1 -1
  30. package/web/dist/assets/{chunk-RYQCIY6F-DAg6JOs7.js → chunk-RYQCIY6F-DmYOwk6-.js} +1 -1
  31. package/web/dist/assets/{chunk-V7JOEXUC-DbVrT8BG.js → chunk-V7JOEXUC-DOxG9Vxu.js} +1 -1
  32. package/web/dist/assets/{chunk-VR4S4FIN-C6f5_Grp.js → chunk-VR4S4FIN-ic-KB2tF.js} +1 -1
  33. package/web/dist/assets/{chunk-XXDRQBXY-CqcCYpqA.js → chunk-XXDRQBXY-BqeYnem3.js} +1 -1
  34. package/web/dist/assets/classDiagram-OUVF2IWQ-arTJL-2y.js +1 -0
  35. package/web/dist/assets/classDiagram-v2-EOCWNBFH-arTJL-2y.js +1 -0
  36. package/web/dist/assets/{cose-bilkent-JH36ORCC-DJw04eFh.js → cose-bilkent-JH36ORCC-uaIgs2-N.js} +1 -1
  37. package/web/dist/assets/{cynefin-VYW2F7L2-CpjwdEIU.js → cynefin-VYW2F7L2-DzFeugVQ.js} +1 -1
  38. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-RqEIWoT0.js → cynefinDiagram-TSTJHNR4-NrAChuCW.js} +1 -1
  39. package/web/dist/assets/{dagre-VKFMJZFB-C1eGQNK2.js → dagre-VKFMJZFB-Djf9LD2t.js} +1 -1
  40. package/web/dist/assets/{diagram-FQU43EPY-BODLQ7dz.js → diagram-FQU43EPY-BpIP2iD9.js} +1 -1
  41. package/web/dist/assets/{diagram-G47NLZAW-DlhmukMC.js → diagram-G47NLZAW-D_DCw8Oy.js} +1 -1
  42. package/web/dist/assets/{diagram-NH7WQ7WH-Ba_jZoCN.js → diagram-NH7WQ7WH-BztvRvrM.js} +1 -1
  43. package/web/dist/assets/{diagram-OA4YK3LP-DnSiAJBG.js → diagram-OA4YK3LP-BGIwSnbM.js} +1 -1
  44. package/web/dist/assets/{diagram-WEI45ONY-B3KoQ7_W.js → diagram-WEI45ONY-Czj2ttCt.js} +1 -1
  45. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-ChnFc5gf.js → ebnfDiagram-CCIWWBDH-0nqAhMvJ.js} +1 -1
  46. package/web/dist/assets/{erDiagram-Q63AITRT-D2KkJXLZ.js → erDiagram-Q63AITRT-uLoGb9TG.js} +1 -1
  47. package/web/dist/assets/{flowDiagram-23GEKE2U-CmybZufj.js → flowDiagram-23GEKE2U-Bgxy7CWO.js} +1 -1
  48. package/web/dist/assets/{ganttDiagram-NO4QXBWP-BzPurW_v.js → ganttDiagram-NO4QXBWP-5q8zYzbu.js} +1 -1
  49. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-DAbXl4gO.js → gitGraphDiagram-IHSO6WYX-D6-i59JZ.js} +1 -1
  50. package/web/dist/assets/index-B3WlYEvi.css +1 -0
  51. package/web/dist/assets/index-BFa_e8zL.js +24 -0
  52. package/web/dist/assets/{infoDiagram-FWYZ7A6U-C6qGjNA1.js → infoDiagram-FWYZ7A6U-bdx9GrWW.js} +1 -1
  53. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-D2d1tO-9.js → ishikawaDiagram-FXEZZL3T-xzL6Bssj.js} +1 -1
  54. package/web/dist/assets/{journeyDiagram-5HDEW3XC-fP13w1b7.js → journeyDiagram-5HDEW3XC-C1-18xOk.js} +1 -1
  55. package/web/dist/assets/{kanban-definition-HUTT4EX6-BJfWAXZ0.js → kanban-definition-HUTT4EX6-BS4KDL4_.js} +1 -1
  56. package/web/dist/assets/{linear-CltApkHf.js → linear-Dmv3ton3.js} +1 -1
  57. package/web/dist/assets/{mermaid.core-B1Dwetk3.js → mermaid.core-Nhzw7Fl2.js} +4 -4
  58. package/web/dist/assets/{mindmap-definition-LN4V7U3C-CdRUD7LF.js → mindmap-definition-LN4V7U3C-2DSMdlsp.js} +1 -1
  59. package/web/dist/assets/{papaparse.min-C1y3wBH1.js → papaparse.min-H4g6q6aH.js} +1 -1
  60. package/web/dist/assets/{pegDiagram-2B236MQR-CeQkqN6Q.js → pegDiagram-2B236MQR-CM_rgJ2s.js} +1 -1
  61. package/web/dist/assets/{pieDiagram-ENE6RG2P-Dqgc87jA.js → pieDiagram-ENE6RG2P-Cbo8FtuT.js} +1 -1
  62. package/web/dist/assets/pilotswarm-ClLipXts.js +97 -0
  63. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CD7f-w6m.js → quadrantDiagram-ABIIQ3AL-CVsF_pZA.js} +1 -1
  64. package/web/dist/assets/{railroadDiagram-RFXS5EU6-CcsAijSC.js → railroadDiagram-RFXS5EU6-CauKYIfy.js} +1 -1
  65. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BkzF80pL.js → requirementDiagram-TGXJPOKE-DoXh8EL8.js} +1 -1
  66. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-D9XOFw7Y.js → sankeyDiagram-HTMAVEWB-gA_ej2MB.js} +1 -1
  67. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js → sequenceDiagram-DBY2YBRQ-keYBa83Q.js} +1 -1
  68. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CDJV16tP.js → sizeCapture-X5ZJPWSS-Y6NCdqw8.js} +1 -1
  69. package/web/dist/assets/{stateDiagram-2N3HPSRC-C0E-MPXO.js → stateDiagram-2N3HPSRC-MvJfZXqW.js} +1 -1
  70. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-xTZh36yB.js +1 -0
  71. package/web/dist/assets/{swimlanes-5IMT3BWC-n5Vsv7n9.js → swimlanes-5IMT3BWC-D41i7zcr.js} +2 -2
  72. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BjlBuuiD.js +8 -0
  73. package/web/dist/assets/{timeline-definition-FHXFAJF6-DW2gV8-n.js → timeline-definition-FHXFAJF6-CUA2ENV8.js} +1 -1
  74. package/web/dist/assets/{vennDiagram-L72KCM5P-D9dbDm08.js → vennDiagram-L72KCM5P-CBTp-2d6.js} +1 -1
  75. package/web/dist/assets/{wardleyDiagram-EHGQE667-CIcG7fiC.js → wardleyDiagram-EHGQE667-BIFjtpCg.js} +1 -1
  76. package/web/dist/assets/{xychartDiagram-FW5EYKEG-elEIFsc9.js → xychartDiagram-FW5EYKEG-DiamOwOI.js} +1 -1
  77. package/web/dist/index.html +3 -3
  78. package/web/runtime.js +101 -7
  79. package/web/server.js +51 -2
  80. package/web/dist/assets/channel-DRy7FvyF.js +0 -1
  81. package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +0 -1
  82. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +0 -1
  83. package/web/dist/assets/index-CRY94nFp.js +0 -24
  84. package/web/dist/assets/index-DtL0lbis.css +0 -1
  85. package/web/dist/assets/pilotswarm-DayYdi39.js +0 -96
  86. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +0 -1
  87. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +0 -8
@@ -12,11 +12,14 @@ 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,
18
20
  formatCronTimestampForClient,
19
21
  formatHumanDurationSeconds,
22
+ getDiagnosticsSplitAdjustBounds,
20
23
  getPromptInputRows,
21
24
  getTheme,
22
25
  isThemeLight,
@@ -173,8 +176,21 @@ function buildArtifactLinkUrl(sessionId, filename, { fullscreen = true } = {}) {
173
176
  return `${base}&artifact=${encodeURIComponent(filename)}${fullscreen ? "&view=full" : ""}`;
174
177
  }
175
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
+
176
191
  function copySessionLinkText(url) {
177
- 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(() => {});
178
194
  }
179
195
 
180
196
  // Best-effort probe: is this session's deep link openable only by the owner/
@@ -633,6 +649,77 @@ function getPortalInspectorContentWidth(paneWidth, inspectorTab, mobile = false)
633
649
  return Math.max(20, paneWidth - (mobile ? 6 : 4));
634
650
  }
635
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
+
636
723
  function normalizeLines(lines) {
637
724
  const normalized = [];
638
725
 
@@ -3734,10 +3821,17 @@ function focusRegionForPaneKey(paneKey, override = null) {
3734
3821
  return PANE_KEY_FOCUS_REGIONS[String(paneKey || "")] || null;
3735
3822
  }
3736
3823
 
3737
- 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 }) {
3738
3825
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
3739
3826
  const theme = getTheme(themeId);
3740
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]);
3741
3835
  const stickyRef = React.useRef(null);
3742
3836
  const syncingHorizontalRef = React.useRef(false);
3743
3837
  const { normalizedLines, onScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd } = useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom });
@@ -3827,7 +3921,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
3827
3921
  normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
3828
3922
  )
3829
3923
  : null,
3830
- 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 },
3831
3925
  typeof renderBody === "function"
3832
3926
  ? renderBody(normalizedLines, theme)
3833
3927
  : structuredBlocks
@@ -3925,6 +4019,7 @@ function SessionDetailBox({ session, childCount = 0 }) {
3925
4019
  // used, so the box height still cannot move with the selection.
3926
4020
  field("Title", session?.title, "is-title"),
3927
4021
  field("ID", session?.sessionId, "is-id"),
4022
+ field("Owner", session?.owner ? formatAdminPrincipalLabel(session.owner) : null),
3928
4023
  field("Model", model),
3929
4024
  field("Context", context, percent != null && percent >= 85 ? "is-hot" : percent != null && percent >= 70 ? "is-warm" : ""),
3930
4025
  field("Cron", cron, session?.cronActive === true ? "is-armed" : ""),
@@ -5136,15 +5231,24 @@ function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
5136
5231
  return React.createElement("div", { className: "ps-share-overlay", onClick: onClose },
5137
5232
  React.createElement("div", { className: "ps-link-modal", onClick: stop },
5138
5233
  React.createElement("div", { className: "ps-share-modal-head" },
5139
- React.createElement("span", null, "Link copied"),
5234
+ React.createElement("span", null, portalLinkOrigins.length >= 2 ? "Session links copied" : "Link copied"),
5140
5235
  React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
5141
5236
  React.createElement("div", { className: "ps-share-section-sub" },
5142
- "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."),
5143
5240
  React.createElement("div", { className: "ps-link-row" },
5144
- React.createElement("input", {
5145
- ref: inputRef, className: "ps-link-input", readOnly: true, value: url,
5146
- onFocus: () => inputRef.current?.select(),
5147
- }),
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
+ }),
5148
5252
  React.createElement("button", { className: "ps-mini-button", onClick: onCopyAgain }, "Copy")),
5149
5253
  warn
5150
5254
  ? React.createElement("div", { className: "ps-link-warn" }, SESSION_LINK_PRIVATE_WARNING)
@@ -5788,9 +5892,14 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5788
5892
  viewState.sessionsById,
5789
5893
  viewState.sessionsFlat,
5790
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;
5791
5900
  const chrome = React.useMemo(
5792
- () => selectChatPaneChrome(selectorState, { width: viewState.contentWidth }),
5793
- [selectorState, viewState.contentWidth],
5901
+ () => selectChatPaneChrome(selectorState, { width: contentWidth }),
5902
+ [selectorState, contentWidth],
5794
5903
  );
5795
5904
  const animatedDots = useAnimatedDots(Boolean(chrome.animateTitleRight));
5796
5905
  const titleRight = React.useMemo(
@@ -5807,21 +5916,21 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5807
5916
  && Number(viewState.activeHistory?.loadedEventCount || 0) >= AUTO_HISTORY_EVENT_SOFT_CAP,
5808
5917
  );
5809
5918
  const lines = React.useMemo(
5810
- () => selectChatLines(selectorState, viewState.contentWidth, { tableMode: "sentinel" }),
5811
- [selectorState, viewState.contentWidth],
5919
+ () => selectChatLines(selectorState, contentWidth, { tableMode: "sentinel" }),
5920
+ [selectorState, contentWidth],
5812
5921
  );
5813
5922
  const spinnerFrame = useSpinnerFrame(viewState.activeSessionStatus === "running");
5814
5923
  const liveActivityLines = React.useMemo(
5815
- () => selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: viewState.contentWidth }),
5816
- [selectorState, spinnerFrame, viewState.contentWidth],
5924
+ () => selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: contentWidth }),
5925
+ [selectorState, spinnerFrame, contentWidth],
5817
5926
  );
5818
5927
  // The live-activity line is pinned in the bottom-sticky strip (with the
5819
5928
  // outbox overlay), NOT appended to the transcript — it must stay put while
5820
5929
  // chat content scrolls, and it drops the instant the turn ends.
5821
5930
  const pinnedActivityLines = liveActivityLines;
5822
5931
  const outboxLines = React.useMemo(
5823
- () => selectOutboxOverlayLines(selectorState, viewState.contentWidth, { tableMode: "sentinel" }),
5824
- [selectorState, viewState.contentWidth],
5932
+ () => selectOutboxOverlayLines(selectorState, contentWidth, { tableMode: "sentinel" }),
5933
+ [selectorState, contentWidth],
5825
5934
  );
5826
5935
  const stickyBottom = React.useMemo(
5827
5936
  () => (pinnedActivityLines.length > 0 ? [...outboxLines, ...pinnedActivityLines] : outboxLines),
@@ -5892,6 +6001,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5892
6001
  paneKey: "chat",
5893
6002
  className: "is-wrapped",
5894
6003
  panelClassName: "ps-chat-panel",
6004
+ panelRef,
5895
6005
  bottomContent: composer,
5896
6006
  topContent: showLoadOlder
5897
6007
  ? React.createElement("div", { className: "ps-load-older-bar" },
@@ -6254,6 +6364,135 @@ function SessionCanvasMark({ mark }) {
6254
6364
  : null));
6255
6365
  }
6256
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
+
6257
6496
  /**
6258
6497
  * The canvas slot controls: a dropdown when the session has more than one
6259
6498
  * drawn canvas, plus prev/next arrows. Hidden entirely with one canvas —
@@ -6320,7 +6559,7 @@ function RestoreGlyph() {
6320
6559
  * loads hidden-but-painted behind the live frame and is promoted on load, so
6321
6560
  * a live redraw is a composite swap of something already drawn.
6322
6561
  */
6323
- 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 }) {
6324
6563
  const [live, setLive] = React.useState(null); // { url, rev }
6325
6564
  const [staging, setStaging] = React.useState(null); // { url, rev }
6326
6565
  const [loadError, setLoadError] = React.useState(null); // { rev }
@@ -6469,13 +6708,22 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
6469
6708
  liveIframeElRef.current?.contentWindow?.postMessage(
6470
6709
  { type: "canvas-data", data: dataPayload, dataRev }, "*",
6471
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
+ }
6472
6720
  } catch { /* frame mid-teardown */ }
6473
6721
  // On-screen injection = seen; hidden injection leaves the badge lit
6474
6722
  // until the user actually looks (the catch-up below files it then).
6475
6723
  if (controller && sessionId && visibleRef.current) {
6476
6724
  controller.dispatch({ type: "canvas/viewed", sessionId, slot, dataRev });
6477
6725
  }
6478
- }, [live, dataRev, dataPayload, controller, sessionId]);
6726
+ }, [live, dataRev, dataPayload, dataPatch, controller, sessionId]);
6479
6727
 
6480
6728
  // Catch-up receipt: becoming visible with a live revision on screen IS
6481
6729
  // viewing it — this is what clears a badge earned while hidden.
@@ -6599,6 +6847,7 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6599
6847
  }, [controller, view.sessionId]);
6600
6848
 
6601
6849
  const zenOn = useControllerSelector(controller, (s) => s?.ui?.canvasZen === true);
6850
+ const [shareOpen, setShareOpen] = React.useState(false);
6602
6851
  const [toolbarSlot, setToolbarSlot] = React.useState(null);
6603
6852
  React.useEffect(() => {
6604
6853
  if (!maximized) { setToolbarSlot(null); return undefined; }
@@ -6681,6 +6930,15 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6681
6930
  React.createElement(CanvasSlotControls, { controller, view })),
6682
6931
  React.createElement("span", { className: "ps-artifact-pane-actions" },
6683
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,
6684
6942
  // Zen: sessions step away, chat becomes a rail, the canvas is
6685
6943
  // the workbench. The mode for "the work happens on the canvas,
6686
6944
  // chat is for steering".
@@ -6725,11 +6983,20 @@ function CanvasPane({ controller, mobile = false, visible = true, focusOnPromote
6725
6983
  focusOnPromote,
6726
6984
  dataRev: view.latestDataRev,
6727
6985
  dataPayload: view.dataPayload,
6986
+ dataPatch: view.dataPatch,
6728
6987
  })
6729
6988
  : React.createElement("div", { className: "ps-canvas-blank" },
6730
6989
  React.createElement("p", { className: "ps-canvas-blank-title" }, "Nothing on the canvas yet."),
6731
6990
  React.createElement("p", null,
6732
- "Ask the agent to draw — a dashboard, a chart, a diagram — or it will draw when it has something worth showing."))));
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);
6733
7000
  }
6734
7001
 
6735
7002
  function InspectorTabs({ activeTab, controller }) {
@@ -7237,9 +7504,16 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
7237
7504
  // could pan it — in practice it rendered clipped on BOTH edges (the
7238
7505
  // timestamps and the STATS header sliced off) and read as broken. Fitting
7239
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;
7240
7514
  const inspector = React.useMemo(() => selectInspector(selectorState, {
7241
- width: viewState.contentWidth,
7242
- }), [selectorState, viewState.contentWidth]);
7515
+ width: contentWidth,
7516
+ }), [selectorState, contentWidth]);
7243
7517
  const completionByTurn = React.useMemo(() => {
7244
7518
  const history = viewState.activeSessionId
7245
7519
  ? viewState.historyBySessionId?.get(viewState.activeSessionId) || null
@@ -7389,6 +7663,7 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
7389
7663
  : "top",
7390
7664
  stickyBottom: inspector.activeTab === "logs",
7391
7665
  paneKey: "inspector",
7666
+ panelRef,
7392
7667
  className: inspector.activeTab === "history" || inspector.activeTab === "logs" ? "is-wrapped" : "is-preserve",
7393
7668
  panelClassName: `ps-inspector-pane${inspector.activeTab === "sequence" ? " has-preserved-sticky" : ""}${panelClassName ? ` ${panelClassName}` : ""}`.trim(),
7394
7669
  });
@@ -7562,6 +7837,33 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7562
7837
  const inputRef = React.useRef(null);
7563
7838
  const attachInputRef = React.useRef(null);
7564
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]);
7565
7867
 
7566
7868
  const canAttachImages = typeof controller.transport?.supportsPromptImageAttachments === "function"
7567
7869
  && controller.transport.supportsPromptImageAttachments()
@@ -7607,16 +7909,31 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7607
7909
  const inputNode = inputRef.current;
7608
7910
  if (!active || promptState.modalOpen || !promptState.focused || !inputNode) return;
7609
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;
7610
7915
  try {
7611
7916
  inputNode.focus({ preventScroll: true });
7612
7917
  } catch {
7613
7918
  inputNode.focus();
7614
7919
  }
7615
7920
  }
7616
- inputNode.setSelectionRange(promptState.cursor, promptState.cursor);
7617
- }, [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]);
7618
7932
 
7619
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);
7620
7937
  controller.handleCommand(UI_COMMANDS.SEND_PROMPT)
7621
7938
  .catch(() => {})
7622
7939
  .finally(() => {
@@ -7656,7 +7973,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7656
7973
  const selectedReadOnly = selectedQueued || selectedCancelling;
7657
7974
 
7658
7975
  return React.createElement("div", {
7659
- 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" : ""}`,
7660
7977
  ...(canAttachImages ? {
7661
7978
  onDragOver: (event) => {
7662
7979
  if (event.dataTransfer?.types?.includes?.("Files")) {
@@ -7718,7 +8035,9 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7718
8035
  React.createElement("textarea", {
7719
8036
  ref: inputRef,
7720
8037
  className: "ps-prompt-input",
7721
- 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,
7722
8041
  value: promptState.value,
7723
8042
  readOnly: selectedReadOnly,
7724
8043
  placeholder: promptState.answerMode
@@ -7732,7 +8051,9 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7732
8051
  : promptState.hasOutbox
7733
8052
  ? "Type a message and press Enter to queue it behind the pending batch"
7734
8053
  : "Type a message and press Enter",
7735
- 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",
7736
8057
  onFocus: () => controller.setFocus("prompt"),
7737
8058
  onSelect: (event) => controller.setPromptCursor(event.currentTarget.selectionStart || 0),
7738
8059
  onChange: (event) => controller.setPrompt(event.currentTarget.value, event.currentTarget.selectionStart || event.currentTarget.value.length),
@@ -7756,14 +8077,14 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7756
8077
  event.preventDefault();
7757
8078
  return;
7758
8079
  }
7759
- if (event.key === "ArrowUp" && !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) {
7760
8086
  event.preventDefault();
7761
- controller.movePromptCursorVertical(-1);
7762
- return;
7763
- }
7764
- if (event.key === "ArrowDown" && !event.shiftKey && !event.metaKey && !event.altKey) {
7765
- event.preventDefault();
7766
- controller.movePromptCursorVertical(1);
8087
+ setExpanded(false);
7767
8088
  return;
7768
8089
  }
7769
8090
  if (event.key === "Escape" && promptState.editingPending) {
@@ -7782,6 +8103,18 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7782
8103
  },
7783
8104
  }),
7784
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 ? "⤡" : "⤢"),
7785
8118
  canAttachImages
7786
8119
  ? React.createElement(React.Fragment, null,
7787
8120
  React.createElement("input", {
@@ -7805,6 +8138,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7805
8138
  className: "ps-mini-button ps-attach-button",
7806
8139
  title: "Attach images (or paste / drop them into the composer)",
7807
8140
  "aria-label": "Attach images",
8141
+ onPointerDown: (event) => event.preventDefault(),
7808
8142
  onClick: () => attachInputRef.current?.click(),
7809
8143
  }, "📎"))
7810
8144
  : null,
@@ -7814,6 +8148,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7814
8148
  className: "ps-mini-button",
7815
8149
  title: selectedQueued ? "Delete selected queued prompt" : "Cancel selected pending prompt",
7816
8150
  "aria-label": selectedQueued ? "Delete selected queued prompt" : "Cancel selected pending prompt",
8151
+ onPointerDown: (event) => event.preventDefault(),
7817
8152
  onClick: cancelPending,
7818
8153
  }, selectedQueued ? "Delete" : "Cancel")
7819
8154
  : null,
@@ -7824,6 +8159,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7824
8159
  title: "Stop the current turn (the session stays alive and returns to idle)",
7825
8160
  "aria-label": "Stop the current turn",
7826
8161
  disabled: stoppingTurn,
8162
+ onPointerDown: (event) => event.preventDefault(),
7827
8163
  onClick: stopTurn,
7828
8164
  }, "■")
7829
8165
  : null,
@@ -7840,6 +8176,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
7840
8176
  : promptState.hasOutbox
7841
8177
  ? "Queue prompt"
7842
8178
  : "Send prompt",
8179
+ onPointerDown: (event) => event.preventDefault(),
7843
8180
  onClick: sendPrompt,
7844
8181
  }, sendLabel),
7845
8182
  ),
@@ -8194,12 +8531,13 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
8194
8531
  const leftButtons = buttonDefs.filter((d) => !RIGHT_GROUP.has(d.key)).map(withRestore);
8195
8532
  const rightButtons = buttonDefs.filter((d) => RIGHT_GROUP.has(d.key)).map(withRestore);
8196
8533
  const toolbar = React.createElement("div", { className: "ps-toolbar" },
8197
- // Three columns: spacer | main controls | right rail. The main group
8198
- // sits in the CENTER column, so it stays dead-centre in every mode —
8199
- // an auto margin only centres in leftover space, and the full-screen
8200
- // strip was shoving it sideways.
8201
- React.createElement("div", { className: "ps-toolbar-side is-left", "aria-hidden": "true" }),
8202
- React.createElement("div", { className: "ps-toolbar-actions" }, leftButtons.map(renderButton)),
8534
+ // Three columns: left rail | main controls | right rail. Full-screen
8535
+ // canvas uses the otherwise-empty left rail for the normal actions so
8536
+ // they do not crowd the canvas metadata and controls across the top.
8537
+ React.createElement("div", { className: "ps-toolbar-side is-left" },
8538
+ canvasMaximized ? leftButtons.map(renderButton) : null),
8539
+ React.createElement("div", { className: "ps-toolbar-actions" },
8540
+ canvasMaximized ? null : leftButtons.map(renderButton)),
8203
8541
  React.createElement("div", { className: "ps-toolbar-side is-right" },
8204
8542
  // The portal header parks its version/status meta here, LEFT of
8205
8543
  // the tool buttons, and its sign-out glyph in the slot after
@@ -8749,11 +9087,11 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
8749
9087
  const onMove = (event) => {
8750
9088
  const st = dragStateRef.current;
8751
9089
  if (!st) return;
8752
- // Clamped to the half-height each side can actually give. Without
8753
- // this the stored value keeps counting past the edge while the
8754
- // bar stops — and the pointer leads the seam by the overshoot on
8755
- // the way back.
8756
- const next = Math.max(-st.half, Math.min(st.half, st.start + (event.clientY - st.startY)));
9090
+ // The first grid track is `50% + adjust`, while the divider takes
9091
+ // real space between the two panes. Its bounds are asymmetric:
9092
+ // -column/2 makes Inspector 0px; column/2-divider makes Activity
9093
+ // 0px. Using one symmetric half-range left an 8-9px pane remnant.
9094
+ const next = clamp(st.start + (event.clientY - st.startY), st.minAdjust, st.maxAdjust);
8757
9095
  controller.dispatch({ type: "ui/diagnosticsSplitAdjust", diagnosticsSplitAdjust: next });
8758
9096
  };
8759
9097
  const onUp = () => {
@@ -8781,8 +9119,15 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
8781
9119
  event.preventDefault();
8782
9120
  capturePointerForDrag(event);
8783
9121
  const col = event.currentTarget.closest(".ps-workspace-column");
8784
- const half = Math.max(0, ((col?.getBoundingClientRect?.().height || 0) - 16) / 2);
8785
- dragStateRef.current = { startY: event.clientY, start: Number(splitAdjust) || 0, half };
9122
+ const columnHeight = Math.max(0, col?.getBoundingClientRect?.().height || 0);
9123
+ const dividerHeight = Math.max(0, event.currentTarget.getBoundingClientRect().height || 0);
9124
+ const { minAdjust, maxAdjust } = getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight);
9125
+ dragStateRef.current = {
9126
+ startY: event.clientY,
9127
+ start: clamp(Number(splitAdjust) || 0, minAdjust, maxAdjust),
9128
+ minAdjust,
9129
+ maxAdjust,
9130
+ };
8786
9131
  setDragging(true);
8787
9132
  document.body.classList.add("is-resizing-pane-y");
8788
9133
  },
@@ -8790,9 +9135,17 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
8790
9135
  onKeyDown: (event) => {
8791
9136
  if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
8792
9137
  event.preventDefault();
9138
+ const col = event.currentTarget.closest(".ps-workspace-column");
9139
+ const columnHeight = Math.max(0, col?.getBoundingClientRect?.().height || 0);
9140
+ const dividerHeight = Math.max(0, event.currentTarget.getBoundingClientRect().height || 0);
9141
+ const { minAdjust, maxAdjust } = getDiagnosticsSplitAdjustBounds(columnHeight, dividerHeight);
8793
9142
  controller.dispatch({
8794
9143
  type: "ui/diagnosticsSplitAdjust",
8795
- diagnosticsSplitAdjust: (Number(splitAdjust) || 0) + (event.key === "ArrowDown" ? 16 : -16),
9144
+ diagnosticsSplitAdjust: clamp(
9145
+ (Number(splitAdjust) || 0) + (event.key === "ArrowDown" ? dividerHeight : -dividerHeight),
9146
+ minAdjust,
9147
+ maxAdjust,
9148
+ ),
8796
9149
  });
8797
9150
  },
8798
9151
  },
@@ -9984,7 +10337,14 @@ function AdminSettingsTree({ controller, view }) {
9984
10337
  `${row.label}`, React.createElement("span", { className: "ps-admin-tree__count" }, String(row.count ?? 0)));
9985
10338
  }
9986
10339
  const onClick = row.kind === "package"
9987
- ? () => controller.selectAdminPackage(row.name)
10340
+ // The selector says WHICH copy this row is — one name can be a
10341
+ // shared package and a user copy at once (scope shadowing).
10342
+ ? () => controller.selectAdminPackage(row.name, {
10343
+ scope: row.scope === "shared" ? "shared" : "user",
10344
+ ...(row.scope !== "shared" && row.owner?.provider && row.owner?.subject
10345
+ ? { owner: { provider: row.owner.provider, subject: row.owner.subject } }
10346
+ : {}),
10347
+ })
9988
10348
  : () => controller.setAdminSection(row.id === "agents" ? "packages" : row.id === "workers" ? "workers" : "ghcp");
9989
10349
  return React.createElement("button", {
9990
10350
  key: row.id,
@@ -10100,8 +10460,29 @@ function AdminPackageDetailPane({ controller, view }) {
10100
10460
  disabled: Boolean(pending),
10101
10461
  title: `Publish a new version of ${detail.name} from a repo folder`,
10102
10462
  }, "Update"),
10103
- React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.scope === "shared" ? "demote" : "promote"), disabled: Boolean(pending) },
10104
- detail.scope === "shared" ? "Demote to user" : "Promote to shared"),
10463
+ // Promote MOVES this row to shared scope, so it only works
10464
+ // while the shared name is free. Once a shared copy exists,
10465
+ // the update path is republish: this version's exact bytes
10466
+ // become a new version of the existing shared package.
10467
+ detail.scope === "user" && detail.hasSharedCounterpart
10468
+ ? React.createElement("button", {
10469
+ type: "button",
10470
+ className: "ps-mini-button",
10471
+ onClick: act("republish", detail.activeSemver),
10472
+ disabled: Boolean(pending) || !detail.activeSemver,
10473
+ title: `Publish ${detail.name}@${detail.activeSemver ?? "?"} into the existing shared package`,
10474
+ }, pending === "republish" ? "Publishing…" : `Publish ${detail.activeSemver ?? ""} to shared`)
10475
+ : React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.scope === "shared" ? "demote" : "promote"), disabled: Boolean(pending) },
10476
+ detail.scope === "shared" ? "Demote to user" : "Promote to shared"),
10477
+ detail.scope === "shared" && !detail.hasOwnUserCounterpart
10478
+ ? React.createElement("button", {
10479
+ type: "button",
10480
+ className: "ps-mini-button",
10481
+ onClick: act("republish", detail.activeSemver),
10482
+ disabled: Boolean(pending) || !detail.activeSemver,
10483
+ title: "Copy the active version into your user scope as a private test bench",
10484
+ }, pending === "republish" ? "Copying…" : "Copy to my user scope")
10485
+ : null,
10105
10486
  React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.enabled ? "disable" : "enable"), disabled: Boolean(pending) },
10106
10487
  detail.enabled ? "Disable" : "Enable"),
10107
10488
  React.createElement("button", { type: "button", className: "ps-mini-button is-danger", onClick: confirmDelete, disabled: Boolean(pending) }, "Delete"))
@@ -10593,6 +10974,7 @@ export function PilotSwarmWebApp({ controller }) {
10593
10974
  diagnosticsPaneAdjust: rootState.ui.layout?.diagnosticsPaneAdjust ?? 0,
10594
10975
  diagnosticsSplitAdjust: rootState.ui.layout?.diagnosticsSplitAdjust ?? 0,
10595
10976
  focusRegion: rootState.ui.focusRegion,
10977
+ requestedFocusRegion: rootState.ui.requestedFocusRegion,
10596
10978
  inspectorTab: rootState.ui.inspectorTab,
10597
10979
  filesFullscreen: Boolean(rootState.files.fullscreen),
10598
10980
  selectedArtifactId: rootState.files.selectedArtifactId || null,
@@ -10881,10 +11263,20 @@ export function PilotSwarmWebApp({ controller }) {
10881
11263
  // toolbar: the sessions-only layout's list reclaims focus, which
10882
11264
  // bounced the user out of Inspector the instant they tapped it.
10883
11265
  // Returning to Main is the Main button's job now.
10884
- if (mobile && (state.focusRegion === "inspector" || state.focusRegion === "activity")) {
11266
+ //
11267
+ // Gate on the RAW request, not the normalized region: on a phone a
11268
+ // workspace region (sessions/chat) normalizes to inspector, so the
11269
+ // Main button's own setFocus("chat") would otherwise land here as an
11270
+ // "inspector" focus and yank the user into diagnostics the instant
11271
+ // they asked for the workspace. Only a focus the caller genuinely
11272
+ // aimed at inspector/activity switches the pane.
11273
+ const requested = state.requestedFocusRegion;
11274
+ if (mobile
11275
+ && (state.focusRegion === "inspector" || state.focusRegion === "activity")
11276
+ && (requested === "inspector" || requested === "activity")) {
10885
11277
  setMobilePane(state.focusRegion);
10886
11278
  }
10887
- }, [mobile, state.focusRegion]);
11279
+ }, [mobile, state.focusRegion, state.requestedFocusRegion]);
10888
11280
 
10889
11281
  React.useEffect(() => {
10890
11282
  const visibleTabs = getVisibleInspectorTabs(controller);