pilotswarm 0.5.59 → 0.5.61

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 (79) hide show
  1. package/README.md +7 -0
  2. package/package.json +2 -2
  3. package/ui/core/src/controller.js +56 -13
  4. package/ui/core/src/history.js +15 -1
  5. package/ui/core/src/index.js +2 -0
  6. package/ui/core/src/moa.js +47 -0
  7. package/ui/core/src/reducer.js +70 -49
  8. package/ui/core/src/selectors.js +27 -13
  9. package/ui/core/src/session-errors.js +97 -0
  10. package/ui/core/src/state.js +1 -0
  11. package/ui/react/src/web-app.js +327 -136
  12. package/web/dist/assets/{abnfDiagram-VRR7QNED-D9Rv2VpV.js → abnfDiagram-VRR7QNED-CCVkGWsr.js} +1 -1
  13. package/web/dist/assets/{arc-BATiucnq.js → arc-y040btO3.js} +1 -1
  14. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CSTMt0si.js → architectureDiagram-ZJ3FMSHR-24Yt5esy.js} +1 -1
  15. package/web/dist/assets/{blockDiagram-677ZJIJ3-C_RGqfeY.js → blockDiagram-677ZJIJ3-BlbS-_GI.js} +1 -1
  16. package/web/dist/assets/{c4Diagram-LMCZKHZV-DH__s__0.js → c4Diagram-LMCZKHZV-_mamn0Fv.js} +1 -1
  17. package/web/dist/assets/channel-COFs9dqk.js +1 -0
  18. package/web/dist/assets/{chunk-2Q5K7J3B-C197fD3B.js → chunk-2Q5K7J3B-TCbIB2Ni.js} +1 -1
  19. package/web/dist/assets/{chunk-32BRIVSS-BjpuBroU.js → chunk-32BRIVSS-C9q5KNKG.js} +1 -1
  20. package/web/dist/assets/{chunk-5VM5RSS4-W_jL8euj.js → chunk-5VM5RSS4-BvoqfU8P.js} +1 -1
  21. package/web/dist/assets/{chunk-EX3LRPZG-a9h0dtlp.js → chunk-EX3LRPZG-zFJjZeNd.js} +1 -1
  22. package/web/dist/assets/{chunk-JWPE2WC7-CQN0qE7F.js → chunk-JWPE2WC7-Ce_6C5M-.js} +1 -1
  23. package/web/dist/assets/{chunk-MOJQB5TN-D-Y6KQ7t.js → chunk-MOJQB5TN-CPjpLM65.js} +1 -1
  24. package/web/dist/assets/{chunk-RYQCIY6F-jPwseC5f.js → chunk-RYQCIY6F-CsmVUSDH.js} +1 -1
  25. package/web/dist/assets/{chunk-V7JOEXUC-DRNCEcQh.js → chunk-V7JOEXUC-CgrmPPOd.js} +1 -1
  26. package/web/dist/assets/{chunk-VR4S4FIN-DzhUcoZ7.js → chunk-VR4S4FIN-BxAHDR7h.js} +1 -1
  27. package/web/dist/assets/{chunk-XXDRQBXY-ldJfyPXK.js → chunk-XXDRQBXY-BGfFoO5_.js} +1 -1
  28. package/web/dist/assets/classDiagram-OUVF2IWQ-B5-KmsCx.js +1 -0
  29. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B5-KmsCx.js +1 -0
  30. package/web/dist/assets/{cose-bilkent-JH36ORCC-BBVo21wR.js → cose-bilkent-JH36ORCC-B6KcngJW.js} +1 -1
  31. package/web/dist/assets/{cynefin-VYW2F7L2-CgRQfdW1.js → cynefin-VYW2F7L2-DaY7l4Ce.js} +1 -1
  32. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Ckz2X0t3.js → cynefinDiagram-TSTJHNR4-BldfGsoa.js} +1 -1
  33. package/web/dist/assets/{dagre-VKFMJZFB-C1SanJiY.js → dagre-VKFMJZFB-C4T9X5Bs.js} +1 -1
  34. package/web/dist/assets/{diagram-FQU43EPY-dBdEifVy.js → diagram-FQU43EPY-DCVA_t-e.js} +1 -1
  35. package/web/dist/assets/{diagram-G47NLZAW-Byqc1VCB.js → diagram-G47NLZAW-pbTZGgE5.js} +1 -1
  36. package/web/dist/assets/{diagram-NH7WQ7WH-9aeTaCsX.js → diagram-NH7WQ7WH-P8Iks122.js} +1 -1
  37. package/web/dist/assets/{diagram-OA4YK3LP-CL0a0o0k.js → diagram-OA4YK3LP-hEjZPipp.js} +1 -1
  38. package/web/dist/assets/{diagram-WEI45ONY-YlMQOJfY.js → diagram-WEI45ONY-CVAzVCGu.js} +1 -1
  39. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-qMZQEnws.js → ebnfDiagram-CCIWWBDH-BLL398qt.js} +1 -1
  40. package/web/dist/assets/{erDiagram-Q63AITRT-C3CWBCtq.js → erDiagram-Q63AITRT-CLTADyUG.js} +1 -1
  41. package/web/dist/assets/{flowDiagram-23GEKE2U-BN79sybL.js → flowDiagram-23GEKE2U-BAdlWAt0.js} +1 -1
  42. package/web/dist/assets/{ganttDiagram-NO4QXBWP-BOWhiAgB.js → ganttDiagram-NO4QXBWP-C7Z2wavr.js} +1 -1
  43. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-CKX9OACp.js → gitGraphDiagram-IHSO6WYX-BI546pYu.js} +1 -1
  44. package/web/dist/assets/index-CvW-4eXF.css +1 -0
  45. package/web/dist/assets/index-DsPaDrf9.js +24 -0
  46. package/web/dist/assets/{infoDiagram-FWYZ7A6U-QQEunPNs.js → infoDiagram-FWYZ7A6U-8C7iQydo.js} +1 -1
  47. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CvMwrnLQ.js → ishikawaDiagram-FXEZZL3T-Co4STVxQ.js} +1 -1
  48. package/web/dist/assets/{journeyDiagram-5HDEW3XC-giNpEVpj.js → journeyDiagram-5HDEW3XC-DbUt11q-.js} +1 -1
  49. package/web/dist/assets/{kanban-definition-HUTT4EX6-CUPPpo27.js → kanban-definition-HUTT4EX6-DUNcFmme.js} +1 -1
  50. package/web/dist/assets/{linear-AXqadfOR.js → linear-Cjaxj94d.js} +1 -1
  51. package/web/dist/assets/{mermaid.core-C78wdk8Y.js → mermaid.core-B3cPFf4f.js} +4 -4
  52. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BGUmh9bK.js → mindmap-definition-LN4V7U3C-DaZtxB4N.js} +1 -1
  53. package/web/dist/assets/{papaparse.min-Bh8RLCk_.js → papaparse.min-vfdso2fs.js} +1 -1
  54. package/web/dist/assets/{pegDiagram-2B236MQR-BHsKXKhK.js → pegDiagram-2B236MQR-DkBpYV7m.js} +1 -1
  55. package/web/dist/assets/{pieDiagram-ENE6RG2P-D3EvWTdH.js → pieDiagram-ENE6RG2P-By3tI-vc.js} +1 -1
  56. package/web/dist/assets/pilotswarm-BL_NbZug.js +98 -0
  57. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CbkxfLkV.js → quadrantDiagram-ABIIQ3AL-DZmVm0xK.js} +1 -1
  58. package/web/dist/assets/{railroadDiagram-RFXS5EU6-H4M6JSDW.js → railroadDiagram-RFXS5EU6-BC5Mbz7q.js} +1 -1
  59. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BJo7JW1v.js → requirementDiagram-TGXJPOKE-CyZNX_O1.js} +1 -1
  60. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DL19MeOP.js → sankeyDiagram-HTMAVEWB-7603NEmu.js} +1 -1
  61. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DyVFc5pQ.js → sequenceDiagram-DBY2YBRQ-CXJ-i2Xj.js} +1 -1
  62. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CKbcKIEQ.js → sizeCapture-X5ZJPWSS-CKT0ATnS.js} +1 -1
  63. package/web/dist/assets/{stateDiagram-2N3HPSRC-BSukSXzS.js → stateDiagram-2N3HPSRC-BIDPX4YU.js} +1 -1
  64. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Dr8oD9T6.js +1 -0
  65. package/web/dist/assets/{swimlanes-5IMT3BWC-BUd7UhsF.js → swimlanes-5IMT3BWC-CPoy6vnO.js} +2 -2
  66. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BV0EQRlB.js +8 -0
  67. package/web/dist/assets/{timeline-definition-FHXFAJF6-Dt1gludn.js → timeline-definition-FHXFAJF6-BGdNkplk.js} +1 -1
  68. package/web/dist/assets/{vennDiagram-L72KCM5P-BHxt00GV.js → vennDiagram-L72KCM5P-ByFzarUb.js} +1 -1
  69. package/web/dist/assets/{wardleyDiagram-EHGQE667-BT3fIDbe.js → wardleyDiagram-EHGQE667-CAk0SYuv.js} +1 -1
  70. package/web/dist/assets/{xychartDiagram-FW5EYKEG-ChoYDS2b.js → xychartDiagram-FW5EYKEG-Np7h6_A0.js} +1 -1
  71. package/web/dist/index.html +3 -3
  72. package/web/dist/assets/channel-CqgY9oZh.js +0 -1
  73. package/web/dist/assets/classDiagram-OUVF2IWQ-Uz4DYZs2.js +0 -1
  74. package/web/dist/assets/classDiagram-v2-EOCWNBFH-Uz4DYZs2.js +0 -1
  75. package/web/dist/assets/index-B4s2lTsK.js +0 -24
  76. package/web/dist/assets/index-DhGdE2Nd.css +0 -1
  77. package/web/dist/assets/pilotswarm-UV6T1_QI.js +0 -98
  78. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BhKG3pJB.js +0 -1
  79. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CgoFkXJE.js +0 -8
@@ -6,6 +6,7 @@ import React from "react";
6
6
  import { createPortal } from "react-dom";
