pilotswarm 0.5.33 → 0.5.35

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 (101) hide show
  1. package/package.json +2 -2
  2. package/tui/src/app.js +10 -0
  3. package/tui/src/node-sdk-transport.js +11 -0
  4. package/ui/core/src/controller.js +533 -24
  5. package/ui/core/src/formatting.js +13 -1
  6. package/ui/core/src/history.js +8 -0
  7. package/ui/core/src/reducer.js +172 -28
  8. package/ui/core/src/selectors.js +160 -272
  9. package/ui/core/src/state.js +34 -1
  10. package/ui/core/src/themes/catppuccin-mocha.js +2 -1
  11. package/ui/core/src/themes/cobalt2.js +2 -1
  12. package/ui/core/src/themes/doom.js +2 -1
  13. package/ui/core/src/themes/dracula.js +2 -1
  14. package/ui/core/src/themes/duroxide.js +136 -0
  15. package/ui/core/src/themes/github-dark.js +2 -1
  16. package/ui/core/src/themes/github-light.js +2 -1
  17. package/ui/core/src/themes/gruvbox-dark.js +2 -1
  18. package/ui/core/src/themes/hacker-x-matrix.js +2 -1
  19. package/ui/core/src/themes/hacker-x-orion-prime.js +2 -1
  20. package/ui/core/src/themes/helpers.js +22 -4
  21. package/ui/core/src/themes/high-contrast-mono.js +2 -1
  22. package/ui/core/src/themes/index.js +26 -5
  23. package/ui/core/src/themes/ms-dos.js +2 -1
  24. package/ui/core/src/themes/noctis-obscuro.js +2 -1
  25. package/ui/core/src/themes/noctis.js +2 -1
  26. package/ui/core/src/themes/rust.js +125 -0
  27. package/ui/core/src/themes/spongebob.js +110 -0
  28. package/ui/core/src/themes/terminal-green.js +2 -1
  29. package/ui/core/src/themes/win95.js +2 -1
  30. package/ui/core/src/themes/winamp.js +2 -1
  31. package/ui/core/src/themes/workspace-dark.js +2 -1
  32. package/ui/react/src/web-app.js +1153 -307
  33. package/web/dist/assets/{abnfDiagram-VRR7QNED-DlP1x0QR.js → abnfDiagram-VRR7QNED-BwJEohLG.js} +1 -1
  34. package/web/dist/assets/{arc-Zo_8acjl.js → arc-CWUJ_S5s.js} +1 -1
  35. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Bc18aajT.js → architectureDiagram-ZJ3FMSHR-i6mrLxuE.js} +1 -1
  36. package/web/dist/assets/{blockDiagram-677ZJIJ3-DU76JwlX.js → blockDiagram-677ZJIJ3-C_KG-qPQ.js} +1 -1
  37. package/web/dist/assets/{c4Diagram-LMCZKHZV-COqs9Vhg.js → c4Diagram-LMCZKHZV-BBJH7hbB.js} +1 -1
  38. package/web/dist/assets/channel-5Ddqi9ib.js +1 -0
  39. package/web/dist/assets/{chunk-2Q5K7J3B-DLg4HU8R.js → chunk-2Q5K7J3B-CXwaFcaM.js} +1 -1
  40. package/web/dist/assets/{chunk-32BRIVSS-CU0Q4Vsz.js → chunk-32BRIVSS-C8ujjkK-.js} +1 -1
  41. package/web/dist/assets/{chunk-5VM5RSS4-BqRSeab5.js → chunk-5VM5RSS4-M9rz7YYk.js} +1 -1
  42. package/web/dist/assets/{chunk-EX3LRPZG-DenHmuGS.js → chunk-EX3LRPZG-bk6tRxOA.js} +1 -1
  43. package/web/dist/assets/{chunk-JWPE2WC7-BUpE28jH.js → chunk-JWPE2WC7-DZ4WSHGH.js} +1 -1
  44. package/web/dist/assets/{chunk-MOJQB5TN-D4WdKP4p.js → chunk-MOJQB5TN-B5-y6tMF.js} +1 -1
  45. package/web/dist/assets/{chunk-RYQCIY6F-DJgnfeyv.js → chunk-RYQCIY6F-yKcnHxm0.js} +1 -1
  46. package/web/dist/assets/{chunk-V7JOEXUC-DII2R5xT.js → chunk-V7JOEXUC-CpoNcATD.js} +1 -1
  47. package/web/dist/assets/{chunk-VR4S4FIN-BJ_02Sft.js → chunk-VR4S4FIN-Bf1yD2fn.js} +1 -1
  48. package/web/dist/assets/{chunk-XXDRQBXY-DTi_vPws.js → chunk-XXDRQBXY-EnPvDVhP.js} +1 -1
  49. package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +1 -0
  50. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +1 -0
  51. package/web/dist/assets/{cose-bilkent-JH36ORCC-DzdVN7Sx.js → cose-bilkent-JH36ORCC-qhKoA2qj.js} +1 -1
  52. package/web/dist/assets/{cynefin-VYW2F7L2-CLw-4-yw.js → cynefin-VYW2F7L2-jcyYelJS.js} +1 -1
  53. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-P8h9O8wh.js → cynefinDiagram-TSTJHNR4-D4RLufLP.js} +1 -1
  54. package/web/dist/assets/{dagre-VKFMJZFB-CDArowlh.js → dagre-VKFMJZFB-C9BA3GTO.js} +1 -1
  55. package/web/dist/assets/{diagram-FQU43EPY-GiMWEGCC.js → diagram-FQU43EPY-DbSSrVUJ.js} +1 -1
  56. package/web/dist/assets/{diagram-G47NLZAW-CpiRJOW2.js → diagram-G47NLZAW-3egISQXQ.js} +1 -1
  57. package/web/dist/assets/{diagram-NH7WQ7WH-BOdTWSov.js → diagram-NH7WQ7WH-8IKEMGRg.js} +1 -1
  58. package/web/dist/assets/{diagram-OA4YK3LP-BPKB7KD5.js → diagram-OA4YK3LP-VnaogqtK.js} +1 -1
  59. package/web/dist/assets/{diagram-WEI45ONY-CPS66m-d.js → diagram-WEI45ONY-DxigXH0T.js} +1 -1
  60. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-nbpYqaxu.js → ebnfDiagram-CCIWWBDH-BSkNwJ9x.js} +1 -1
  61. package/web/dist/assets/{erDiagram-Q63AITRT-BF547i7I.js → erDiagram-Q63AITRT-CKN8JXl6.js} +1 -1
  62. package/web/dist/assets/{flowDiagram-23GEKE2U-CWIuM8_R.js → flowDiagram-23GEKE2U-DDaD5ExP.js} +1 -1
  63. package/web/dist/assets/{ganttDiagram-NO4QXBWP-DZRmOOgZ.js → ganttDiagram-NO4QXBWP-tOrBOuoI.js} +1 -1
  64. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-DQASL6yJ.js → gitGraphDiagram-IHSO6WYX-Cdp867ON.js} +1 -1
  65. package/web/dist/assets/{index-MikpTOty.js → index-BmwU43_u.js} +6 -6
  66. package/web/dist/assets/index-D-nA7gUe.css +1 -0
  67. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DlK6reuU.js → infoDiagram-FWYZ7A6U-DwcKHD2h.js} +1 -1
  68. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DOiJ-xI_.js → ishikawaDiagram-FXEZZL3T-CWffK5kz.js} +1 -1
  69. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B4EVeK0i.js → journeyDiagram-5HDEW3XC-BEAgS-EZ.js} +1 -1
  70. package/web/dist/assets/{kanban-definition-HUTT4EX6-eBT_l0PV.js → kanban-definition-HUTT4EX6-xDm-mRRj.js} +1 -1
  71. package/web/dist/assets/{linear-CGPMpgYy.js → linear-oP9MA0as.js} +1 -1
  72. package/web/dist/assets/{mermaid.core-CMg-D4qI.js → mermaid.core-DgXXt1OS.js} +4 -4
  73. package/web/dist/assets/{mindmap-definition-LN4V7U3C-COIkL1R_.js → mindmap-definition-LN4V7U3C-BBQgVrYC.js} +1 -1
  74. package/web/dist/assets/{papaparse.min-BkzE5bQr.js → papaparse.min-CR6JmBWm.js} +1 -1
  75. package/web/dist/assets/{pegDiagram-2B236MQR-nXLPVk3y.js → pegDiagram-2B236MQR-C2O0cf3j.js} +1 -1
  76. package/web/dist/assets/{pieDiagram-ENE6RG2P-CLFUyga1.js → pieDiagram-ENE6RG2P-D08pUtYU.js} +1 -1
  77. package/web/dist/assets/pilotswarm-D7NLCbuc.js +99 -0
  78. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-BHSt68p5.js → quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js} +1 -1
  79. package/web/dist/assets/{railroadDiagram-RFXS5EU6-CyohRm8J.js → railroadDiagram-RFXS5EU6-ClkdveLI.js} +1 -1
  80. package/web/dist/assets/{requirementDiagram-TGXJPOKE-CQTikEcB.js → requirementDiagram-TGXJPOKE-IY4xbn-Q.js} +1 -1
  81. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CxDlhil0.js → sankeyDiagram-HTMAVEWB-Cpt5i7KM.js} +1 -1
  82. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-n4ZRJPY0.js → sequenceDiagram-DBY2YBRQ-c07lALcz.js} +1 -1
  83. package/web/dist/assets/{sizeCapture-X5ZJPWSS-J_LGaCmN.js → sizeCapture-X5ZJPWSS-CmCDiNbI.js} +1 -1
  84. package/web/dist/assets/{stateDiagram-2N3HPSRC-CWavHbpD.js → stateDiagram-2N3HPSRC-Dueu17zX.js} +1 -1
  85. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +1 -0
  86. package/web/dist/assets/{swimlanes-5IMT3BWC-l-bxBeeJ.js → swimlanes-5IMT3BWC-Csc2Fup8.js} +2 -2
  87. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.js +8 -0
  88. package/web/dist/assets/{timeline-definition-FHXFAJF6-ChA5iURW.js → timeline-definition-FHXFAJF6-4-lf702e.js} +1 -1
  89. package/web/dist/assets/{vennDiagram-L72KCM5P-DVkk3iKL.js → vennDiagram-L72KCM5P-CmlaKpLI.js} +1 -1
  90. package/web/dist/assets/{wardleyDiagram-EHGQE667-CrN8c7Sv.js → wardleyDiagram-EHGQE667-B9Qe44pi.js} +1 -1
  91. package/web/dist/assets/{xychartDiagram-FW5EYKEG-Hym-Hdkg.js → xychartDiagram-FW5EYKEG-BFcc0CLn.js} +1 -1
  92. package/web/dist/index.html +3 -3
  93. package/ui/core/src/themes/dark-high-contrast.js +0 -56
  94. package/ui/core/src/themes/workspace-dark-rich.js +0 -65
  95. package/web/dist/assets/channel-DA38xcdK.js +0 -1
  96. package/web/dist/assets/classDiagram-OUVF2IWQ-1k2XCPxl.js +0 -1
  97. package/web/dist/assets/classDiagram-v2-EOCWNBFH-1k2XCPxl.js +0 -1
  98. package/web/dist/assets/index-Do0UrHeI.css +0 -1
  99. package/web/dist/assets/pilotswarm-C17s5NOb.js +0 -99
  100. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-ClqTpkzd.js +0 -1
  101. package/web/dist/assets/swimlanesDiagram-G3AALYLV-Bfar1rKu.js +0 -8
@@ -24,6 +24,8 @@ import {
24
24
  MIN_ACTIVITY_PANE_HEIGHT,
25
25
  MIN_INSPECTOR_PANE_HEIGHT,
26
26
  DEFAULT_ACTIVITY_PANE_RATIO,
27
+ DEFAULT_LEFT_PANE_RATIO,
28
+ COLLAPSE_RIGHT_THRESHOLD,
27
29
  normalizeFocusRegion,
28
30
  } from "./layout.js";
29
31
  import { parseTerminalMarkupRuns } from "./formatting.js";
@@ -680,7 +682,7 @@ function getRenameSessionPrefix(session) {
680
682
  if (separatorIndex > 0) {
681
683
  return currentTitle.slice(0, separatorIndex).trim() || null;
682
684
  }
683
- return currentTitle || null;
685
+ return null;
684
686
  }
685
687
 
686
688
  function getRenameSessionMaxLength(session) {
@@ -713,6 +715,249 @@ function formatAgentDisplayTitle(agentName, title) {
713
715
  : "Agent";
714
716
  }
715
717
 
718
+ // ─── Agent picker: sections and composition ─────────────────────────────
719
+ //
720
+ // The picker used to be one flat list with `── Shared ──` / `── My agents ──`
721
+ // rules through it. That reads fine at six agents and not at all at forty:
722
+ // a deployment's own roster and four installed packages arrive as one
723
+ // undifferentiated column, and nothing says which agents belong together.
724
+ //
725
+ // So: two sections. BUILT-IN is every agent baked into the deployment (base
726
+ // PilotSwarm plus whatever the layered app ships), INSTALLED is one collapsed
727
+ // subsection per agent package. Inside a section, an agent that declares
728
+ // `startedBy` nests under the agent that starts it — a package's shape is
729
+ // visible before you start a session with it.
730
+
731
+ export const AGENT_PICKER_BUILTIN_KEY = "builtin";
732
+ // Installed packages split by ownership rather than sitting under one heading.
733
+ // "shared with the deployment" and "mine, private" are different trust stories
734
+ // — a shared package's agents run with the fleet's identity — and a user with
735
+ // only one kind should never be asked to read past a heading for the other.
736
+ export const AGENT_PICKER_SHARED_KEY = "installed:shared";
737
+ export const AGENT_PICKER_MINE_KEY = "installed:mine";
738
+
739
+ export function agentPickerPackageKey(agent) {
740
+ const scope = String(agent?.packageScope || agent?.scope || "shared");
741
+ const owner = String(agent?.ownerLabel || "");
742
+ return `pkg:${scope}:${owner}:${String(agent?.packageName || "")}`;
743
+ }
744
+
745
+ // Same normalization the agent RESOLVER uses (see normalizeAgentName in
746
+ // agent-package-format): the runtime matches names case- and
747
+ // punctuation-insensitively, so "Editor_In_Chief" and "editor-in-chief" are
748
+ // one agent. Matching on the raw string here would nest by a stricter rule
749
+ // than the thing it is describing.
750
+ function normalizeAgentKey(value) {
751
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
752
+ }
753
+
754
+ /**
755
+ * Order a set of agents as a forest: entry points first, each immediately
756
+ * followed by the agents it creates.
757
+ *
758
+ * An entry point is an agent no OTHER agent in the same set creates. That is
759
+ * the whole definition — there is no `main` field to declare and keep honest,
760
+ * and a package with one, several or zero entry points all render correctly
761
+ * from the same rule.
762
+ *
763
+ * `startedBy` names that do not resolve inside the set are ignored rather than
764
+ * treated as errors: a package may legitimately be called by a deployment
765
+ * agent, and an agent must never vanish from the picker because of a typo.
766
+ */
767
+ function orderAgentsByComposition(agents) {
768
+ const byName = new Map();
769
+ for (const agent of agents) {
770
+ if (agent?.agentName) byName.set(normalizeAgentKey(agent.agentName), agent);
771
+ }
772
+
773
+ const childrenOf = new Map();
774
+ const parentOf = new Map();
775
+ for (const agent of agents) {
776
+ const self = normalizeAgentKey(agent.agentName);
777
+ const parents = (agent.startedBy || [])
778
+ .map((name) => normalizeAgentKey(name))
779
+ .filter((name) => name && byName.has(name) && name !== self);
780
+ if (parents.length === 0) continue;
781
+ // First resolvable creator wins the nesting slot. An agent started by
782
+ // two entry points is real; showing it twice would double-count the
783
+ // section and make Enter ambiguous.
784
+ const parent = parents[0];
785
+ parentOf.set(agent, parent);
786
+ if (!childrenOf.has(parent)) childrenOf.set(parent, []);
787
+ childrenOf.get(parent).push(agent);
788
+ }
789
+
790
+ // Whether a name in `startedBy` resolved to an agent in THIS set is the
791
+ // signal, not whether the field was present. A package that names a
792
+ // creator in another package — or misspells one — has an agent that
793
+ // nothing here starts, and it must stay an entry point rather than
794
+ // becoming permanently unstartable in a section that still claims it as
795
+ // one. (This is where the default for `supportsDirectStart` is applied;
796
+ // an explicit value always wins.)
797
+ const isNested = (agent) => parentOf.has(agent);
798
+ const startable = (agent) => (
799
+ typeof agent.supportsDirectStart === "boolean"
800
+ ? agent.supportsDirectStart
801
+ : !isNested(agent)
802
+ );
803
+
804
+ const ordered = [];
805
+ const emitted = new Set();
806
+ const emit = (agent, depth, parentName) => {
807
+ // Also the cycle guard: a → b → a stops here rather than recursing.
808
+ if (emitted.has(agent)) return;
809
+ emitted.add(agent);
810
+ ordered.push({
811
+ ...agent,
812
+ depth,
813
+ parentAgentName: parentName || null,
814
+ supportsDirectStart: startable(agent),
815
+ });
816
+ for (const child of childrenOf.get(normalizeAgentKey(agent.agentName)) || []) {
817
+ emit(child, depth + 1, agent.agentName);
818
+ }
819
+ };
820
+
821
+ for (const agent of agents) {
822
+ if (isNested(agent)) continue;
823
+ emit(agent, 0, null);
824
+ }
825
+ // Whatever is left is inside a cycle. Promote the CYCLE MEMBERS — the ones
826
+ // whose parent chain returns to themselves — and let the recursion carry
827
+ // their descendants down at the right depth. Promoting in array order
828
+ // instead would surface an ordinary sub-agent as an entry point purely
829
+ // because it happened to be listed before the cycle it hangs off.
830
+ const inCycle = (agent) => {
831
+ const seen = new Set();
832
+ let current = agent;
833
+ while (current && !seen.has(current)) {
834
+ seen.add(current);
835
+ current = byName.get(parentOf.get(current));
836
+ if (current === agent) return true;
837
+ }
838
+ return false;
839
+ };
840
+ for (const agent of agents) {
841
+ if (!emitted.has(agent) && inCycle(agent)) emit(agent, 0, null);
842
+ }
843
+ // Belt and braces: nothing may be dropped, whatever shape the graph is in.
844
+ for (const agent of agents) emit(agent, 0, null);
845
+ return ordered;
846
+ }
847
+
848
+ /**
849
+ * Flatten the catalog into the VISIBLE row list the picker navigates.
850
+ *
851
+ * Section headings are items, not decoration: arrow keys walk onto them and
852
+ * Enter toggles them, so the whole dialog is reachable without a mouse. Rows
853
+ * inside a collapsed section are simply absent, which is what keeps
854
+ * `selectedIndex` a plain index into this array.
855
+ */
856
+ /** Where the cursor starts. See the call site for why it is not just 0. */
857
+ export function agentPickerInitialIndex(items = []) {
858
+ const firstAgent = items.findIndex((item) => item.kind !== "section");
859
+ if (firstAgent >= 0) return firstAgent;
860
+ const firstClosed = items.findIndex((item) => item.kind === "section" && item.collapsed);
861
+ return firstClosed >= 0 ? firstClosed : 0;
862
+ }
863
+
864
+ export function buildAgentPickerItems(catalog = [], collapsedKeys = [], genericItem = null) {
865
+ const collapsed = new Set(collapsedKeys);
866
+ const items = [];
867
+
868
+ const builtins = catalog.filter((agent) => agent.builtin);
869
+ const packaged = catalog.filter((agent) => !agent.builtin);
870
+
871
+ // Generic sits ABOVE everything, outside both sections and outside every
872
+ // collapse. It is not an agent — it is the absence of one — and it is the
873
+ // most common pick, so it must never be a click away behind a twisty.
874
+ if (genericItem) items.push({ ...genericItem, depth: 0 });
875
+
876
+ if (builtins.length > 0) {
877
+ const builtinAgents = orderAgentsByComposition(builtins);
878
+ items.push({
879
+ id: `section:${AGENT_PICKER_BUILTIN_KEY}`,
880
+ kind: "section",
881
+ sectionKind: "builtin",
882
+ sectionKey: AGENT_PICKER_BUILTIN_KEY,
883
+ title: "Built-in",
884
+ meta: `${builtinAgents.length} agent${builtinAgents.length === 1 ? "" : "s"}`,
885
+ collapsed: collapsed.has(AGENT_PICKER_BUILTIN_KEY),
886
+ depth: 0,
887
+ });
888
+ if (!collapsed.has(AGENT_PICKER_BUILTIN_KEY)) {
889
+ // One level in from the header, so the section reads as a
890
+ // container rather than as a label floating above a flat list.
891
+ items.push(...builtinAgents.map((agent) => ({ ...agent, depth: agent.depth + 1 })));
892
+ }
893
+ }
894
+
895
+ // An empty category is omitted entirely rather than shown reading "0
896
+ // packages" — a heading whose only content is its own emptiness is noise.
897
+ for (const [sectionKey, title, belongs] of [
898
+ [AGENT_PICKER_SHARED_KEY, "Installed · Shared", (agent) => agent.group !== "mine"],
899
+ [AGENT_PICKER_MINE_KEY, "Installed · Yours", (agent) => agent.group === "mine"],
900
+ ]) {
901
+ const members = packaged.filter(belongs);
902
+ if (members.length === 0) continue;
903
+
904
+ const groups = new Map();
905
+ for (const agent of members) {
906
+ const key = agentPickerPackageKey(agent);
907
+ if (!groups.has(key)) groups.set(key, []);
908
+ groups.get(key).push(agent);
909
+ }
910
+ items.push({
911
+ id: `section:${sectionKey}`,
912
+ kind: "section",
913
+ sectionKind: "installed",
914
+ sectionKey,
915
+ title,
916
+ meta: `${groups.size} package${groups.size === 1 ? "" : "s"}`,
917
+ collapsed: collapsed.has(sectionKey),
918
+ depth: 0,
919
+ });
920
+ if (collapsed.has(sectionKey)) continue;
921
+
922
+ const keys = [...groups.keys()].sort((a, b) => {
923
+ const at = groups.get(a)[0];
924
+ const bt = groups.get(b)[0];
925
+ return String(at.packageTitle || at.packageName || "")
926
+ .localeCompare(String(bt.packageTitle || bt.packageName || ""));
927
+ });
928
+ for (const key of keys) {
929
+ const packageAgents = orderAgentsByComposition(groups.get(key));
930
+ const first = packageAgents[0] || {};
931
+ const entries = packageAgents.filter((agent) => agent.depth === 0).length;
932
+ items.push({
933
+ id: `section:${key}`,
934
+ kind: "section",
935
+ sectionKind: "package",
936
+ sectionKey: key,
937
+ title: first.packageTitle || first.packageName || "Package",
938
+ packageName: first.packageName || null,
939
+ packageSemver: first.packageSemver || null,
940
+ packageScope: first.packageScope || null,
941
+ ownerLabel: first.ownerLabel || null,
942
+ mine: first.group === "mine",
943
+ // No scope badge: the section it sits in already says which
944
+ // shelf this is, and repeating "[shared]" on every row under
945
+ // "Installed · Shared" is the kind of noise that makes a list
946
+ // harder to scan, not easier.
947
+ meta: `${entries} ${entries === 1 ? "entry" : "entries"} · ${packageAgents.length} agent${packageAgents.length === 1 ? "" : "s"}`,
948
+ collapsed: collapsed.has(key),
949
+ depth: 1,
950
+ });
951
+ if (collapsed.has(key)) continue;
952
+ // Package rows sit one level in from their section header, so
953
+ // their sub-agents start at depth 2.
954
+ items.push(...packageAgents.map((agent) => ({ ...agent, depth: agent.depth + 2 })));
955
+ }
956
+ }
957
+
958
+ return items;
959
+ }
960
+
716
961
  function buildPromptAttachmentToken(filename) {
717
962
  return `📎 ${String(filename || "").trim()}`;
718
963
  }
@@ -1000,6 +1245,12 @@ function isBinaryPreview(filename, contentType = "") {
1000
1245
  || normalizedType === "application/wasm";
1001
1246
  }
1002
1247
 
1248
+ // How recent a `session.artifact_presented` event must be for the portal to
1249
+ // act on it. Long enough to survive a slow turn and a brief reconnect; short
1250
+ // enough that a catch-up burst after a long disconnect does not reorganize the
1251
+ // workspace around something the agent said ages ago.
1252
+ const PRESENTED_ARTIFACT_FRESHNESS_MS = 120_000;
1253
+
1003
1254
  function truncateFilePreview(content, limit = FILE_PREVIEW_CHAR_LIMIT) {
1004
1255
  const text = String(content ?? "");
1005
1256
  if (text.length <= limit) return text;
@@ -3952,6 +4203,32 @@ export class PilotSwarmUiController {
3952
4203
  }
3953
4204
  }
3954
4205
 
4206
+ /**
4207
+ * Act on a `session.artifact_presented` event from the live stream.
4208
+ *
4209
+ * Separate from mergeSessionEvent so the guards are testable on their own
4210
+ * and so the merge path stays a straight line. Returns whether it revealed.
4211
+ */
4212
+ maybeRevealPresentedArtifact(sessionId, event) {
4213
+ const filename = String(event?.data?.filename || "").trim();
4214
+ if (!filename) return false;
4215
+ if (sessionId !== this.getState().sessions.activeSessionId) return false;
4216
+
4217
+ const createdAt = event?.createdAt ? Date.parse(event.createdAt) : Number.NaN;
4218
+ if (Number.isFinite(createdAt) && Date.now() - createdAt > PRESENTED_ARTIFACT_FRESHNESS_MS) {
4219
+ return false;
4220
+ }
4221
+
4222
+ // Same destination a user click reaches: the artifact reader, with the
4223
+ // chat still beside it. `fullscreen` from the tool means "give it the
4224
+ // whole window" and stays the phone/fullscreen path.
4225
+ const fullscreen = event?.data?.fullscreen === true;
4226
+ this.revealArtifact(sessionId, filename, fullscreen
4227
+ ? { fullscreen: true }
4228
+ : { pane: true }).catch(() => {});
4229
+ return true;
4230
+ }
4231
+
3955
4232
  mergeSessionEvent(sessionId, event) {
3956
4233
  if (!sessionId || !event) return false;
3957
4234
  const state = this.getState();
@@ -3984,6 +4261,24 @@ export class PilotSwarmUiController {
3984
4261
  },
3985
4262
  });