7
7
  import { appendAnimatedDotsToRuns, useAnimatedDots, useSpinnerFrame } from "./chat-status.js";
8
8
  import {
9
+ normalizeMoa,
9
10
  UI_COMMANDS,
10
11
  INSPECTOR_TABS,
11
12
  ARTIFACT_DOWNLOAD_HINT,
@@ -35,6 +36,7 @@ import {
35
36
  selectArtifactPickerModal,
36
37
  selectArtifactUploadModal,
37
38
  selectLiveActivityLines,
39
+ selectActiveOutboxMessages,
38
40
  selectChatLines,
39
41
  selectChatPaneChrome,
40
42
  selectOutboxOverlayLines,
@@ -286,6 +288,7 @@ function clearBrowserPreferenceCache() {
286
288
  function normalizeProfileSettings(settings) {
287
289
  const candidate = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
288
290
  const normalized = {};
291
+ if (hasOwn(candidate, "moa")) normalized.moa = normalizeMoa(candidate.moa);
289
292
  if (typeof candidate.themeId === "string" && candidate.themeId.trim()) {
290
293
  normalized.themeId = candidate.themeId.trim();
291
294
  }
@@ -431,8 +434,18 @@ function hasOwn(value, key) {
431
434
  return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
432
435
  }
433
436
 
437
+ function profileViewState(root) {
438
+ return {
439
+ ...root.ui, ...root.ui.layout,
440
+ ownerFilter: root.sessions.ownerFilter, pinnedIds: root.sessions.pinnedIds,
441
+ manualOrder: root.sessions.manualOrder, collapsedSessionIds: root.sessions.collapsedIds,
442
+ activeSessionId: root.sessions.activeSessionId, canvasPrefs: root.canvas?.prefs,
443
+ };
444
+ }
445
+
434
446
  function profileSettingsFromViewState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null, preservedDesktopPanes = null) {
435
447
  return normalizeProfileSettings({
448
+ ...(state.moa ? { moa: state.moa } : {}),
436
449
  themeId: state.themeId,
437
450
  [touchScaleKey()]: state.touchScale,
438
451
  sessionDetailCollapsed: state.sessionDetailCollapsed,
@@ -498,6 +511,7 @@ function profileSettingsFromViewState(state, preservedOtherTouchScale = null, pr
498
511
 
499
512
  function buildDefaultProfileSettingsFromState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null) {
500
513
  return normalizeProfileSettings({
514
+ ...(state?.ui?.moa ? { moa: state.ui.moa } : {}),
501
515
  themeId: state?.ui?.themeId,
502
516
  [touchScaleKey()]: state?.ui?.touchScale,
503
517
  sessionDetailCollapsed: state?.ui?.sessionDetailCollapsed,
@@ -541,6 +555,7 @@ function materializeProfileSettings(remoteSettings, defaults) {
541
555
  // the server has not persisted yet. An omitted key leaves the
542
556
  // profileSettings/apply guards preserving the current value.
543
557
  return normalizeProfileSettings({
558
+ ...(hasOwn(normalizedRemote, "moa") ? { moa: normalizedRemote.moa } : {}),
544
559
  themeId: hasOwn(normalizedRemote, "themeId")
545
560
  ? normalizedRemote.themeId
546
561
  : normalizedDefaults.themeId,
@@ -611,11 +626,18 @@ function materializeProfileSettings(remoteSettings, defaults) {
611
626
  });
612
627
  }
613
628
 
629
+ const profileSaveQueues = new WeakMap();
614
630
  async function saveProfileSettings(controller, settings) {
615
631
  if (typeof controller?.transport?.setCurrentUserProfileSettings !== "function") return null;
616
- return controller.transport.setCurrentUserProfileSettings({
632
+ // Whole-document writes must arrive in edit order. A slow earlier request
633
+ // must never land after a newer layout and erase it.
634
+ const previous = profileSaveQueues.get(controller) || Promise.resolve();
635
+ const next = previous.catch(() => {}).then(() => controller.transport.setCurrentUserProfileSettings({
617
636
  settings: normalizeProfileSettings(settings),
618
- });
637
+ }));
638
+ profileSaveQueues.set(controller, next);
639
+ try { return await next; }
640
+ finally { if (profileSaveQueues.get(controller) === next) profileSaveQueues.delete(controller); }
619
641
  }
620
642
 
621
643
  function getVisibleInspectorTabs(controller) {
@@ -993,10 +1015,11 @@ function applyDocumentTheme(themeId) {
993
1015
  root.dataset.psTheme = theme.id;
994
1016
  }
995
1017
 
996
- function useMeasuredViewport(ref) {
1018
+ function useMeasuredViewport(ref, suspended = false) {
997
1019
  const [viewport, setViewport] = React.useState({ width: 0, height: 0 });
998
1020
 
999
1021
  React.useLayoutEffect(() => {
1022
+ if (suspended) return undefined;
1000
1023
  const element = ref.current;
1001
1024
  if (!element) return undefined;
1002
1025
 
@@ -1015,7 +1038,7 @@ function useMeasuredViewport(ref) {
1015
1038
  observer.disconnect();
1016
1039
  window.removeEventListener("resize", update);
1017
1040
  };
1018
- }, [ref]);
1041
+ }, [ref, suspended]);
1019
1042
 
1020
1043
  return viewport;
1021
1044
  }
@@ -2862,7 +2885,7 @@ function useElementBox(ref) {
2862
2885
  return box;
2863
2886
  }
2864
2887
 
2865
- function fetchArtifactHtmlObjectUrl(controller, sessionId, filename) {
2888
+ function fetchArtifactHtmlObjectUrl(controller, sessionId, filename, panelKeys = false) {
2866
2889
  const downloadResponse = controller?.transport?.api?.downloadArtifactResponse;
2867
2890
  if (typeof downloadResponse !== "function") {
2868
2891
  return Promise.reject(new Error("artifact download unavailable"));
@@ -2875,7 +2898,18 @@ function fetchArtifactHtmlObjectUrl(controller, sessionId, filename) {
2875
2898
  // would otherwise make the iframe offer a download instead of
2876
2899
  // rendering, and a missing charset would mojibake UTF-8 content.
2877
2900
  const bytes = await response.arrayBuffer();
2878
- return URL.createObjectURL(new Blob([bytes], { type: "text/html;charset=utf-8" }));
2901
+ // Only MoA canvases opt into panel navigation. Ordinary canvases
2902
+ // retain native Tab behavior and their exact document bytes.
2903
+ const bridge = panelKeys ? `<script>window.addEventListener('keydown',function(e){if(e.isTrusted&&!e.altKey&&!e.metaKey&&!e.ctrlKey&&(e.key==='Tab'||e.key==='Escape')){e.preventDefault();e.stopImmediatePropagation();parent.postMessage({type:'moa-panel-key',key:e.key,backwards:e.shiftKey},'*')}},true);</script>` : "";
2904
+ let parts = [bytes];
2905
+ if (panelKeys) {
2906
+ const html = new TextDecoder().decode(bytes);
2907
+ // Keep the doctype first so panel navigation cannot change
2908
+ // the canvas from standards mode into quirks mode.
2909
+ const prolog = /^(?:\uFEFF|\s|<!--[\s\S]*?-->)*<!doctype[^>]*>/i.exec(html)?.[0] || "";
2910
+ parts = [html.slice(0, prolog.length), bridge, html.slice(prolog.length)];
2911
+ }
2912
+ return URL.createObjectURL(new Blob(parts, { type: "text/html;charset=utf-8" }));
2879
2913
  });
2880
2914
  }
2881
2915
 
@@ -3571,15 +3605,14 @@ const AssistantPreviewCard = React.memo(function AssistantPreviewCard({ line, th
3571
3605
  ? React.createElement(Runs, { runs: line.headerRuns, theme })
3572
3606
  : React.createElement(React.Fragment, null,
3573
3607
  React.createElement("span", { className: "ps-canvas-action-tag" }, "Agent"),
3574
- React.createElement("span", { className: "ps-system-notice-summary-text" }, "Message preview"),
3575
- React.createElement("span", { className: "ps-preview-status" },
3576
- line.isLive ? (line.body ? "Responding" : "Thinking") : "Saved"))),
3608
+ React.createElement("span", { className: "ps-system-notice-summary-text" }, line.text),
3609
+ line.statusText ? React.createElement("span", { className: "ps-preview-status" }, line.statusText) : null)),
3577
3610
  React.createElement("div", {
3578
3611
  ref: viewportRef,
3579
3612
  className: "ps-assistant-preview-viewport",
3580
3613
  tabIndex: expanded && !line.final ? 0 : undefined,
3581
3614
  role: line.final ? undefined : "region",
3582
- "aria-label": line.final ? undefined : "Agent message preview",
3615
+ "aria-label": line.final ? undefined : line.text,
3583
3616
  onScroll: (event) => {
3584
3617
  if (!line.final) {
3585
3618
  event.stopPropagation();
@@ -3947,7 +3980,7 @@ function focusRegionForPaneKey(paneKey, override = null) {
3947
3980
  return PANE_KEY_FOCUS_REGIONS[String(paneKey || "")] || null;
3948
3981
  }
3949
3982
 
3950
- 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, ariaLive = null }) {
3983
+ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, lines, stickyLines = [], bottomStickyLines = [], reserveBottomSticky = false, scrollOffset = 0, scrollMode = "top", paneKey, controller, className = "", panelClassName = "", topContent = null, bottomContent = null, structuredBlocks = false, stickyBottom = false, renderBody = null, focusRegion = null, panelRef = null, ariaLive = null }) {
3951
3984
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
3952
3985
  const theme = getTheme(themeId);
3953
3986
  const ref = React.useRef(null);
@@ -4066,9 +4099,9 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
4066
4099
  ? React.createElement(StructuredChatBlocks, { lines: normalizedLines, theme, controller })
4067
4100
  : normalizedLines.map((line, index) => React.createElement(Line, { key: `line:${index}`, line, theme })),
4068
4101
  ),
4069
- normalizedBottomSticky.length > 0
4102
+ (normalizedBottomSticky.length > 0 || reserveBottomSticky)
4070
4103
  ? React.createElement("div", {
4071
- className: "ps-panel-bottom-sticky",
4104
+ className: `ps-panel-bottom-sticky${reserveBottomSticky ? " is-reserved" : ""}`,
4072
4105
  },
4073
4106
  normalizedBottomSticky.map((line, index) => React.createElement(Line, { key: `bottom-sticky:${index}`, line, theme })),
4074
4107
  )
@@ -4868,7 +4901,7 @@ function useAxisLockedPan(ref, enabled = true) {
4868
4901
  }, [ref, enabled]);
4869
4902
  }
4870
4903
 
4871
- function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false, showDetailBox = null }) {
4904
+ function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false, showDetailBox = null, selection = null, actionsOnly = false, actionsHost = null, onAction = null, onDialogChange = null }) {
4872
4905
  // Mobile keeps its inline detail line and normally gets no detail box — a
4873
4906
  // reserved footer would eat a meaningful slice of a phone screen. The
4874
4907
  // sessions-ONLY layout is the exception: it has the whole screen and the
@@ -4894,23 +4927,23 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4894
4927
  const sessionButtonRefs = React.useRef(new Map());
4895
4928
  const viewState = useControllerSelector(controller, (state) => ({
4896
4929
  branding: state.branding,
4897
- activeSessionId: state.sessions.activeSessionId,
4930
+ activeSessionId: selection ? selection.sessionId : state.sessions.activeSessionId,
4898
4931
  sessionsById: state.sessions.byId,
4899
4932
  sessionsFlat: state.sessions.flat,
4900
- filterQuery: state.sessions.filterQuery || "",
4933
+ filterQuery: selection ? selection.query || "" : state.sessions.filterQuery || "",
4901
4934
  ownerFilter: state.sessions.ownerFilter,
4902
4935
  pinnedIds: state.sessions.pinnedIds,
4903
4936
  manualOrder: state.sessions.manualOrder,
4904
- selectedIds: state.sessions.selectedIds,
4905
- selectMode: state.sessions.selectMode,
4937
+ selectedIds: selection ? [] : state.sessions.selectedIds,
4938
+ selectMode: selection ? false : state.sessions.selectMode,
4906
4939
  auth: state.auth,
4907
4940
  connectionMode: state.connection?.mode || "local",
4908
4941
  modalOpen: Boolean(state.ui.modal),
4909
- focused: state.ui.focusRegion === "sessions",
4942
+ focused: selection ? true : state.ui.focusRegion === "sessions",
4910
4943
  // Clicking empty space clears the list highlight; the row VM reads it,
4911
4944
  // so the reconstruction below must carry it or the click does nothing
4912
4945
  // visible (the same omission that blinded the Node Map).
4913
- listDeselected: Boolean(state.sessions.listDeselected),
4946
+ listDeselected: selection ? !selection.sessionId : Boolean(state.sessions.listDeselected),
4914
4947
  // The canvas markers read these. This synthetic state is exactly the
4915
4948
  // trap the branding comment below describes: omit a slice here and
4916
4949
  // every row computes as if it were empty — the markers were null on
@@ -4983,6 +5016,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4983
5016
  setManageOpen(true);
4984
5017
  }
4985
5018
  }, [modelFlowOpen]);
5019
+ React.useEffect(() => { onDialogChange?.(manageOpen || Boolean(linkModal) || modelFlowOpen); }, [manageOpen, linkModal, modelFlowOpen, onDialogChange]);
4986
5020
  const requestSwitchModel = () => {
4987
5021
  reopenManageRef.current = true;
4988
5022
  setManageOpen(false);
@@ -5076,7 +5110,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5076
5110
  // rows/viewState from a ref that is refreshed on every render instead of
5077
5111
  // closing over them.
5078
5112
  const clickEnv = React.useRef(null);
5079
- clickEnv.current = { rows, viewState, controller };
5113
+ clickEnv.current = { rows, viewState, controller, selection };
5080
5114
  // ── Drag sessions into / out of groups ────────────────────────────
5081
5115
  // Pointer-driven so it works from <button> rows, can render a collection
5082
5116
  // ghost for a multi-selection, and can highlight the destination folder.
@@ -5401,7 +5435,12 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5401
5435
  }), [dragState.dragging, dragState.overGroupId, dragState.insertBeforeId, dragState.insertAfterId]);
5402
5436
 
5403
5437
  const handleRowClick = React.useCallback((event, row) => {
5404
- const { rows: currentRows, viewState: current, controller: ctl } = clickEnv.current;
5438
+ const { rows: currentRows, viewState: current, controller: ctl, selection: pick } = clickEnv.current;
5439
+ if (pick) {
5440
+ if (row.isGroup || (row.hasChildren && row.active)) ctl.dispatch({ type: row.collapsed ? "sessions/expand" : "sessions/collapse", sessionId: row.sessionId });
5441
+ if (!row.isGroup) pick.onSelect(row.sessionId);
5442
+ return;
5443
+ }
5405
5444
  // Cmd/Ctrl-click toggles multi-selection (any row).
5406
5445
  if (event.metaKey || event.ctrlKey) {
5407
5446
  event.preventDefault();
@@ -5516,14 +5555,14 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5516
5555
  }, [activeRowRevealKey, viewState.activeSessionId, viewState.focused, viewState.modalOpen, viewState.sessionsFlat]);
5517
5556
 
5518
5557
  const panelActions = React.createElement(React.Fragment, null,
5519
- isBulkSelection
5558
+ !actionsOnly && isBulkSelection
5520
5559
  ? React.createElement("span", {
5521
5560
  className: "ps-mini-button-label",
5522
5561
  style: { padding: "0 6px", fontSize: "12px", opacity: 0.85 },
5523
5562
  title: "Multiple sessions selected. Move to group or cancel selected sessions; click Clear to exit.",
5524
5563
  }, `${selectedCount} selected`)
5525
5564
  : null,
5526
- isBulkSelection
5565
+ !actionsOnly && (isBulkSelection
5527
5566
  ? React.createElement(IconButton, {
5528
5567
  className: "ps-mini-button",
5529
5568
  icon: "✕",
@@ -5539,8 +5578,8 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5539
5578
  label: canPinActiveSession
5540
5579
  ? (isActivePinned ? "Unpin this session" : "Pin this session to the top of the list")
5541
5580
  : "Only top-level non-system sessions can be pinned",
5542
- }),
5543
- React.createElement(IconButton, {
5581
+ })),
5582
+ !actionsOnly && React.createElement(IconButton, {
5544
5583
  className: "ps-mini-button",
5545
5584
  icon: groupableIds.length > 1
5546
5585
  ? React.createElement("span", { className: "ps-icon-badge" },
@@ -5581,9 +5620,9 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5581
5620
  ? "Rename folder"
5582
5621
  : !canModifyActiveSession
5583
5622
  ? "Only this session's owner or an admin can manage it"
5584
- : "Manage session — rename, switch model, and sharing",
5623
+ : actionsOnly ? "Manage session — rename and switch model" : "Manage session — rename, switch model, and sharing",
5585
5624
  }),
5586
- React.createElement(IconButton, {
5625
+ !actionsOnly && React.createElement(IconButton, {
5587
5626
  className: "ps-mini-button",
5588
5627
  icon: React.createElement(LinkGlyph),
5589
5628
  onClick: () => {
@@ -5643,16 +5682,31 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5643
5682
 
5644
5683
  return React.createElement(React.Fragment, null,
5645
5684
  dragGhost,
5646
- React.createElement(Panel, {
5685
+ actionsOnly ? (actionsHost ? createPortal(React.createElement("div", { className: "ps-moa-control-actions", onClick: onAction }, panelActions), actionsHost) : null) : React.createElement(Panel, {
5647
5686
  title: [{ text: "Sessions", color: "yellow", bold: true }],
5648
5687
  color: "yellow",
5649
5688
  focused: viewState.focused,
5650
5689
  theme,
5651
- actions: panelActions,
5690
+ actions: selection ? React.createElement(React.Fragment, null,
5691
+ selection.onCreate ? React.createElement(IconButton, {
5692
+ className: "ps-mini-button", icon: React.createElement(PlusGlyph),
5693
+ label: "Create New Session", onClick: selection.onCreate,
5694
+ }) : null, actions) : panelActions,
5652
5695
  className: combinedPanelClassName,
5653
5696
  },
5697
+ selection ? React.createElement("input", { className: "ps-modal-input", "aria-label": "Find a session", placeholder: "Find a session…", value: selection.query || "", onChange: e => selection.onQuery?.(e.target.value) }) : null,
5654
5698
  React.createElement("div", {
5655
5699
  ref: sessionListRef,
5700
+ onKeyDown: selection ? event => {
5701
+ if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key) || event.altKey || event.ctrlKey || event.metaKey) return;
5702
+ const buttons = [...sessionListRef.current.querySelectorAll(".ps-session-list-button")];
5703
+ const current = buttons.indexOf(event.target.closest(".ps-session-list-button"));
5704
+ const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, current + (event.key === "ArrowUp" ? -1 : 1)));
5705
+ const button = buttons[next]; if (!button) return;
5706
+ event.preventDefault(); event.stopPropagation();
5707
+ if (button.dataset.sessionId) selection.onSelect(button.dataset.sessionId);
5708
+ button.focus();
5709
+ } : undefined,
5656
5710
  className: `ps-action-list ps-session-list${dragState.dragging ? " is-dragging" : ""}`,
5657
5711
  // Clicking empty space below the rows clears the list selection while
5658
5712
  // the other panes keep showing the session. With nothing selected the
@@ -5661,9 +5715,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5661
5715
  // Empty space only: a click that lands on a row bubbles here too.
5662
5716
  const onRow = event.target instanceof Element && event.target.closest(".ps-session-list-button");
5663
5717
  if (onRow) return;
5664
- controller.dispatch({ type: "sessions/listDeselect" });
5718
+ if (selection) selection.onSelect(null);
5719
+ else controller.dispatch({ type: "sessions/listDeselect" });
5665
5720
  },
5666
5721
  },
5722
+
5667
5723
  rows.length === 0
5668
5724
  ? React.createElement("div", { className: "ps-empty-state" }, viewState.filterQuery
5669
5725
  ? `No sessions matched "@@${viewState.filterQuery}".`
@@ -5678,7 +5734,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5678
5734
  setRef: setSessionButtonRef,
5679
5735
  // Drag-to-folder is a fine-pointer gesture: arming it on
5680
5736
  // touch hijacks the finger that should be scrolling the list.
5681
- drag: touchInput ? null : dragHandlers,
5737
+ drag: selection || touchInput ? null : dragHandlers,
5682
5738
  }))),
5683
5739
  (showDetailBox === null ? !isMobilePane : showDetailBox)
5684
5740
  ? React.createElement(SessionDetailBox, {
@@ -5700,6 +5756,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
5700
5756
  controller,
5701
5757
  sessionId: activeSession.sessionId,
5702
5758
  initialTitle: activeSession.title || "",
5759
+ allowSharing: !actionsOnly,
5703
5760
  currentModel: activeSession.model || "",
5704
5761
  currentReasoningEffort: activeSession.reasoningEffort || "",
5705
5762
  principal: viewState.auth?.principal || null,
@@ -5898,6 +5955,13 @@ function SidebarGlyph() {
5898
5955
  // icon size without a label.
5899
5956
  // The Main pane, whatever layout it is in: a frame with a list rail and a
5900
5957
  // conversation beside it. Constant across the three-way cycle.
5958
+ // A tiled command surface: one tall pane beside two stacked panes.
5959
+ function MoaGlyph() {
5960
+ return React.createElement(Glyph, null,
5961
+ React.createElement("rect", { x: 3, y: 4, width: 7, height: 16, rx: 1 }),
5962
+ React.createElement("rect", { x: 13, y: 4, width: 8, height: 6.5, rx: 1 }),
5963
+ React.createElement("rect", { x: 13, y: 13.5, width: 8, height: 6.5, rx: 1 }));
5964
+ }
5901
5965
  function MainLayoutGlyph() {
5902
5966
  return React.createElement(Glyph, null,
5903
5967
  React.createElement("rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }),
@@ -6082,7 +6146,7 @@ function useActiveSessionAccess(controller, activeSessionId, isGroup) {
6082
6146
  // rename, copy link, plus (for the owner/admin) sharing — visibility +
6083
6147
  // per-person grants. Fetches its own access snapshot so callers only pass the
6084
6148
  // session id + current title.
6085
- function SessionModifyModal({ controller, sessionId, initialTitle, currentModel, currentReasoningEffort, principal, onClose, onSwitchModel, onChanged }) {
6149
+ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel, currentReasoningEffort, principal, onClose, onSwitchModel, onChanged, allowSharing = true }) {
6086
6150
  const [tab, setTab] = React.useState("general");
6087
6151
  const [access, setAccess] = React.useState(null);
6088
6152
  const [title, setTitle] = React.useState(initialTitle || "");
@@ -6101,20 +6165,20 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
6101
6165
  .then((a) => { if (!cancelled && a) { setAccess(a); setVisibility(a.visibility || "private"); } })
6102
6166
  .catch(() => {});
6103
6167
  // Member directory for name autocomplete (excludes synthetic principals).
6104
- if (typeof controller.transport.listKnownUsers === "function") {
6168
+ if (allowSharing && typeof controller.transport.listKnownUsers === "function") {
6105
6169
  controller.transport.listKnownUsers({ limit: 500 })
6106
6170
  .then((users) => { if (!cancelled) setDirectory(Array.isArray(users) ? users : []); })
6107
6171
  .catch(() => {});
6108
6172
  }
6109
6173
  return () => { cancelled = true; };
6110
- }, [controller, sessionId]);
6174
+ }, [controller, sessionId, allowSharing]);
6111
6175
 
6112
6176
  const loadShares = React.useCallback(() => {
6113
6177
  controller.transport.listSessionShares(sessionId)
6114
6178
  .then((rows) => { const list = Array.isArray(rows) ? rows : []; setShares(list); setDraftShares(list); })
6115
6179
  .catch(() => { setShares([]); setDraftShares([]); });
6116
6180
  }, [controller, sessionId]);
6117
- React.useEffect(() => { loadShares(); }, [loadShares]);
6181
+ React.useEffect(() => { if (allowSharing) loadShares(); }, [loadShares, allowSharing]);
6118
6182
 
6119
6183
  const run = async (fn) => {
6120
6184
  setBusy(true); setError(null);
@@ -6220,7 +6284,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
6220
6284
  // only rename/switch model on their own session.
6221
6285
  const tabs = [
6222
6286
  { id: "general", label: "General" },
6223
- ...(canManage ? [{ id: "access", label: "Access" }] : []),
6287
+ ...(allowSharing && canManage ? [{ id: "access", label: "Access" }] : []),
6224
6288
  ];
6225
6289
  const activeTab = tabs.some((t) => t.id === tab) ? tab : "general";
6226
6290
  // Regenerate is a change-this-session action, so it belongs here rather
@@ -6344,7 +6408,41 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
6344
6408
  error ? React.createElement("div", { className: "ps-share-error" }, error) : null));
6345
6409
  }
6346
6410
 
6347
- function ChatPane({ controller, mobile = false, fullWidth = false, showComposer = true }) {
6411
+ function SessionComposer({ controller, onReadOnlyFocus = null, mobile = false, compact = false }) {
6412
+ const modal = useControllerSelector(controller, state => state.ui.modal);
6413
+ const session = useControllerSelector(controller, state => state.sessions.byId[state.sessions.activeSessionId]);
6414
+ const { access } = useActiveSessionAccess(controller, session?.sessionId, session?.isGroup);
6415
+ const readOnly = session?.serviceKind || access?.canWrite === false;
6416
+ React.useLayoutEffect(() => { if (readOnly && !modal) onReadOnlyFocus?.(); }, [readOnly, modal, onReadOnlyFocus]);
6417
+ if (!session || session.isGroup || modal) return null;
6418
+ return React.createElement("div", { className: "ps-chat-composer" }, readOnly
6419
+ ? React.createElement("div", { className: "ps-composer-readonly" }, session.serviceKind
6420
+ ? "⚗ Service session — runtime machinery. Its transcript is a read-only trace; it does not accept messages."
6421
+ : `You have view access to this session. Ask ${access.owner?.displayName || access.owner?.email || "the owner"} for write access to participate.`)
6422
+ : React.createElement(PromptComposer, { controller, mobile, compact, autoFocus: !mobile, active: true }));
6423
+ }
6424
+
6425
+ // The compact focus views use their existing header for activity. The same
6426
+ // selector powers the desktop footer, so stale-status and elapsed-time rules
6427
+ // remain identical; queued prompt bodies stay readable in the transcript.
6428
+ function SessionHeaderStatus({ controller }) {
6429
+ const state = useControllerSelector(controller, s => s);
6430
+ const session = state.sessions.byId[state.sessions.activeSessionId];
6431
+ const spinnerFrame = useSpinnerFrame(session?.status === "running");
6432
+ const activity = selectLiveActivityLines(state, { spinnerFrame, maxWidth: 120 });
6433
+ const status = activity[0]?.map(run => run.text || "").join("")
6434
+ || (session ? String(session.status || "idle").replace(/^./, c => c.toUpperCase()) : "");
6435
+ const messages = selectActiveOutboxMessages(state);
6436
+ const queue = ["pending", "queued", "cancelling", "rejected"].map(phase => {
6437
+ const count = messages.filter(message => message.pendingPhase === phase).length;
6438
+ return count ? `${count} ${phase}` : "";
6439
+ }).filter(Boolean).join(" · ");
6440
+ return React.createElement("div", { className: "ps-mobile-session-status", "aria-label": "Session status" },
6441
+ React.createElement("span", { className: "ps-mobile-activity", title: status }, status),
6442
+ queue ? React.createElement("span", { className: "ps-mobile-queue", title: queue }, queue) : null);
6443
+ }
6444
+
6445
+ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer = true, onEnterZen = null, activityInHeader = false }) {
6348
6446
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
6349
6447
  const theme = getTheme(themeId);
6350
6448
  const viewState = useControllerSelector(controller, (state) => {
@@ -6455,6 +6553,10 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
6455
6553
  () => (pinnedActivityLines.length > 0 ? [...outboxLines, ...pinnedActivityLines] : outboxLines),
6456
6554
  [outboxLines, pinnedActivityLines],
6457
6555
  );
6556
+ const transcriptLines = React.useMemo(
6557
+ () => activityInHeader && outboxLines.length ? [...lines, ...outboxLines] : lines,
6558
+ [activityInHeader, lines, outboxLines],
6559
+ );
6458
6560
  // Read-only gating: a view-only viewer (shared_read / read grant, no write)
6459
6561
  // gets an explanatory notice instead of the composer. The visibility chip
6460
6562
  // and Share affordance now live in the session list "Modify" modal and the
@@ -6510,11 +6612,12 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
6510
6612
  titleRight: mobile && titleRight ? compactTitleRuns(titleRight, 18) : titleRight,
6511
6613
  // Chat/Summary toggling lives on the top toolbar so the pane chrome
6512
6614
  // stays clean. The toolbar button is disabled for group sessions.
6513
- actions: null,
6615
+ actions: mobile && onEnterZen ? React.createElement(IconButton, { className: "ps-mini-button", icon: React.createElement(ExpandGlyph), label: "Enter mobile zen", onClick: onEnterZen }) : null,
6514
6616
  color: chrome.color,
6515
6617
  focused: viewState.focused,
6516
- lines,
6517
- bottomStickyLines: stickyBottom,
6618
+ lines: transcriptLines,
6619
+ bottomStickyLines: activityInHeader ? EMPTY_ARRAY : stickyBottom,
6620
+ reserveBottomSticky: !activityInHeader,
6518
6621
  scrollOffset: viewState.scroll,
6519
6622
  scrollMode: "bottom",
6520
6623
  paneKey: "chat",
@@ -6864,7 +6967,7 @@ function useKeyboardTakeover(enabled) {
6864
6967
  * detail sub-panel). This replaced chat-focus mode, which was a second way to
6865
6968
  * say "chat only" with its own chrome and its own exit.
6866
6969
  */
6867
- function MobileWorkspace({ controller, layout = "split" }) {
6970
+ function MobileWorkspace({ controller, layout = "split", onEnterZen }) {
6868
6971
  const sessionPane = React.createElement(SessionPane, {
6869
6972
  controller,
6870
6973
  panelClassName: "ps-mobile-session-pane",
@@ -6872,7 +6975,7 @@ function MobileWorkspace({ controller, layout = "split" }) {
6872
6975
  if (layout === "chat") {
6873
6976
  return React.createElement("div", { className: "ps-mobile-workspace is-chat-only" },
6874
6977
  React.createElement("div", { className: "ps-mobile-chat-pane" },
6875
- React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
6978
+ React.createElement(ChatPane, { controller, mobile: true, fullWidth: true, onEnterZen })));
6876
6979
  }
6877
6980
  if (layout === "sessions") {
6878
6981
  return React.createElement("div", { className: "ps-mobile-workspace is-sessions-only" },
@@ -6885,7 +6988,7 @@ function MobileWorkspace({ controller, layout = "split" }) {
6885
6988
  return React.createElement("div", { className: "ps-mobile-workspace" },
6886
6989
  sessionPane,
6887
6990
  React.createElement("div", { className: "ps-mobile-chat-pane" },
6888
- React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
6991
+ React.createElement(ChatPane, { controller, mobile: true, fullWidth: true, onEnterZen })));
6889
6992
  }
6890
6993
 
6891
6994
  /** Frame-and-easel glyph for the Canvas toggle. */
@@ -7233,7 +7336,9 @@ function RestoreGlyph() {
7233
7336
  * loads hidden-but-painted behind the live frame and is promoted on load, so
7234
7337
  * a live redraw is a composite swap of something already drawn.
7235
7338
  */
7236
- function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null, dataPatch = null }) {
7339
+ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null, dataPatch = null, onPanelKey = null }) {
7340
+ const panelKeyRef = React.useRef(onPanelKey); panelKeyRef.current = onPanelKey;
7341
+ const panelKeys = Boolean(onPanelKey);
7237
7342
  const [live, setLive] = React.useState(null); // { url, rev }
7238
7343
  const [staging, setStaging] = React.useState(null); // { url, rev }
7239
7344
  const [loadError, setLoadError] = React.useState(null); // { rev }
@@ -7285,9 +7390,13 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
7285
7390
  // window that asked). Writes and actions stay live-frame only.
7286
7391
  const fromStaging = Boolean(stagingIframeElRef.current && event.source === stagingIframeElRef.current.contentWindow);
7287
7392
  if (!fromLive && !fromStaging) return;
7393
+ if (payload.type === "moa-panel-key") {
7394
+ if (fromLive && document.activeElement === frameEl && ["Tab", "Escape"].includes(payload.key)) panelKeyRef.current?.(payload.key, payload.backwards === true);
7395
+ return;
7396
+ }
7288
7397
  if (payload.type === "canvas-action") {
7289
7398
  if (!fromLive) return;
7290
- controller.submitCanvasAction(sessionId, payload).then((result) => {
7399
+ controller.submitCanvasAction(sessionId, payload, slot).then((result) => {
7291
7400
  if (result && result.ok === false && typeof console !== "undefined") {
7292
7401
  console.warn(`canvas action rejected: ${result.reason}`);
7293
7402
  }
@@ -7372,7 +7481,7 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
7372
7481
  if (!controller || !sessionId || !latestRev) return undefined;
7373
7482
  let cancelled = false;
7374
7483
  let retryTimer = null;
7375
- fetchArtifactHtmlObjectUrl(controller, sessionId, slot <= 1 ? "canvas.html" : `canvas${slot}.html`)
7484
+ fetchArtifactHtmlObjectUrl(controller, sessionId, slot <= 1 ? "canvas.html" : `canvas${slot}.html`, panelKeys)
7376
7485
  .then((url) => {
7377
7486
  if (cancelled) { URL.revokeObjectURL(url); return; }
7378
7487
  // A staging revision that never loaded is superseded here —
@@ -7400,7 +7509,7 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
7400
7509
  cancelled = true;
7401
7510
  if (retryTimer) clearTimeout(retryTimer);
7402
7511
  };
7403
- }, [controller, sessionId, slot, latestRev, retryTick]);
7512
+ }, [controller, sessionId, slot, latestRev, retryTick, panelKeys]);
7404
7513
 
7405
7514
  // Session switch / unmount: drop both frames — a different session is a
7406
7515
  // different document, and showing the old canvas under a new session is a
@@ -8605,7 +8714,7 @@ function formatAttachmentSize(sizeBytes) {
8605
8714
  return `${bytes} B`;
8606
8715
  }
8607
8716
 
8608
- function PromptComposer({ controller, mobile, active = true, onAfterSend = null }) {
8717
+ function PromptComposer({ controller, mobile, compact = false, active = true, onAfterSend = null, autoFocus = true }) {
8609
8718
  const promptState = useControllerSelector(controller, (state) => {
8610
8719
  const activeSessionId = state.sessions.activeSessionId;
8611
8720
  const activeSession = activeSessionId ? state.sessions.byId[activeSessionId] || null : null;
@@ -8634,6 +8743,20 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
8634
8743
  const inputRef = React.useRef(null);
8635
8744
  const attachInputRef = React.useRef(null);
8636
8745
  const [dragOver, setDragOver] = React.useState(false);
8746
+ const selectedQueued = promptState.selectedOutboxPhase === "queued";
8747
+ const selectedCancelling = promptState.selectedOutboxPhase === "cancelling";
8748
+ const selectedReadOnly = selectedQueued || selectedCancelling;
8749
+ const placeholder = mobile && !promptState.editingPending ? (promptState.answerMode ? "Answer…" : "Message…") : promptState.answerMode
8750
+ ? "Type an answer and press Enter"
8751
+ : promptState.editingPending
8752
+ ? selectedCancelling
8753
+ ? "Cancellation requested"
8754
+ : selectedQueued
8755
+ ? "Queued message selected"
8756
+ : "Edit the pending message, then send or cancel it"
8757
+ : promptState.hasOutbox
8758
+ ? "Type a message and press Enter to queue it behind the pending batch"
8759
+ : "Type a message and press Enter";
8637
8760
 
8638
8761
  // Auto-grow: one line idle, sized to the RENDERED content (scrollHeight
8639
8762
  // sees soft wrap; counting "\n" does not). CSS max-height provides the
@@ -8661,7 +8784,10 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
8661
8784
  const mayHaveShrunk = length <= lastPromptLengthRef.current;
8662
8785
  lastPromptLengthRef.current = length;
8663
8786
  growInput(mayHaveShrunk || length === 0);
8664
- }, [growInput, promptState.value]);
8787
+ // Empty textareas measure their placeholder too. Send briefly shows
8788
+ // the long outbox hint; acknowledgement shortens it without changing
8789
+ // the value or width, so neither input nor ResizeObserver will fire.
8790
+ }, [growInput, promptState.value, placeholder]);
8665
8791
  React.useEffect(() => {
8666
8792
  // Width changes re-wrap the content; re-measure on viewport resizes
8667
8793
  // (covers rotation and the on-screen keyboard shrinking the pane).
@@ -8724,6 +8850,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
8724
8850
  const inputNode = inputRef.current;
8725
8851
  if (!active || promptState.modalOpen || !promptState.focused || !inputNode) return;
8726
8852
  if (document.activeElement !== inputNode) {
8853
+ if (!autoFocus) return;
8727
8854
  // Programmatic focus pops the on-screen keyboard on touch devices;
8728
8855
  // there, focus only ever comes from the user's own tap.
8729
8856
  if (mobile) return;
@@ -8743,7 +8870,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
8743
8870
  if (inputNode.value === promptState.value && inputNode.selectionStart !== promptState.cursor) {
8744
8871
  inputNode.setSelectionRange(promptState.cursor, promptState.cursor);
8745
8872
  }
8746
- }, [active, mobile, promptState.cursor, promptState.value, promptState.focused, promptState.modalOpen]);
8873
+ }, [active, mobile, autoFocus, promptState.cursor, promptState.value, promptState.focused, promptState.modalOpen]);
8747
8874
 
8748
8875
  const sendPrompt = React.useCallback(() => {
8749
8876
  controller.handleCommand(UI_COMMANDS.SEND_PROMPT)
@@ -8780,12 +8907,9 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
8780
8907
  : promptState.hasOutbox
8781
8908
  ? "+"
8782
8909
  : "❯";
8783
- const selectedQueued = promptState.selectedOutboxPhase === "queued";
8784
- const selectedCancelling = promptState.selectedOutboxPhase === "cancelling";
8785
- const selectedReadOnly = selectedQueued || selectedCancelling;
8786
8910
 
8787
8911
  return React.createElement("div", {
8788
- className: `ps-prompt-shell${mobile ? " is-mobile" : ""}${dragOver ? " is-drag-over" : ""}`,
8912
+ className: `ps-prompt-shell${compact ? " is-compact" : ""}${mobile ? " is-mobile" : ""}${dragOver ? " is-drag-over" : ""}`,
8789
8913
  ...(canAttachImages ? {
8790
8914
  onDragOver: (event) => {
8791
8915
  if (event.dataTransfer?.types?.includes?.("Files")) {
@@ -8852,17 +8976,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
8852
8976
  rows: 1,
8853
8977
  value: promptState.value,
8854
8978
  readOnly: selectedReadOnly,
8855
- placeholder: promptState.answerMode
8856
- ? "Type an answer and press Enter"
8857
- : promptState.editingPending
8858
- ? selectedCancelling
8859
- ? "Cancellation requested"
8860
- : selectedQueued
8861
- ? "Queued message selected"
8862
- : "Edit the pending message, then send or cancel it"
8863
- : promptState.hasOutbox
8864
- ? "Type a message and press Enter to queue it behind the pending batch"
8865
- : "Type a message and press Enter",
8979
+ placeholder,
8866
8980
  // On touch, Enter inserts a newline (the chevron sends) — the
8867
8981
  // keyboard must not advertise a send that will not happen.
8868
8982
  enterKeyHint: mobile ? "enter" : "send",
@@ -8913,7 +9027,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
8913
9027
  // pointerdown preventDefault on every action keeps focus in the
8914
9028
  // textarea — on a phone, tapping Send must not collapse the
8915
9029
  // keyboard between messages.
8916
- canAttachImages
9030
+ canAttachImages && !compact
8917
9031
  ? React.createElement(React.Fragment, null,
8918
9032
  React.createElement("input", {
8919
9033
  ref: attachInputRef,
@@ -8950,7 +9064,7 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
8950
9064
  onClick: cancelPending,
8951
9065
  }, selectedQueued ? "Delete" : "Cancel")
8952
9066
  : null,
8953
- promptState.canStopTurn || stoppingTurn
9067
+ !compact && (promptState.canStopTurn || stoppingTurn)
8954
9068
  ? React.createElement("button", {
8955
9069
  type: "button",
8956
9070
  className: `ps-stop-button${stoppingTurn ? " is-stopping" : ""}`,
@@ -9006,14 +9120,29 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
9006
9120
  const timerRef = React.useRef(null);
9007
9121
  const longPressRef = React.useRef(false);
9008
9122
 
9009
- // Keep the tooltip within the viewport horizontally — the leftmost/rightmost
9010
- // buttons would otherwise clip off the edge (the tooltip is center-anchored).
9123
+ // Measure wrapped text against the visual viewport, including the keyboard.
9011
9124
  React.useLayoutEffect(() => {
9012
- if (!tip || !tipRef.current || typeof window === "undefined") return;
9013
- const half = tipRef.current.offsetWidth / 2;
9014
- const margin = 6;
9015
- const clampedX = Math.max(half + margin, Math.min(tip.x, window.innerWidth - half - margin));
9016
- tipRef.current.style.left = `${clampedX}px`;
9125
+ if (!tip || !tipRef.current || !btnRef.current) return;
9126
+ const el = tipRef.current, viewport = window.visualViewport;
9127
+ const update = () => {
9128
+ const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
9129
+ const width = viewport?.width || window.innerWidth, height = viewport?.height || window.innerHeight;
9130
+ const margin = 6, r = btnRef.current.getBoundingClientRect();
9131
+ el.style.width = "max-content";
9132
+ el.style.maxWidth = `${Math.min(220, width - 2 * margin)}px`;
9133
+ el.style.maxHeight = `${height - 2 * margin}px`;
9134
+ const box = el.getBoundingClientRect();
9135
+ let below = tip.placement === "below";
9136
+ if (!below && r.top - box.height - margin < top) below = true;
9137
+ if (below && r.bottom + box.height + margin > top + height && r.top - box.height - margin >= top) below = false;
9138
+ const x = Math.max(left + margin, Math.min(r.left + r.width / 2 - box.width / 2, left + width - box.width - margin));
9139
+ const y = Math.max(top + margin, Math.min(below ? r.bottom + margin : r.top - box.height - margin, top + height - box.height - margin));
9140
+ el.style.transform = "none";
9141
+ el.style.left = `${x}px`; el.style.top = `${y}px`;
9142
+ };
9143
+ update(); viewport?.addEventListener("resize", update); viewport?.addEventListener("scroll", update);
9144
+ window.addEventListener("resize", update);
9145
+ return () => { viewport?.removeEventListener("resize", update); viewport?.removeEventListener("scroll", update); window.removeEventListener("resize", update); };
9017
9146
  }, [tip]);
9018
9147
 
9019
9148
  const reveal = (preferAbove = false) => {
@@ -9135,7 +9264,7 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
9135
9264
  tooltipNode);
9136
9265
  }
9137
9266
 
9138
- function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPane = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
9267
+ function Toolbar({ controller, mobile, moa = null, canvasPaneOpen = false, onToggleCanvasPane = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
9139
9268
  const [headerSlot, setHeaderSlot] = React.useState(null);
9140
9269
  React.useEffect(() => {
9141
9270
  if (typeof document === "undefined") return;
@@ -9274,10 +9403,21 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9274
9403
  icon: React.createElement(MainLayoutGlyph),
9275
9404
  label: "Workspace — sessions, chat and panels",
9276
9405
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_WORKSPACE).catch(() => {}),
9277
- active: !adminVisible && !budgetOpen,
9406
+ active: !moa?.active && !adminVisible && !budgetOpen,
9278
9407
  }]),
9279
9408
  ];
9280
9409
 
9410
+ if (moa) buttonDefs.push({
9411
+ key: "moa", icon: React.createElement(MoaGlyph),
9412
+ label: moa.returnTo ? "Back to MoA — Master of Agents" : "Master of Agents",
9413
+ active: moa.active, disabled: !moa.loaded,
9414
+ onClick: () => {
9415
+ controller.dispatch({ type: "ui/canvasMaximized", on: false });
9416
+ controller.handleCommand(UI_COMMANDS.OPEN_WORKSPACE).catch(() => {});
9417
+ moa.open();
9418
+ },
9419
+ });
9420
+
9281
9421
  const renderButton = (def) => {
9282
9422
  const button = React.createElement(IconButton, {
9283
9423
  key: def.badge ? undefined : def.key,
@@ -9331,8 +9471,9 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9331
9471
  ];
9332
9472
  return React.createElement("div", { className: "ps-toolbar is-mobile" },
9333
9473
  React.createElement("div", { className: "ps-toolbar-row ps-toolbar-row-primary" },
9334
- React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton)),
9474
+ React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.filter(def => def.key !== "moa").map(renderButton)),
9335
9475
  React.createElement("div", { className: "ps-toolbar-row-actions is-panes" },
9476
+ ...buttonDefs.filter(def => def.key === "moa").map(renderButton),
9336
9477
  paneDefs.map((def) => React.createElement(IconButton, {
9337
9478
  key: def.id === "workspace" ? "workspace" : "diagnostics",
9338
9479
  icon: React.createElement(def.icon),
@@ -9355,10 +9496,10 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9355
9496
  // mode. Budget and the Admin Console replace the workspace; nothing on
9356
9497
  // the left applies there. Modes are exclusive (the controller closes one
9357
9498
  // when the other opens) and the Workspace button is the way back.
9358
- const mode = adminVisible ? "admin" : (budgetOpen ? "budget" : "workspace");
9499
+ const mode = moa?.active ? "moa" : adminVisible ? "admin" : (budgetOpen ? "budget" : "workspace");
9359
9500
  const ACTIONS = ["new", "filter"];
9360
9501
  const PANELS = ["canvas", "diagnostics"];
9361
- const MODES = ["workspace", "budget", "admin"];
9502
+ const MODES = ["workspace", "moa", "budget", "admin"];
9362
9503
 
9363
9504
  // While the canvas is full screen, ANY other button first drops full
9364
9505
  // screen and then does its own job. Pressing Filter and watching nothing
@@ -9366,10 +9507,11 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9366
9507
  // it — is the confusing half of every full-screen mode. The Canvas toggle
9367
9508
  // is exempt: it already means "put the canvas away", and closing drops
9368
9509
  // the flag in the reducer.
9369
- const withRestore = (def) => (!canvasMaximized || def.key === "canvas" ? def : {
9510
+ const withRestore = (def) => ((!canvasMaximized || def.key === "canvas") && !(moa?.active && ["workspace", "budget", "admin"].includes(def.key)) ? def : {
9370
9511
  ...def,
9371
9512
  onClick: (...args) => {
9372
9513
  controller.dispatch({ type: "ui/canvasMaximized", on: false });
9514
+ if (moa?.active && ["workspace", "budget", "admin"].includes(def.key)) moa.leave();
9373
9515
  return def.onClick?.(...args);
9374
9516
  },
9375
9517
  });
@@ -9390,14 +9532,14 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
9390
9532
  ...pick(["theme"]),
9391
9533
  ];
9392
9534
 
9393
- const toolbar = React.createElement("div", { className: "ps-toolbar" },
9535
+ const toolbar = React.createElement("div", { className: `ps-toolbar${moa?.active ? " is-moa" : ""}` },
9394
9536
  // Three columns: left rail | main controls | right rail. Full-screen
9395
9537
  // canvas uses the otherwise-empty left rail for the normal actions so
9396
9538
  // they do not crowd the canvas metadata and controls across the top.
9397
9539
  React.createElement("div", { className: "ps-toolbar-side is-left" },
9398
- canvasMaximized ? leftCluster : null),
9540
+ moa?.active ? React.createElement("div", { id: "ps-moa-status-slot" }) : canvasMaximized ? leftCluster : null),
9399
9541
  React.createElement("div", { className: "ps-toolbar-actions" },
9400
- canvasMaximized ? null : leftCluster),
9542
+ moa?.active ? React.createElement("div", { id: "ps-moa-header-slot" }) : canvasMaximized ? null : leftCluster),
9401
9543
  React.createElement("div", { className: "ps-toolbar-side is-right" },
9402
9544
  // The portal header parks its version/status meta here, LEFT of
9403
9545
  // the tool buttons, and its sign-out glyph in the slot after
@@ -10129,32 +10271,30 @@ function budgetPlural(n, one, many) {
10129
10271
  return `${n} ${n === 1 ? one : many}`;
10130
10272
  }
10131
10273
 
10132
- /**
10133
- * A model reference a session can run: `<provider>:<model>`.
10134
- *
10135
- * The catalog's `providerId` is the TYPE ("azure-openai"); a provider is an
10136
- * instance of that type with a name of its own, and both a model-scoped limit
10137
- * and a default tuple are refused by the database unless the model half names
10138
- * the provider it belongs to.
10139
- */
10140
- function budgetModelsForProvider(models, provider) {
10141
- if (!provider?.typeId) return [];
10142
- return models
10143
- .filter((model) => model.providerId === provider.typeId)
10144
- .map((model) => ({
10145
- qualifiedName: `${provider.name}:${model.modelName}`,
10146
- modelName: model.modelName,
10147
- }));
10148
- }
10149
-
10150
- /** The provider TYPES this deployment can instantiate, from its model catalog. */
10151
- function budgetProviderTypes(models) {
10152
- const seen = new Map();
10274
+ /** Runtime catalogs name a pool; type catalogs name a provider template. */
10275
+ function budgetModelsForProvider(models, provider, rawRows = []) {
10276
+ if (!provider?.name) return [];
10277
+ const choices = new Map();
10153
10278
  for (const model of models) {
10154
- if (!model.providerId || seen.has(model.providerId)) continue;
10155
- seen.set(model.providerId, { id: model.providerId, type: model.providerType || model.providerId });
10279
+ const matches = model.catalogKind === "runtime_provider"
10280
+ ? model.providerId === provider.name
10281
+ : model.catalogKind === "provider_type"
10282
+ ? model.providerId === provider.typeId
10283
+ : model.providerId === provider.name || model.providerId === provider.typeId;
10284
+ if (!matches) continue;
10285
+ const qualifiedName = `${provider.name}:${model.modelName}`;
10286
+ choices.set(qualifiedName, { qualifiedName, modelName: model.modelName });
10287
+ }
10288
+ // Existing caps remain editable even when the catalog is unavailable or a
10289
+ // model was retired. Never borrow a model reference from another pool.
10290
+ for (const row of rawRows) {
10291
+ if (row.providerName !== provider.name || row.rowKind !== "model"
10292
+ || !String(row.scope || "").startsWith(`${provider.name}:`)) continue;
10293
+ choices.set(row.scope, {
10294
+ qualifiedName: row.scope, modelName: row.scope.slice(provider.name.length + 1),
10295
+ });
10156
10296
  }
10157
- return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id));
10297
+ return [...choices.values()];
10158
10298
  }
10159
10299
 
10160
10300
  /** listModels() is flat on the web transport and grouped on the direct one. */
@@ -10164,15 +10304,18 @@ function budgetNormalizeModels(raw) {
10164
10304
  : (Array.isArray(raw?.providers) ? raw.providers : (Array.isArray(raw?.models) ? raw.models : []));
10165
10305
  const flat = top.flatMap((entry) => (
10166
10306
  Array.isArray(entry?.models)
10167
- ? entry.models.map((model) => ({ ...model, providerId: model.providerId || entry.providerId }))
10307
+ ? entry.models.map((model) => ({
10308
+ ...model, providerId: model.providerId || entry.providerId,
10309
+ catalogKind: model.catalogKind || entry.catalogKind,
10310
+ }))
10168
10311
  : [entry]
10169
10312
  ));
10170
10313
  return flat
10171
10314
  .filter((model) => model && (model.modelName || model.qualifiedName))
10172
10315
  .map((model) => ({
10173
- modelName: model.modelName || String(model.qualifiedName).split(":").pop(),
10316
+ modelName: model.modelName || String(model.qualifiedName).split(":").slice(1).join(":"),
10174
10317
  providerId: model.providerId || "",
10175
- providerType: model.providerType || model.providerId || "",
10318
+ catalogKind: model.catalogKind || null,
10176
10319
  }));
10177
10320
  }
10178
10321
 
@@ -10710,7 +10853,7 @@ function ProviderSystemSpend({ view }) {
10710
10853
  * reachable.
10711
10854
  */
10712
10855
  function EditProviderSheet({
10713
- row, rawRows, models, isAdmin, busy, error, onCancel, onRun,
10856
+ row, rawRows, models, modelsLoading, modelsError, onRetryModels, isAdmin, busy, error, onCancel, onRun,
10714
10857
  initialPeriod = "day", initialScope = "*",
10715
10858
  }) {
10716
10859
  const shared = row.class === "shared";
@@ -10854,10 +10997,13 @@ function EditProviderSheet({
10854
10997
  className: "ps-budget-input", "aria-label": "Model this limit applies to",
10855
10998
  value: model, onChange: (event) => setModel(event.target.value),
10856
10999
  }, models.length === 0
10857
- ? React.createElement("option", { value: "" }, "No models are listed for this provider's type")
11000
+ ? React.createElement("option", { value: "" }, modelsLoading ? "Loading models…" : modelsError ? "Model catalog unavailable" : "No models listed for this provider")
10858
11001
  : models.map((option) => React.createElement("option", {
10859
11002
  key: option.qualifiedName, value: option.qualifiedName,
10860
- }, option.qualifiedName))) : null),
11003
+ }, option.qualifiedName))) : null,
11004
+ modelsError ? React.createElement("div", { className: "ps-budget-note is-warn", role: "status" },
11005
+ "Could not load the model catalog. Existing model limits remain editable. ",
11006
+ React.createElement("button", { type: "button", onClick: onRetryModels, disabled: modelsLoading }, "Retry")) : null),
10861
11007
  React.createElement(SheetField, {
10862
11008
  label: "Tokens",
10863
11009
  hint: parsed
@@ -11643,6 +11789,9 @@ function ProviderBudgetView({ controller }) {
11643
11789
  // a failure is not fatal — the controls that need it say they have nothing
11644
11790
  // to offer.
11645
11791
  const [models, setModels] = React.useState([]);
11792
+ const [modelsLoading, setModelsLoading] = React.useState(true);
11793
+ const [modelsError, setModelsError] = React.useState(false);
11794
+ const [modelsRequest, setModelsRequest] = React.useState(0);
11646
11795
  const [providerTypes, setProviderTypes] = React.useState(new Map());
11647
11796
  const [defaults, setDefaults] = React.useState(null);
11648
11797
  const readDefaults = React.useCallback(() => {
@@ -11653,11 +11802,14 @@ function ProviderBudgetView({ controller }) {
11653
11802
  }, [controller]);
11654
11803
  React.useEffect(() => {
11655
11804
  let live = true;
11656
- if (typeof controller.transport.listModels === "function") {
11657
- Promise.resolve(controller.transport.listModels())
11658
- .then((rows) => { if (live) setModels(budgetNormalizeModels(rows)); })
11659
- .catch(() => {});
11660
- }
11805
+ setModelsLoading(true);
11806
+ Promise.resolve().then(() => {
11807
+ if (typeof controller.transport.listModels !== "function") throw new Error("Model catalog unavailable");
11808
+ return controller.transport.listModels();
11809
+ }).then((rows) => {
11810
+ if (live) { setModels(budgetNormalizeModels(rows)); setModelsError(false); }
11811
+ }).catch(() => { if (live) setModelsError(true); })
11812
+ .finally(() => { if (live) setModelsLoading(false); });
11661
11813
  if (typeof controller.transport.listProviders === "function") {
11662
11814
  Promise.resolve(controller.transport.listProviders())
11663
11815
  .then((rows) => {
@@ -11670,7 +11822,7 @@ function ProviderBudgetView({ controller }) {
11670
11822
  }
11671
11823
  readDefaults();
11672
11824
  return () => { live = false; };
11673
- }, [controller, readDefaults]);
11825
+ }, [controller, readDefaults, modelsRequest]);
11674
11826
 
11675
11827
  // Opened by the toolbar, which loads it. Mounting with nothing behind it —
11676
11828
  // a restored state, a second mount — reads once rather than showing zeros.
@@ -11755,13 +11907,14 @@ function ProviderBudgetView({ controller }) {
11755
11907
  const modelsForSelected = selected
11756
11908
  ? budgetModelsForProvider(models, {
11757
11909
  name: selected.providerName, typeId: providerTypes.get(selected.providerName) || "",
11758
- })
11910
+ }, rawRows)
11759
11911
  : [];
11760
11912
 
11761
11913
  let sheetEl = null;
11762
11914
  if (sheet?.kind === "edit" && selectedProvider) {
11763
11915
  sheetEl = React.createElement(EditProviderSheet, {
11764
11916
  row: selectedProvider, rawRows, models: modelsForSelected, isAdmin: view.isAdmin,
11917
+ modelsLoading, modelsError, onRetryModels: () => setModelsRequest((value) => value + 1),
11765
11918
  // MAJOR 17: the sheet opens on the limit the reader was standing
11766
11919
  // on, rather than always on Daily / all models.
11767
11920
  initialPeriod: sheet.period || "day",
@@ -12596,8 +12749,17 @@ function DistillerModelPickers({ controller, extras }) {
12596
12749
  );
12597
12750
  }
12598
12751
 
12599
- function useKeyboardShortcuts(controller, mobile) {
12752
+ function ScopedModalLayer({ controller }) {
12753
+ const modal = useControllerSelector(controller, state => state.ui.modal);
12754
+ // Isolated MoA controllers own keyboard commands only while their dialog
12755
+ // is open. They must never install normal-workspace shortcuts globally.
12756
+ useKeyboardShortcuts(controller, false, !modal);
12757
+ return React.createElement(ModalLayer, { controller });
12758
+ }
12759
+
12760
+ function useKeyboardShortcuts(controller, mobile, suspended = false) {
12600
12761
  React.useEffect(() => {
12762
+ if (suspended) return undefined;
12601
12763
  const handler = (event) => {
12602
12764
  // A focused control that handled the key owns it. The diagnostics
12603
12765
  // seam answers ArrowUp/ArrowDown itself; letting the same keydown
@@ -12942,7 +13104,7 @@ function useKeyboardShortcuts(controller, mobile) {
12942
13104
 
12943
13105
  window.addEventListener("keydown", handler);
12944
13106
  return () => window.removeEventListener("keydown", handler);
12945
- }, [controller, mobile]);
13107
+ }, [controller, mobile, suspended]);
12946
13108
  }
12947
13109
 
12948
13110
  function formatAdminPrincipalLabel(principal) {
@@ -14143,11 +14305,11 @@ export function createWebPilotSwarmController({ transport, mode = "remote", bran
14143
14305
  return new PilotSwarmUiController({ store, transport });
14144
14306
  }
14145
14307
 
14146
- export function PilotSwarmWebApp({ controller }) {
14308
+ export function PilotSwarmWebApp({ controller, suspended = false, moa = null }) {
14147
14309
  const viewportRef = React.useRef(null);
14148
14310
  const mainGridRef = React.useRef(null);
14149
- const viewport = useMeasuredViewport(viewportRef);
14150
- const mainGridViewport = useMeasuredViewport(mainGridRef);
14311
+ const viewport = useMeasuredViewport(viewportRef, suspended);
14312
+ const mainGridViewport = useMeasuredViewport(mainGridRef, suspended);
14151
14313
  const gridViewport = computeGridViewport(viewport);
14152
14314
 
14153
14315
  // Publish the real viewport in character cells. Without this the ui-core
@@ -14209,6 +14371,7 @@ export function PilotSwarmWebApp({ controller }) {
14209
14371
  // toolbar button). Desktop has real columns and needs no such cycle.
14210
14372
  const [mobileMainLayout, setMobileMainLayout] = React.useState("split");
14211
14373
  const state = useControllerSelector(controller, (rootState) => ({
14374
+ moa: rootState.ui.moa,
14212
14375
  themeId: rootState.ui.themeId,
14213
14376
  touchScale: Boolean(rootState.ui.touchScale),
14214
14377
  sessionDetailCollapsed: Boolean(rootState.ui.sessionDetailCollapsed),
@@ -14224,6 +14387,7 @@ export function PilotSwarmWebApp({ controller }) {
14224
14387
  // Conversion to a sorted array happens inside `normalizeProfileSettings`.
14225
14388
  collapsedSessionIds: rootState.sessions.collapsedIds,
14226
14389
  activeSessionId: rootState.sessions.activeSessionId || null,
14390
+ revealedCreatedSessionId: rootState.ui.revealedCreatedSessionId || null,
14227
14391
  promptRows: getStatePromptRows(rootState),
14228
14392
  rightPaneMode: rootState.ui.rightPaneMode || "panes",
14229
14393
  // The two optional desktop columns, independent of each other.
@@ -14278,7 +14442,21 @@ export function PilotSwarmWebApp({ controller }) {
14278
14442
  const readOnlyChatPane = state.activeSessionIsGroup;
14279
14443
  const effectivePromptRows = readOnlyChatPane ? 0 : state.promptRows;
14280
14444
 
14281
- useKeyboardShortcuts(controller, mobile);
14445
+ useKeyboardShortcuts(controller, mobile, suspended);
14446
+
14447
+ const lastCreatedSessionRef = React.useRef(state.revealedCreatedSessionId);
14448
+ React.useEffect(() => {
14449
+ if (lastCreatedSessionRef.current === state.revealedCreatedSessionId) return;
14450
+ lastCreatedSessionRef.current = state.revealedCreatedSessionId;
14451
+ if (!state.revealedCreatedSessionId || state.activeSessionId !== state.revealedCreatedSessionId) return;
14452
+ if (mobile) {
14453
+ setMobilePane("workspace");
14454
+ setMobileMainLayout("chat");
14455
+ }
14456
+ // Creation opens the ordinary session workspace, without modifying
14457
+ // any of the user's saved Master of Agents panels.
14458
+ if (moa?.active) moa.leave();
14459
+ }, [state.revealedCreatedSessionId, state.activeSessionId, mobile, moa]);
14282
14460
 
14283
14461
  // Tell the reducer which device slot of the per-session views this tab
14284
14462
  // owns. Only a desktop applies and records them; a phone keeps its
@@ -14440,7 +14618,7 @@ export function PilotSwarmWebApp({ controller }) {
14440
14618
  );
14441
14619
  const settingsJson = JSON.stringify(settings);
14442
14620
  const currentSettingsBeforeApply = profileSettingsFromViewState(
14443
- controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
14621
+ profileViewState(controller.getState()), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
14444
14622
  );
14445
14623
  const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
14446
14624
  const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
@@ -14448,7 +14626,8 @@ export function PilotSwarmWebApp({ controller }) {
14448
14626
  && currentSettingsBeforeApplyJson !== lastProfileSettingsJsonRef.current;
14449
14627
  const hasPendingLocalWrite = Boolean(profileSettingsSaveTimerRef.current)
14450
14628
  || profileSettingsSaveInFlightRef.current
14451
- || hasUnpersistedLocalChange;
14629
+ || hasUnpersistedLocalChange
14630
+ || controller.getState().ui.moaDirty === true;
14452
14631
  if (!hasPendingLocalWrite && appliedProfileSettingsJsonRef.current !== settingsJson) {
14453
14632
  controller.dispatch({ type: "profileSettings/apply", settings });
14454
14633
  appliedProfileSettingsJsonRef.current = settingsJson;
@@ -14483,7 +14662,7 @@ export function PilotSwarmWebApp({ controller }) {
14483
14662
  // remote state by definition and there is nothing to lose).
14484
14663
  if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
14485
14664
  const currentSettings = profileSettingsFromViewState(
14486
- controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
14665
+ profileViewState(controller.getState()), otherTouchScaleRef.current, desktopRightPaneModeRef.current, desktopPanesRef.current,
14487
14666
  );
14488
14667
  lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
14489
14668
  }
@@ -14527,14 +14706,20 @@ export function PilotSwarmWebApp({ controller }) {
14527
14706
  profileSettingsSaveTimerRef.current = setTimeout(() => {
14528
14707
  profileSettingsSaveTimerRef.current = null;
14529
14708
  profileSettingsSaveInFlightRef.current = true;
14709
+ const moaRevision = controller.getState().ui.moaRevision;
14710
+ controller.dispatch({ type: "ui/moaSaveStatus", status: "saving" });
14530
14711
  saveProfileSettings(controller, settings)
14531
- .catch(() => {})
14712
+ .then(() => controller.dispatch({ type: "ui/moaSaveStatus", status: "saved", revision: moaRevision }))
14713
+ .catch(() => {
14714
+ lastProfileSettingsJsonRef.current = null;
14715
+ controller.dispatch({ type: "ui/moaSaveStatus", status: "error" });
14716
+ })
14532
14717
  .finally(() => {
14533
14718
  profileSettingsSaveInFlightRef.current = false;
14534
14719
  });
14535
14720
  }, 400);
14536
14721
  return undefined;
14537
- }, [controller, state.activeSessionId, state.activityPaneAdjust, state.agentPickerUsage, state.canvasOpen, state.canvasPaneAdjust, state.canvasPrefs, state.canvasZen, state.collapsedSessionIds, state.diagnosticsOpen, state.diagnosticsPaneAdjust, state.diagnosticsSplitAdjust, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.rightPaneMode, state.sessionDetailCollapsed, state.sessionPaneAdjust, state.sessionViews, state.themeId, state.touchScale]);
14722
+ }, [controller, state.moa, state.activeSessionId, state.activityPaneAdjust, state.agentPickerUsage, state.canvasOpen, state.canvasPaneAdjust, state.canvasPrefs, state.canvasZen, state.collapsedSessionIds, state.diagnosticsOpen, state.diagnosticsPaneAdjust, state.diagnosticsSplitAdjust, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.rightPaneMode, state.sessionDetailCollapsed, state.sessionPaneAdjust, state.sessionViews, state.themeId, state.touchScale]);
14538
14723
 
14539
14724
  React.useEffect(() => {
14540
14725
  applyDocumentTheme(state.themeId);
@@ -14645,7 +14830,7 @@ export function PilotSwarmWebApp({ controller }) {
14645
14830
  Number(state.canvasPrefs?.[state.activeSessionId]?.zenRailPx) || 380));
14646
14831
  // Escape steps down one rung: full screen -> zen -> normal workspace.
14647
14832
  React.useEffect(() => {
14648
- if (!canvasMaximized && !zenActive) return undefined;
14833
+ if (suspended || (!canvasMaximized && !zenActive)) return undefined;
14649
14834
  const onKey = (e) => {
14650
14835
  if (e.key !== "Escape") return;
14651
14836
  controller.dispatch(canvasMaximized
@@ -14654,7 +14839,7 @@ export function PilotSwarmWebApp({ controller }) {
14654
14839
  };
14655
14840
  window.addEventListener("keydown", onKey);
14656
14841
  return () => window.removeEventListener("keydown", onKey);
14657
- }, [controller, canvasMaximized, zenActive]);
14842
+ }, [controller, canvasMaximized, zenActive, suspended]);
14658
14843
 
14659
14844
  // The right block is exactly as wide as the columns in it. Pixels, not a
14660
14845
  // share of the window: when a column closes, its pixels go to chat and
@@ -14862,7 +15047,7 @@ export function PilotSwarmWebApp({ controller }) {
14862
15047
  // it — the layer below is a SIBLING of this content and covers it. Drawing
14863
15048
  // a pane here would only be invisible work.
14864
15049
  else if (mobilePane === "canvas") mobileContent = null;
14865
- else mobileContent = React.createElement(MobileWorkspace, { controller, layout: mobileMainLayout });
15050
+ else mobileContent = React.createElement(MobileWorkspace, { controller, layout: mobileMainLayout, onEnterZen: moa?.openMobileZen });
14866
15051
 
14867
15052
  // The phone's canvas layer: a sibling of the content region's pane, NOT a
14868
15053
  // child of any pane. That is deliberate — panes mount and unmount as the
@@ -14877,13 +15062,16 @@ export function PilotSwarmWebApp({ controller }) {
14877
15062
  })
14878
15063
  : null;
14879
15064
 
15065
+ if (suspended) return React.createElement(ControllerContext.Provider, { value: controller },
15066
+ React.createElement(Toolbar, { controller, mobile: false, moa }),
15067
+ React.createElement(ModalLayer, { controller }));
14880
15068
  return React.createElement(ControllerContext.Provider, { value: controller },
14881
15069
  React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
14882
15070
  // The phone's toolbar carries its NAVIGATION (the three view modes,
14883
15071
  // plus the Main layout cycle), so it renders on every pane — hiding it
14884
15072
  // anywhere would trap the user with no way back.
14885
15073
  React.createElement(Toolbar, {
14886
- controller,
15074
+ controller, moa,
14887
15075
  mobile,
14888
15076
  canvasPaneOpen: mobileCanvasOpen,
14889
15077
  onToggleCanvasPane: toggleMobileCanvas,
@@ -14921,3 +15109,6 @@ export function PilotSwarmWebApp({ controller }) {
14921
15109
  : null,
14922
15110
  React.createElement(ModalLayer, { controller })));
14923
15111
  }
15112
+
15113
+ // Browser hosts may compose these existing surfaces with isolated controllers.
15114
+ export { SessionHeaderStatus, ChatPane, CanvasFrame, SessionPane, SessionRowContent, SessionDetailBox, SessionComposer, ScopedModalLayer, ControllerContext };