3986
4263
  }
4264
+ // show_artifact: the agent is presenting something to look at, so the
4265
+ // inspector switches to Files and opens that preview live.
4266
+ //
4267
+ // Three guards, because "the UI moves on its own" is only acceptable
4268
+ // when it is unmistakably a response to what is happening right now:
4269
+ //
4270
+ // - active session only. A background session finishing a dashboard
4271
+ // must never yank the pane away from the session being read.
4272
+ // - fresh events only. This path also runs for the catch-up burst
4273
+ // after a reconnect; replaying an hour-old presentation as if it
4274
+ // just happened would be a jump scare, not a feature.
4275
+ // - live path only. Bulk history loads (ensureSessionHistory) do not
4276
+ // come through here, so merely OPENING a session never auto-opens
4277
+ // whatever it last presented. Reopening is what the artifact link
4278
+ // in the transcript and the deep link are for.
4279
+ if (event.eventType === "session.artifact_presented") {
4280
+ this.maybeRevealPresentedArtifact(sessionId, event);
4281
+ }
3987
4282
  this.reconcileOutboxAgainstEvent(sessionId, event);
3988
4283
  const derivedModel = extractSessionModelFromEvent(event);
3989
4284
  const currentSession = this.getState().sessions.byId[sessionId] || { sessionId };
@@ -4417,9 +4712,14 @@ export class PilotSwarmUiController {
4417
4712
  const activeSession = state.sessions?.activeSessionId
4418
4713
  ? state.sessions.byId[state.sessions.activeSessionId]
4419
4714
  : null;
4420
- const groupId = activeSession?.isGroup
4421
- ? activeSession.groupId || sessionGroupIdFromRowId(activeSession.sessionId)
4422
- : activeSession?.groupId;
4715
+ // ONLY when the selected row is the folder itself. Selecting a folder
4716
+ // and pressing New is the user pointing at it; inheriting the group
4717
+ // from a selected MEMBER is not — it filed new sessions inside
4718
+ // whichever folder happened to hold the last session read, so a new
4719
+ // session appeared buried mid-list instead of at the end where the
4720
+ // sort puts an unplaced row.
4721
+ if (!activeSession?.isGroup) return options;
4722
+ const groupId = activeSession.groupId || sessionGroupIdFromRowId(activeSession.sessionId);
4423
4723
  return groupId ? { ...options, groupId } : options;
4424
4724
  }
4425
4725
 
@@ -4503,9 +4803,9 @@ export class PilotSwarmUiController {
4503
4803
  return;
4504
4804
  }
4505
4805
 
4506
- // Display order IS item order (Shared → My agents → Generic) so
4507
- // keyboard navigation walks the list visually; the selector only
4508
- // inserts non-clickable group headings between the segments.
4806
+ // The CATALOG (every agent, section-agnostic). The visible row list is
4807
+ // derived from it by buildAgentPickerItems, which is re-run whenever a
4808
+ // section is expanded or collapsed.
4509
4809
  const agentItems = [];
4510
4810
  for (const agent of agents) {
4511
4811
  const agentName = String(agent?.name || "").trim();
@@ -4533,17 +4833,24 @@ export class PilotSwarmUiController {
4533
4833
  ? "mine"
4534
4834
  : "shared",
4535
4835
  packageName: agent?.packageName || null,
4836
+ packageTitle: agent?.packageTitle || null,
4536
4837
  packageSemver: agent?.packageSemver || null,
4537
4838
  packageScope: agent?.scope || null,
4839
+ ownerLabel: agent?.ownerLabel || null,
4840
+ startedBy: Array.isArray(agent?.startedBy) ? agent.startedBy.filter(Boolean) : [],
4841
+ // RAW: undefined means "not declared". The default is applied
4842
+ // in orderAgentsByComposition, which is the only place that
4843
+ // knows whether a creator actually resolved.
4844
+ supportsDirectStart: typeof agent?.supportsDirectStart === "boolean" ? agent.supportsDirectStart : undefined,
4538
4845
  builtin: agent?.source !== "package",
4539
4846
  });
4540
4847
  }
4541
- const items = [
4848
+ const catalog = [
4542
4849
  ...agentItems.filter((item) => item.group === "shared"),
4543
4850
  ...agentItems.filter((item) => item.group === "mine"),
4544
4851
  ];
4545
- if (allowGeneric) {
4546
- items.push({
4852
+ const genericItem = allowGeneric
4853
+ ? {
4547
4854
  id: "__generic__",
4548
4855
  kind: "generic",
4549
4856
  group: "generic",
@@ -4553,16 +4860,39 @@ export class PilotSwarmUiController {
4553
4860
  splash: null,
4554
4861
  splashMobile: null,
4555
4862
  initialPrompt: null,
4556
- });
4557
- }
4863
+ supportsDirectStart: true,
4864
+ }
4865
+ : null;
4866
+
4867
+ // EVERYTHING starts closed. The dialog opens as a short menu of
4868
+ // categories — Generic, then whichever of Built-in / Shared / Yours
4869
+ // this deployment actually has — and you open the one you want. A
4870
+ // section expanded by default is a section every other user has to
4871
+ // scroll past.
4872
+ const collapsed = [
4873
+ AGENT_PICKER_BUILTIN_KEY,
4874
+ AGENT_PICKER_SHARED_KEY,
4875
+ AGENT_PICKER_MINE_KEY,
4876
+ ...new Set(catalog.filter((item) => !item.builtin).map((item) => agentPickerPackageKey(item))),
4877
+ ];
4878
+ const items = buildAgentPickerItems(catalog, collapsed, genericItem);
4558
4879
 
4559
4880
  this.dispatch({
4560
4881
  type: "ui/modal",
4561
4882
  modal: {
4562
4883
  type: "sessionAgentPicker",
4563
4884
  title: "Select agent for new session",
4885
+ catalog,
4886
+ genericItem,
4887
+ collapsed,
4564
4888
  items,
4565
- selectedIndex: 0,
4889
+ // Land somewhere Enter does something useful. First choice is a
4890
+ // real agent row; with none visible (no generic, no baked
4891
+ // agents, every package closed) fall back to the first CLOSED
4892
+ // package, whose Enter opens it. Landing on an open section
4893
+ // header — which findIndex's -1 used to do via Math.max — made
4894
+ // the very first keystroke collapse the whole list.
4895
+ selectedIndex: agentPickerInitialIndex(items),
4566
4896
  previousFocus,
4567
4897
  sessionOptions: options,
4568
4898
  },
@@ -4838,10 +5168,16 @@ export class PilotSwarmUiController {
4838
5168
  }
4839
5169
 
4840
5170
  openThemePicker() {
5171
+ // listThemes() is already ordered by group then label, so the picker
5172
+ // renders a heading wherever `group` changes rather than re-sorting.
5173
+ // The list itself stays FLAT: headings are drawn between rows, never
5174
+ // as rows, so selectedIndex keeps addressing themes and arrow-key
5175
+ // navigation never lands on a heading.
4841
5176
  const themes = listThemes().map((theme) => ({
4842
5177
  id: theme.id,
4843
5178
  label: theme.label,
4844
5179
  description: theme.description,
5180
+ group: theme.group,
4845
5181
  page: theme.page,
4846
5182
  terminal: theme.terminal,
4847
5183
  tui: theme.tui,
@@ -4978,7 +5314,11 @@ export class PilotSwarmUiController {
4978
5314
  * READ. Switches the inspector to Files, selects the artifact, and warms
4979
5315
  * the preview so the pane is populated by the time it renders.
4980
5316
  */
4981
- async revealArtifact(sessionId, filename, { force = true, fullscreen = false } = {}) {
5317
+ async revealArtifact(sessionId, filename, {
5318
+ force = true,
5319
+ fullscreen = false,
5320
+ pane = false,
5321
+ } = {}) {
4982
5322
  const resolvedSessionId = sessionId || this.getState().sessions.activeSessionId;
4983
5323
  const name = String(filename || "").trim();
4984
5324
  if (!resolvedSessionId || !name) return false;
@@ -4994,12 +5334,37 @@ export class PilotSwarmUiController {
4994
5334
  if (this.getState().sessions.activeSessionId !== resolvedSessionId) {
4995
5335
  await this.loadSession(resolvedSessionId).catch(() => null);
4996
5336
  }
4997
- this.dispatch({ type: "ui/inspectorTab", inspectorTab: "files" });
4998
- // Focus the inspector, not just the tab. On desktop this makes j/k
4999
- // drive the artifact list immediately; on mobile the visible pane is
5000
- // derived from focusRegion, so without this the preview would open on
5001
- // a pane the user cannot see.
5002
- this.setFocus("inspector");
5337
+ // The reader REPLACES the inspector, so it has no business changing
5338
+ // which inspector tab is selected. Switching to Files here meant that
5339
+ // opening an artifact from a session you were watching on the Sequence
5340
+ // tab silently reassigned that tab, and ✕ dropped you on a file list
5341
+ // you never opened. Leaving it alone makes ✕ restore the tab for free,
5342
+ // with no state to capture. The non-pane paths still need Files,
5343
+ // because there the inspector IS the preview.
5344
+ if (!pane) this.dispatch({ type: "ui/inspectorTab", inspectorTab: "files" });
5345
+ // The takeover pane is its own focus surface and, unlike the inspector,
5346
+ // it is what the user is looking at — so it must NOT steal focus to the
5347
+ // artifact list underneath. Chat keeps focus, which is the point: you
5348
+ // followed a link from the transcript and should still be able to type.
5349
+ if (pane) {
5350
+ // Whether the column was already collapsed is read HERE rather than
5351
+ // taken from the caller, so every entry point — a chat card, a deep
5352
+ // link, an agent's show_artifact — gets the same answer.
5353
+ //
5354
+ // And if it WAS collapsed, the column has to be opened: rendering
5355
+ // the reader into a zero-width track produced a pane that existed
5356
+ // in the DOM, reported itself open, and could not be seen. Closing
5357
+ // collapses it again, so the detour is invisible.
5358
+ const wasHidden = Boolean(this.getCurrentLayout()?.rightHidden);
5359
+ if (wasHidden) this.expandRightColumn();
5360
+ this.dispatch({ type: "files/pane", open: true, restoresToHidden: wasHidden });
5361
+ } else {
5362
+ // Focus the inspector, not just the tab. On desktop this makes j/k
5363
+ // drive the artifact list immediately; on mobile the visible pane is
5364
+ // derived from focusRegion, so without this the preview would open on
5365
+ // a pane the user cannot see.
5366
+ this.setFocus("inspector");
5367
+ }
5003
5368
  await this.ensureFilesForSession(resolvedSessionId, { force }).catch(() => null);
5004
5369
  this.dispatch({ type: "files/select", sessionId: resolvedSessionId, filename: name });
5005
5370
  await this.ensureFilePreview(resolvedSessionId, name, { force }).catch(() => null);
@@ -5007,6 +5372,57 @@ export class PilotSwarmUiController {
5007
5372
  return true;
5008
5373
  }
5009
5374
 
5375
+ /**
5376
+ * Close the artifact takeover pane, restoring the right column.
5377
+ *
5378
+ * Restores the inspector/activity split it replaced — unless the column was
5379
+ * already collapsed when the pane opened, in which case it collapses again.
5380
+ * Opening a reader must not become a way to un-hide panes the user had
5381
+ * deliberately resized away.
5382
+ */
5383
+ async closeArtifactPane() {
5384
+ const restoresToHidden = this.getState().files.paneRestoresToHidden;
5385
+ this.dispatch({ type: "files/pane", open: false });
5386
+ this.dispatch({ type: "files/fullscreen", fullscreen: false });
5387
+ // Selection is deliberately left alone: the artifact stays selected in
5388
+ // the Files list, so reopening the column shows what you were reading
5389
+ // rather than an empty preview.
5390
+ this.dispatch({ type: "files/previewOrigin", origin: null });
5391
+ if (restoresToHidden) this.collapseRightColumn();
5392
+ this.dispatch({ type: "ui/focus", focusRegion: FOCUS_REGIONS.CHAT });
5393
+ return { collapsedRightColumn: Boolean(restoresToHidden) };
5394
+ }
5395
+
5396
+ /**
5397
+ * Drive the left/right split far enough right that the layout's own
5398
+ * collapse rule hides the right column.
5399
+ *
5400
+ * Expressed as a paneAdjust rather than a new "hidden" flag so there stays
5401
+ * exactly one source of truth for column width — the same number the
5402
+ * resizer writes. A second flag would let the two disagree.
5403
+ */
5404
+ /** Return the left/right split to its default, un-collapsing the column. */
5405
+ expandRightColumn() {
5406
+ this.dispatch({ type: "ui/paneAdjust", paneAdjust: 0 });
5407
+ const nextLayout = this.getCurrentLayout({ paneAdjust: 0 });
5408
+ const currentFocus = this.getState().ui.focusRegion;
5409
+ const safeFocus = normalizeFocusRegion(currentFocus, nextLayout);
5410
+ if (safeFocus !== currentFocus) this.setFocus(safeFocus);
5411
+ }
5412
+
5413
+ collapseRightColumn() {
5414
+ const layoutState = this.getState().ui.layout || {};
5415
+ const totalWidth = layoutState.viewportWidth ?? 120;
5416
+ const baseLeftWidth = Math.floor(totalWidth * DEFAULT_LEFT_PANE_RATIO);
5417
+ // One past the threshold: `<=` collapses, so land strictly inside it.
5418
+ const paneAdjust = totalWidth - COLLAPSE_RIGHT_THRESHOLD - baseLeftWidth;
5419
+ this.dispatch({ type: "ui/paneAdjust", paneAdjust });
5420
+ const nextLayout = this.getCurrentLayout({ paneAdjust });
5421
+ const currentFocus = this.getState().ui.focusRegion;
5422
+ const safeFocus = normalizeFocusRegion(currentFocus, nextLayout);
5423
+ if (safeFocus !== currentFocus) this.setFocus(safeFocus);
5424
+ }
5425
+
5010
5426
  /**
5011
5427
  * Leave the artifact preview, returning wherever the user came FROM.
5012
5428
  *
@@ -5328,7 +5744,9 @@ export class PilotSwarmUiController {
5328
5744
  type: "ui/modal",
5329
5745
  modal: {
5330
5746
  type: "renameSession",
5331
- title: `Rename (${shortSessionIdValue(sessionId)})`,
5747
+ // A group row's id is synthetic, so showing its short form
5748
+ // reads as a stray hex string rather than an identifier.
5749
+ title: session.isGroup ? "Rename folder" : `Rename (${shortSessionIdValue(sessionId)})`,
5332
5750
  sessionId,
5333
5751
  previousFocus: state.ui.focusRegion,
5334
5752
  value,
@@ -5336,13 +5754,19 @@ export class PilotSwarmUiController {
5336
5754
  agentTitlePrefix,
5337
5755
  currentTitle: String(session.title || "").trim(),
5338
5756
  maxLength,
5757
+ // Folders share this modal but not its caveats: a group has no
5758
+ // agent and no LLM-generated title, so the selector suppresses
5759
+ // the auto-title warning for them.
5760
+ isGroup: Boolean(session.isGroup),
5339
5761
  },
5340
5762
  });
5341
5763
  this.dispatch({
5342
5764
  type: "ui/status",
5343
- text: agentTitlePrefix
5344
- ? `Rename title for ${agentTitlePrefix}; the agent-name prefix stays fixed`
5345
- : "Type a new session title and press Enter to save",
5765
+ text: session.isGroup
5766
+ ? "Type a new folder name and press Enter to save"
5767
+ : agentTitlePrefix
5768
+ ? `Rename title for ${agentTitlePrefix}; the agent-name prefix stays fixed`
5769
+ : "Type a new session title and press Enter to save",
5346
5770
  });
5347
5771
  }
5348
5772
 
@@ -5839,6 +6263,72 @@ export class PilotSwarmUiController {
5839
6263
  this.dispatch({ type: "ui/status", text: "Connected" });
5840
6264
  }
5841
6265
 
6266
+ /**
6267
+ * Open or close one agent-picker section.
6268
+ *
6269
+ * The visible row list is rebuilt from the catalog, and selection is
6270
+ * carried by ITEM ID rather than index: collapsing a section above the
6271
+ * cursor shifts every index below it, and re-using the old number would
6272
+ * silently move the highlight onto a different agent.
6273
+ */
6274
+ toggleAgentPickerSection(sectionKey, force = null) {
6275
+ const modal = this.getState().ui.modal;
6276
+ if (!modal || modal.type !== "sessionAgentPicker" || !sectionKey) return;
6277
+ const collapsed = new Set(modal.collapsed || []);
6278
+ const shouldCollapse = force === null ? !collapsed.has(sectionKey) : force;
6279
+ if (shouldCollapse === collapsed.has(sectionKey)) return;
6280
+ if (shouldCollapse) collapsed.add(sectionKey);
6281
+ else collapsed.delete(sectionKey);
6282
+
6283
+ const nextCollapsed = [...collapsed];
6284
+ const items = buildAgentPickerItems(modal.catalog || [], nextCollapsed, modal.genericItem || null);
6285
+ const selectedId = modal.items?.[modal.selectedIndex || 0]?.id;
6286
+ let nextIndex = items.findIndex((item) => item.id === selectedId);
6287
+ if (nextIndex < 0) {
6288
+ // The selected row was inside the section that just closed — land
6289
+ // on that section's own header, which is where it went.
6290
+ nextIndex = Math.max(0, items.findIndex((item) => item.sectionKey === sectionKey));
6291
+ }
6292
+ this.dispatch({
6293
+ type: "ui/modal",
6294
+ modal: { ...modal, collapsed: nextCollapsed, items, selectedIndex: nextIndex },
6295
+ });
6296
+ }
6297
+
6298
+ /**
6299
+ * Left/right on the agent picker. Right opens a closed section (or steps
6300
+ * into it); left closes an open one, or jumps to the parent header when
6301
+ * the cursor is already on a leaf — the same shape as the session tree.
6302
+ */
6303
+ moveAgentPickerSection(delta) {
6304
+ const modal = this.getState().ui.modal;
6305
+ if (!modal || modal.type !== "sessionAgentPicker") return false;
6306
+ const items = Array.isArray(modal.items) ? modal.items : [];
6307
+ const index = Math.max(0, Math.min(Number(modal.selectedIndex) || 0, items.length - 1));
6308
+ const item = items[index];
6309
+ if (!item) return false;
6310
+
6311
+ if (delta > 0) {
6312
+ if (item.kind === "section" && item.collapsed) {
6313
+ this.toggleAgentPickerSection(item.sectionKey, false);
6314
+ return true;
6315
+ }
6316
+ return false;
6317
+ }
6318
+
6319
+ if (item.kind === "section" && !item.collapsed) {
6320
+ this.toggleAgentPickerSection(item.sectionKey, true);
6321
+ return true;
6322
+ }
6323
+ for (let i = index - 1; i >= 0; i -= 1) {
6324
+ if (items[i].kind === "section" && items[i].depth < item.depth) {
6325
+ this.dispatch({ type: "ui/modalSelection", index: i });
6326
+ return true;
6327
+ }
6328
+ }
6329
+ return false;
6330
+ }
6331
+
5842
6332
  moveModalSelection(delta) {
5843
6333
  const modal = this.getState().ui.modal;
5844
6334
  if (!modal) return;
@@ -5883,6 +6373,10 @@ export class PilotSwarmUiController {
5883
6373
 
5884
6374
  moveModalPane(delta) {
5885
6375
  const modal = this.getState().ui.modal;
6376
+ if (modal?.type === "sessionAgentPicker") {
6377
+ this.moveAgentPickerSection(delta);
6378
+ return;
6379
+ }
5886
6380
  if (!modal || (modal.type !== "logFilter" && modal.type !== "filesFilter" && modal.type !== "historyFormat") || !Array.isArray(modal.items) || modal.items.length === 0) return;
5887
6381
  const current = Math.max(0, Number(modal.selectedIndex) || 0);
5888
6382
  const next = (current + delta + modal.items.length) % modal.items.length;
@@ -6052,6 +6546,21 @@ export class PilotSwarmUiController {
6052
6546
  }
6053
6547
  if (modal.type === "sessionAgentPicker") {
6054
6548
  const item = modal.items?.[modal.selectedIndex || 0];
6549
+ // Enter on a heading opens or closes it; the dialog stays up.
6550
+ if (item?.kind === "section") {
6551
+ this.toggleAgentPickerSection(item.sectionKey);
6552
+ return;
6553
+ }
6554
+ // A called-only agent is rendered so the package's composition is
6555
+ // visible, but starting one cold is not a thing you can do. Refuse
6556
+ // here rather than letting the create fail after the dialog closes.
6557
+ if (item && item.kind !== "generic" && item.supportsDirectStart === false) {
6558
+ this.dispatch({
6559
+ type: "ui/status",
6560
+ text: `${item.title || item.agentName} is created by another agent and cannot be started on its own`,
6561
+ });
6562
+ return;
6563
+ }
6055
6564
  const previousFocus = modal.previousFocus;
6056
6565
  const sessionOptions = modal.sessionOptions || {};
6057
6566
  this.dispatch({ type: "ui/modal", modal: null });