pilotswarm 0.5.28 → 0.5.30

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 (135) hide show
  1. package/mcp/dist/bin/pilotswarm-mcp.js +4 -0
  2. package/mcp/dist/bin/pilotswarm-mcp.js.map +1 -1
  3. package/mcp/dist/src/context.d.ts +43 -10
  4. package/mcp/dist/src/context.d.ts.map +1 -1
  5. package/mcp/dist/src/context.js +15 -7
  6. package/mcp/dist/src/context.js.map +1 -1
  7. package/mcp/dist/src/resources/artifacts.d.ts.map +1 -1
  8. package/mcp/dist/src/resources/artifacts.js +4 -3
  9. package/mcp/dist/src/resources/artifacts.js.map +1 -1
  10. package/mcp/dist/src/server.d.ts.map +1 -1
  11. package/mcp/dist/src/server.js +2 -0
  12. package/mcp/dist/src/server.js.map +1 -1
  13. package/mcp/dist/src/tools/agent-packages.d.ts +14 -0
  14. package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -0
  15. package/mcp/dist/src/tools/agent-packages.js +126 -0
  16. package/mcp/dist/src/tools/agent-packages.js.map +1 -0
  17. package/mcp/dist/src/tools/agents.d.ts.map +1 -1
  18. package/mcp/dist/src/tools/agents.js +18 -0
  19. package/mcp/dist/src/tools/agents.js.map +1 -1
  20. package/mcp/dist/src/tools/artifacts.d.ts.map +1 -1
  21. package/mcp/dist/src/tools/artifacts.js +12 -11
  22. package/mcp/dist/src/tools/artifacts.js.map +1 -1
  23. package/mcp/dist/src/tools/capabilities.js +1 -1
  24. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  25. package/mcp/dist/src/tools/observability.js +1 -1
  26. package/mcp/dist/src/tools/observability.js.map +1 -1
  27. package/mcp/dist/src/tools/sessions.js +2 -2
  28. package/mcp/dist/src/tools/sessions.js.map +1 -1
  29. package/mcp/dist/src/tools/system.d.ts.map +1 -1
  30. package/mcp/dist/src/tools/system.js +56 -5
  31. package/mcp/dist/src/tools/system.js.map +1 -1
  32. package/mcp/dist/src/tools/turn-control.js +1 -1
  33. package/mcp/dist/src/tools/turn-control.js.map +1 -1
  34. package/mcp/dist/src/util/command.d.ts +3 -3
  35. package/mcp/dist/src/util/command.d.ts.map +1 -1
  36. package/mcp/dist/src/util/command.js +1 -1
  37. package/mcp/dist/src/util/command.js.map +1 -1
  38. package/package.json +6 -6
  39. package/tui/bin/tui.js +6 -0
  40. package/tui/src/agents-cli.js +442 -0
  41. package/tui/src/app.js +44 -1
  42. package/tui/src/node-sdk-transport.js +386 -5
  43. package/tui/src/plugin-config.js +17 -0
  44. package/ui/core/src/commands.js +7 -0
  45. package/ui/core/src/controller.js +498 -33
  46. package/ui/core/src/formatting.js +60 -16
  47. package/ui/core/src/index.js +2 -0
  48. package/ui/core/src/reducer.js +421 -34
  49. package/ui/core/src/repo-import.js +298 -0
  50. package/ui/core/src/repo-links.js +54 -0
  51. package/ui/core/src/selectors.js +879 -91
  52. package/ui/core/src/session-tree.js +109 -8
  53. package/ui/core/src/state.js +108 -2
  54. package/ui/core/src/themes/helpers.js +4 -1
  55. package/ui/core/src/themes/index.js +4 -10
  56. package/ui/core/src/themes/workspace-dark-rich.js +65 -0
  57. package/ui/core/src/themes/workspace-dark.js +65 -0
  58. package/ui/react/src/components.js +181 -4
  59. package/ui/react/src/web-app.js +1410 -91
  60. package/web/api/router.js +20 -2
  61. package/web/authz.js +4 -10
  62. package/web/dist/assets/{abnfDiagram-VRR7QNED-DLV45wH4.js → abnfDiagram-VRR7QNED-Cimc0f-u.js} +1 -1
  63. package/web/dist/assets/{arc-CE4xAYdE.js → arc-D8-_Dx5J.js} +1 -1
  64. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DfzR8-9F.js → architectureDiagram-ZJ3FMSHR-lzMAuQHJ.js} +1 -1
  65. package/web/dist/assets/{blockDiagram-677ZJIJ3-C2pZdqvH.js → blockDiagram-677ZJIJ3-Dp2CWjVS.js} +1 -1
  66. package/web/dist/assets/{c4Diagram-LMCZKHZV-CKRspTan.js → c4Diagram-LMCZKHZV-DF93UkOy.js} +1 -1
  67. package/web/dist/assets/channel-OwWeiCox.js +1 -0
  68. package/web/dist/assets/{chunk-2Q5K7J3B-CNjLrfHT.js → chunk-2Q5K7J3B-pbsQRM-F.js} +1 -1
  69. package/web/dist/assets/{chunk-32BRIVSS-Bv5EwyaJ.js → chunk-32BRIVSS-B_f-gG1O.js} +1 -1
  70. package/web/dist/assets/{chunk-5VM5RSS4-NDBesEbJ.js → chunk-5VM5RSS4-gqlpJDmt.js} +1 -1
  71. package/web/dist/assets/{chunk-EX3LRPZG-DHQht9ty.js → chunk-EX3LRPZG-Cms7hPmM.js} +1 -1
  72. package/web/dist/assets/{chunk-JWPE2WC7-r0OQpJRF.js → chunk-JWPE2WC7-Boq5PtPI.js} +1 -1
  73. package/web/dist/assets/{chunk-MOJQB5TN-fcwIDgtl.js → chunk-MOJQB5TN-DVn9i8zt.js} +1 -1
  74. package/web/dist/assets/{chunk-RYQCIY6F-x7XQlDO1.js → chunk-RYQCIY6F-a1vKs3UX.js} +1 -1
  75. package/web/dist/assets/{chunk-V7JOEXUC-U8ZNVy1m.js → chunk-V7JOEXUC-i60p5fHd.js} +1 -1
  76. package/web/dist/assets/{chunk-VR4S4FIN-DLEdPLcN.js → chunk-VR4S4FIN-a8p0o-8B.js} +1 -1
  77. package/web/dist/assets/{chunk-XXDRQBXY-TeyAtB-i.js → chunk-XXDRQBXY-BwP1yBKd.js} +1 -1
  78. package/web/dist/assets/classDiagram-OUVF2IWQ-CDaP7nuC.js +1 -0
  79. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CDaP7nuC.js +1 -0
  80. package/web/dist/assets/{cose-bilkent-JH36ORCC-B3bAl1-Z.js → cose-bilkent-JH36ORCC-HW6kGgze.js} +1 -1
  81. package/web/dist/assets/{cynefin-VYW2F7L2-DexP8nG-.js → cynefin-VYW2F7L2-YUFMn-vE.js} +1 -1
  82. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Doa2XnSs.js → cynefinDiagram-TSTJHNR4-Brdy17i4.js} +1 -1
  83. package/web/dist/assets/{dagre-VKFMJZFB-DZMBKQcJ.js → dagre-VKFMJZFB-CAiSJJj-.js} +1 -1
  84. package/web/dist/assets/{diagram-FQU43EPY-BkzibmSq.js → diagram-FQU43EPY-B63Cqe0J.js} +1 -1
  85. package/web/dist/assets/{diagram-G47NLZAW-CihyHR3a.js → diagram-G47NLZAW-ByktrDJ2.js} +1 -1
  86. package/web/dist/assets/{diagram-NH7WQ7WH-OMDUUxwW.js → diagram-NH7WQ7WH-BLSfRMoE.js} +1 -1
  87. package/web/dist/assets/{diagram-OA4YK3LP-Lc0imJba.js → diagram-OA4YK3LP-DNVu-zpk.js} +1 -1
  88. package/web/dist/assets/{diagram-WEI45ONY-Dlcy8hPe.js → diagram-WEI45ONY-CNf4Sz-I.js} +1 -1
  89. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-Dh3u17Dg.js → ebnfDiagram-CCIWWBDH-9CT3ergx.js} +1 -1
  90. package/web/dist/assets/{erDiagram-Q63AITRT-C0OhlUC2.js → erDiagram-Q63AITRT-BPJHAMgl.js} +1 -1
  91. package/web/dist/assets/{flowDiagram-23GEKE2U-CADKxT-x.js → flowDiagram-23GEKE2U-BVtpr6Iu.js} +1 -1
  92. package/web/dist/assets/{ganttDiagram-NO4QXBWP-iVVdxWB7.js → ganttDiagram-NO4QXBWP-BTHnv9sV.js} +1 -1
  93. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BwDUec_a.js → gitGraphDiagram-IHSO6WYX-BfmtqbL5.js} +1 -1
  94. package/web/dist/assets/index-B37T1c2s.css +1 -0
  95. package/web/dist/assets/index-Bmb8qK39.js +24 -0
  96. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CGPUif0s.js → infoDiagram-FWYZ7A6U-kU4i4HRu.js} +1 -1
  97. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-QYnrl-KA.js → ishikawaDiagram-FXEZZL3T-Ch7i-E8O.js} +1 -1
  98. package/web/dist/assets/{journeyDiagram-5HDEW3XC-CeKixxL0.js → journeyDiagram-5HDEW3XC-CPQxp0ps.js} +1 -1
  99. package/web/dist/assets/{kanban-definition-HUTT4EX6-CeYokFBS.js → kanban-definition-HUTT4EX6-BBgMVqoJ.js} +1 -1
  100. package/web/dist/assets/{linear-BbA6_gnz.js → linear-rwksvzXJ.js} +1 -1
  101. package/web/dist/assets/{mermaid.core--xGRoQTa.js → mermaid.core-o7PszRUc.js} +4 -4
  102. package/web/dist/assets/{mindmap-definition-LN4V7U3C-D53u8h0z.js → mindmap-definition-LN4V7U3C-wgOTdzlq.js} +1 -1
  103. package/web/dist/assets/{papaparse.min-BU0o7jXn.js → papaparse.min-BRXtjEI6.js} +1 -1
  104. package/web/dist/assets/{pegDiagram-2B236MQR-B7QSyG-3.js → pegDiagram-2B236MQR-BkhQqqTm.js} +1 -1
  105. package/web/dist/assets/{pieDiagram-ENE6RG2P-XtSGL7GG.js → pieDiagram-ENE6RG2P-CcMnvxUz.js} +1 -1
  106. package/web/dist/assets/pilotswarm-DYXtzuUN.js +99 -0
  107. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-LZoYSbTH.js → quadrantDiagram-ABIIQ3AL-_KD1QeEr.js} +1 -1
  108. package/web/dist/assets/{railroadDiagram-RFXS5EU6-B5FREx2U.js → railroadDiagram-RFXS5EU6-CqI0olIe.js} +1 -1
  109. package/web/dist/assets/{requirementDiagram-TGXJPOKE-1VRFkBQX.js → requirementDiagram-TGXJPOKE-1JxRwusF.js} +1 -1
  110. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-J456gWvu.js → sankeyDiagram-HTMAVEWB-9IpFoKzi.js} +1 -1
  111. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CaCcw6KO.js → sequenceDiagram-DBY2YBRQ-teyObSQ9.js} +1 -1
  112. package/web/dist/assets/{sizeCapture-X5ZJPWSS-DClsP3_O.js → sizeCapture-X5ZJPWSS-DVhU2J_6.js} +1 -1
  113. package/web/dist/assets/{stateDiagram-2N3HPSRC-D6VyrKhd.js → stateDiagram-2N3HPSRC-DHPoWpIQ.js} +1 -1
  114. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DJjZWYLS.js +1 -0
  115. package/web/dist/assets/{swimlanes-5IMT3BWC-q678iAuo.js → swimlanes-5IMT3BWC-BxTtDQoB.js} +2 -2
  116. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CV12vDcK.js +8 -0
  117. package/web/dist/assets/{timeline-definition-FHXFAJF6-WnPK_zhe.js → timeline-definition-FHXFAJF6-D0wXZx-4.js} +1 -1
  118. package/web/dist/assets/{vennDiagram-L72KCM5P-WpumuLld.js → vennDiagram-L72KCM5P-BSFyI3iV.js} +1 -1
  119. package/web/dist/assets/{wardleyDiagram-EHGQE667-CBrYLUm_.js → wardleyDiagram-EHGQE667-BN-S7hh0.js} +1 -1
  120. package/web/dist/assets/{xychartDiagram-FW5EYKEG-DbX1rnDW.js → xychartDiagram-FW5EYKEG-nDNuPs7o.js} +1 -1
  121. package/web/dist/index.html +3 -3
  122. package/web/runtime.js +36 -4
  123. package/ui/core/src/themes/daylight.js +0 -62
  124. package/ui/core/src/themes/light-high-contrast.js +0 -65
  125. package/ui/core/src/themes/paper-ink.js +0 -62
  126. package/ui/core/src/themes/solarized-ops.js +0 -59
  127. package/ui/core/src/themes/tokyo-night.js +0 -56
  128. package/web/dist/assets/channel-D7p-E4El.js +0 -1
  129. package/web/dist/assets/classDiagram-OUVF2IWQ-yFZ-1yMb.js +0 -1
  130. package/web/dist/assets/classDiagram-v2-EOCWNBFH-yFZ-1yMb.js +0 -1
  131. package/web/dist/assets/index-BGWTu1KK.css +0 -1
  132. package/web/dist/assets/index-Djfdr7ts.js +0 -24
  133. package/web/dist/assets/pilotswarm-CtO819QH.js +0 -94
  134. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-D7eKFhHb.js +0 -1
  135. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DlVHQBQx.js +0 -8
@@ -1,4 +1,4 @@
1
- import { buildSessionTree } from "./session-tree.js";
1
+ import { buildSessionTree, isManuallyOrderableSession } from "./session-tree.js";
2
2
  import { FOCUS_REGIONS } from "./commands.js";
3
3
  import { DEFAULT_HISTORY_EVENT_LIMIT, dedupeChatMessages } from "./history.js";
4
4
  import { getPromptInputRows } from "./layout.js";
@@ -11,6 +11,8 @@ import {
11
11
  normalizeStoredCollapsedSessionIds,
12
12
  normalizeStoredLayoutAdjustments,
13
13
  normalizeStoredPinnedSessionIds,
14
+ normalizeStoredSessionOrder,
15
+ createInitialState,
14
16
  } from "./state.js";
15
17
 
16
18
  function cloneHistoryMap(historyMap) {
@@ -55,29 +57,43 @@ function prunePinnedIds(ids, byId) {
55
57
  return pruneIdList(ids, byId).filter((sessionId) => isPinnableSession(byId[sessionId]));
56
58
  }
57
59
 
58
- function collectCollapsibleSessionIds(sessions = []) {
59
- const byId = new Set((sessions || []).map((session) => session?.sessionId).filter(Boolean));
60
- const collapsibleIds = new Set();
61
- for (const session of sessions || []) {
62
- if (!session?.sessionId) continue;
63
- if (session.isGroup) collapsibleIds.add(session.sessionId);
64
- const parentSessionId = session.parentSessionId;
65
- if (parentSessionId && byId.has(parentSessionId)) {
66
- collapsibleIds.add(parentSessionId);
67
- }
68
- }
69
- return collapsibleIds;
70
- }
71
60
 
72
- function pruneCollapsedIds(collapsedIds, byId) {
73
- const collapsibleIds = collectCollapsibleSessionIds(Object.values(byId || {}));
74
- const out = new Set();
75
- for (const id of collapsedIds || []) {
76
- if (collapsibleIds.has(id)) out.add(id);
77
- }
78
- return out;
61
+ /**
62
+ * Keep a collapse flag unless the session is genuinely gone.
63
+ *
64
+ * "Collapsible" means the row is a folder OR has at least one LOADED child.
65
+ * Sub-agent children arrive lazily, so a parent is routinely not collapsible
66
+ * yet at the moment the profile is applied — dropping its flag there is why
67
+ * parent/sub-agent expansion never survived a reload, while folders (whose
68
+ * rows load with the listing) mostly did.
69
+ *
70
+ * So: a row we cannot currently see keeps its state. Not-loaded, filtered
71
+ * out, on another page, or simply slower to arrive are all indistinguishable
72
+ * from here, and none of them is the user changing their mind. The flag is a
73
+ * preference about a row, not a fact about the current listing.
74
+ *
75
+ * Nothing prunes on deletion either, and deliberately: there is no signal
76
+ * that means "deleted" as opposed to "not in this listing". A row missing
77
+ * from sessions/loaded may have been filtered, paged out, or simply not
78
+ * arrived yet, and treating that as deletion is precisely the bug above. A
79
+ * stale id costs one string and orders nothing; a wrongly dropped one costs
80
+ * the user's layout. If a session really is gone its id is inert, and if it
81
+ * comes back it returns exactly as the user left it.
82
+ *
83
+ * What protects you when something vanishes UNDER you is separate and
84
+ * already here: reconcileCollapsedIdsForActiveSession force-expands every
85
+ * ancestor of the active session, so the row you are actually looking at can
86
+ * never end up hidden inside a collapsed parent.
87
+ */
88
+ function pruneCollapsedIds(collapsedIds) {
89
+ // Keeps everything. "Loaded but not collapsible yet" is the common case
90
+ // for a parent whose children have not arrived, so even that cannot be
91
+ // used to drop a flag — which is what made this function's original
92
+ // filter lose parent/sub-agent expansion on every reload.
93
+ return cloneCollapsedIds(collapsedIds);
79
94
  }
80
95
 
96
+
81
97
  function collectSessionAncestorIds(sessionId, byId) {
82
98
  const ancestors = [];
83
99
  const seen = new Set([sessionId]);
@@ -402,6 +418,14 @@ function resolveVisibleActiveSessionId(state, fallbackSessions = []) {
402
418
  if (currentSessionId && state.sessions?.byId?.[currentSessionId] && !hasSessionVisibilityFilter(state.sessions)) {
403
419
  return currentSessionId;
404
420
  }
421
+ // A selection restored from the profile names a session the listing has
422
+ // not delivered yet — the folder listing routinely lands first, so the
423
+ // restored id was judged "not visible" and replaced by the default, which
424
+ // then got saved over it. Hold it until a sessions listing has actually
425
+ // arrived; only then is absence evidence the session is gone.
426
+ if (currentSessionId && !state.sessions?.listingSeen) {
427
+ return currentSessionId;
428
+ }
405
429
  if (hasSessionVisibilityFilter(state.sessions)) {
406
430
  return visibleRows[0]?.sessionId || null;
407
431
  }
@@ -465,7 +489,7 @@ function applyVisibleSessionSelection(state, nextSessions) {
465
489
  sessions = {
466
490
  ...sessions,
467
491
  collapsedIds: expandedCollapsedIds,
468
- flat: buildSessionTree(Object.values(sessions.byId || {}), expandedCollapsedIds, sessions.orderById, sessions.pinnedIds),
492
+ flat: buildSessionTree(Object.values(sessions.byId || {}), expandedCollapsedIds, sessions.orderById, sessions.pinnedIds, sessions.manualOrder),
469
493
  };
470
494
  }
471
495
  }
@@ -494,7 +518,7 @@ function applyVisibleSessionSelection(state, nextSessions) {
494
518
  ? {
495
519
  ...sessions,
496
520
  collapsedIds: nextCollapsedIds,
497
- flat: buildSessionTree(Object.values(sessions.byId || {}), nextCollapsedIds, sessions.orderById, sessions.pinnedIds),
521
+ flat: buildSessionTree(Object.values(sessions.byId || {}), nextCollapsedIds, sessions.orderById, sessions.pinnedIds, sessions.manualOrder),
498
522
  }
499
523
  : sessions;
500
524
  return {
@@ -636,11 +660,13 @@ export function appReducer(state, action) {
636
660
  && settings.themeId.trim();
637
661
  const hasOwnerFilter = Object.prototype.hasOwnProperty.call(settings, "sessionOwnerFilter");
638
662
  const hasLayout = Object.prototype.hasOwnProperty.call(settings, "layoutAdjustments");
663
+ // "rich" retired as a view mode (now theme.richChat) — stored
664
+ // profiles that still say "rich" simply keep the transcript.
639
665
  const hasChatViewMode = Object.prototype.hasOwnProperty.call(settings, "chatViewMode")
640
- && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript"
641
- || settings.chatViewMode === "rich");
666
+ && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript");
642
667
  const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
643
668
  const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
669
+ const hasSessionOrder = Object.prototype.hasOwnProperty.call(settings, "sessionOrder");
644
670
  // A pending/resolved deep-link intent outranks the profile's
645
671
  // persisted activeSessionId — a remote profile poll must not
646
672
  // yank selection away from the linked session.
@@ -667,6 +693,9 @@ export function appReducer(state, action) {
667
693
  ? pruneCollapsedIds(normalizeStoredCollapsedSessionIds(settings.collapsedSessionIds), state.sessions.byId)
668
694
  : normalizeStoredCollapsedSessionIds(settings.collapsedSessionIds))
669
695
  : state.sessions.collapsedIds;
696
+ const nextManualOrder = hasSessionOrder
697
+ ? normalizeStoredSessionOrder(settings.sessionOrder)
698
+ : state.sessions.manualOrder;
670
699
  const nextActiveSessionId = hasActive
671
700
  ? normalizeStoredActiveSessionId(settings.activeSessionId)
672
701
  : state.sessions.activeSessionId;
@@ -679,11 +708,12 @@ export function appReducer(state, action) {
679
708
  }
680
709
  : {}),
681
710
  pinnedIds: nextPinnedIds,
711
+ manualOrder: nextManualOrder,
682
712
  collapsedIds: nextCollapsedIds,
683
713
  collapsedIdsExplicit: hasCollapsed ? true : state.sessions.collapsedIdsExplicit,
684
714
  activeSessionId: nextActiveSessionId,
685
- flat: (hasPins || hasCollapsed)
686
- ? buildSessionTree(Object.values(state.sessions.byId), nextCollapsedIds, state.sessions.orderById, nextPinnedIds)
715
+ flat: (hasPins || hasCollapsed || hasSessionOrder)
716
+ ? buildSessionTree(Object.values(state.sessions.byId), nextCollapsedIds, state.sessions.orderById, nextPinnedIds, nextManualOrder)
687
717
  : state.sessions.flat,
688
718
  };
689
719
  const selection = hasLoadedSessions && (hasOwnerFilter || hasPins || hasCollapsed || hasActive)
@@ -702,7 +732,7 @@ export function appReducer(state, action) {
702
732
  }
703
733
 
704
734
  case "ui/chatViewMode":
705
- if (action.mode !== "summary" && action.mode !== "transcript" && action.mode !== "rich") {
735
+ if (action.mode !== "summary" && action.mode !== "transcript") {
706
736
  return state;
707
737
  }
708
738
  return {
@@ -954,6 +984,12 @@ export function appReducer(state, action) {
954
984
  };
955
985
  }
956
986
 
987
+ case "ui/nodeMapSelect": {
988
+ // Set, never toggle: a node is always selected while the Node Map
989
+ // is up, and the remembered choice survives leaving the tab.
990
+ if (!action.label) return state;
991
+ return { ...state, ui: { ...state.ui, nodeMapSelectedNode: String(action.label) } };
992
+ }
957
993
  case "ui/inspectorTab":
958
994
  return {
959
995
  ...state,
@@ -1058,6 +1094,14 @@ export function appReducer(state, action) {
1058
1094
  const byId = {};
1059
1095
  let anyChanged = false;
1060
1096
  const nowMs = Date.now();
1097
+ // Folders first: this case rebuilds byId from the payload, so a
1098
+ // session refresh that carries no groups would otherwise delete
1099
+ // every folder until the next group fetch — the "flickers in and
1100
+ // out" bug. They are re-seeded from their own slice and can only
1101
+ // be removed by sessions/groupsLoaded.
1102
+ for (const groupRow of (state.sessions.groupRows || [])) {
1103
+ byId[groupRow.sessionId] = state.sessions.byId[groupRow.sessionId] || groupRow;
1104
+ }
1061
1105
  for (const session of action.sessions) {
1062
1106
  const previous = state.sessions.byId[session.sessionId];
1063
1107
  const merged = mergeSessionRowVisualStatus(
@@ -1106,7 +1150,21 @@ export function appReducer(state, action) {
1106
1150
  const collapsedIds = previousCollapsedIdsExplicit
1107
1151
  ? cloneCollapsedIds(state.sessions.collapsedIds)
1108
1152
  : initialCollapsedIds;
1109
- if (previousCollapsedIdsExplicit) {
1153
+ // A row that ARRIVES while the app is open starts collapsed, even
1154
+ // for a user with explicit preferences — a new sub-tree should not
1155
+ // spill open on its own.
1156
+ //
1157
+ // "Arrives" is only meaningful against a baseline. On the FIRST
1158
+ // listing after a page load there is none (byId is empty), so
1159
+ // every row looked new and this force-collapsed the whole tree
1160
+ // over the profile that had just been restored. The save effect
1161
+ // then persisted that, and the next load read its own corruption
1162
+ // back as the user's preference — a one-way ratchet, which is why
1163
+ // one refresh looked fine and the one after it collapsed
1164
+ // everything. With no baseline we cannot tell new from
1165
+ // pre-existing, so we add nothing and trust the restored state.
1166
+ const hadBaseline = state.sessions.listingSeen === true;
1167
+ if (previousCollapsedIdsExplicit && hadBaseline) {
1110
1168
  const previousDefaultCollapsedIds = collectDefaultCollapsedSessionIds(Object.values(state.sessions.byId));
1111
1169
  for (const sessionId of initialCollapsedIds) {
1112
1170
  if (!previousDefaultCollapsedIds.has(sessionId)) {
@@ -1119,12 +1177,13 @@ export function appReducer(state, action) {
1119
1177
  // sessions). Sessions inside containers lose their pins.
1120
1178
  const survivingPins = prunePinnedIds(state.sessions.pinnedIds, byId);
1121
1179
  const survivingSelected = pruneIdList(state.sessions.selectedIds, byId);
1122
- const flat = buildSessionTree(mergedSessions, collapsedIds, orderById, survivingPins);
1180
+ const flat = buildSessionTree(mergedSessions, collapsedIds, orderById, survivingPins, state.sessions.manualOrder);
1123
1181
  const nextSessions = {
1124
1182
  ...state.sessions,
1125
1183
  byId,
1126
1184
  collapsedIds,
1127
1185
  collapsedIdsExplicit: previousCollapsedIdsExplicit,
1186
+ listingSeen: true,
1128
1187
  pinnedIds: survivingPins,
1129
1188
  selectedIds: survivingSelected,
1130
1189
  selectMode: state.sessions.selectMode && survivingSelected.length > 0,
@@ -1141,6 +1200,87 @@ export function appReducer(state, action) {
1141
1200
  };
1142
1201
  }
1143
1202
 
1203
+ case "sessions/groupsLoaded": {
1204
+ // The ONLY writer of folder rows. A successful fetch is the whole
1205
+ // truth: folders it omits are gone, folders it names are kept.
1206
+ const groups = Array.isArray(action.groups) ? action.groups : [];
1207
+ const byId = { ...state.sessions.byId };
1208
+ // A folder the fetch omits is gone — UNLESS a loaded session still
1209
+ // claims membership in it. Deleting one that is still referenced
1210
+ // was visibly destructive: the titled row was replaced by a
1211
+ // generic stand-in ("Group"), and because collapse state is keyed
1212
+ // by row id, the pruned entry took the group's COLLAPSED state
1213
+ // with it, so the whole group sprang open and dumped its members
1214
+ // into the list. One transient empty-but-successful listing did
1215
+ // all of that. Keep the row we already have; the stand-in stays
1216
+ // the last resort for a folder we have never seen.
1217
+ //
1218
+ // Judge that against the INCOMING catalog when the refresh sends
1219
+ // it: this action lands before sessions/loaded, so state still
1220
+ // holds the PREVIOUS membership, and a folder whose last member
1221
+ // had just moved out looked claimed by its own stale members and
1222
+ // survived its deletion forever.
1223
+ const claimSource = Array.isArray(action.sessions)
1224
+ ? action.sessions
1225
+ : Object.values(state.sessions.byId);
1226
+ const claimedByLoadedSessions = new Set(
1227
+ claimSource
1228
+ .filter((session) => session && !session.isGroup && session.groupId && !session.parentSessionId)
1229
+ .map((session) => `group:${session.groupId}`),
1230
+ );
1231
+ const keptRows = [];
1232
+ for (const key of Object.keys(byId)) {
1233
+ if (!byId[key]?.isGroup) continue;
1234
+ if (groups.some((row) => row.sessionId === key)) continue;
1235
+ if (claimedByLoadedSessions.has(key)) { keptRows.push(byId[key]); continue; }
1236
+ delete byId[key];
1237
+ }
1238
+ for (const row of groups) {
1239
+ byId[row.sessionId] = { ...(byId[row.sessionId] || {}), ...row };
1240
+ }
1241
+ const survivingPins = prunePinnedIds(state.sessions.pinnedIds, byId);
1242
+ const { orderById, nextOrderOrdinal } = assignStableSessionOrder(
1243
+ state.sessions.orderById,
1244
+ state.sessions.nextOrderOrdinal,
1245
+ Object.values(byId),
1246
+ );
1247
+ // A folder the state has not seen before starts COLLAPSED, exactly
1248
+ // as it does on first load — otherwise a folder that round-trips
1249
+ // (deleted by one listing, restored by the next) comes back open
1250
+ // and spills its members into the list.
1251
+ //
1252
+ // "Not seen before" needs something to have been seen. On a fresh
1253
+ // page load byId is empty, so every folder qualified and this
1254
+ // re-collapsed everything the user had left open, on top of the
1255
+ // profile that had just been restored. Same baseline rule as
1256
+ // sessions/loaded: with nothing to compare against, add nothing.
1257
+ //
1258
+ // (An earlier attempt keyed this off collapsedIdsExplicit, which
1259
+ // fixed the reload but also stopped a genuinely new folder —
1260
+ // created while the app is open — from starting collapsed. The
1261
+ // baseline test gets both right.)
1262
+ const hadGroupBaseline = state.sessions.groupsListingSeen === true;
1263
+ const nextCollapsedIds = cloneCollapsedIds(state.sessions.collapsedIds);
1264
+ for (const row of groups) {
1265
+ if (!hadGroupBaseline) continue;
1266
+ if (!state.sessions.byId[row.sessionId]) nextCollapsedIds.add(row.sessionId);
1267
+ }
1268
+ const nextSessions = {
1269
+ ...state.sessions,
1270
+ // sessions/loaded re-seeds folders from this slice, so a kept
1271
+ // row has to live here too or the next session refresh drops it.
1272
+ groupRows: [...groups, ...keptRows],
1273
+ byId,
1274
+ pinnedIds: survivingPins,
1275
+ orderById,
1276
+ nextOrderOrdinal,
1277
+ collapsedIds: nextCollapsedIds,
1278
+ groupsListingSeen: true,
1279
+ flat: buildSessionTree(Object.values(byId), nextCollapsedIds, orderById, survivingPins, state.sessions.manualOrder),
1280
+ };
1281
+ const groupSelection = applyVisibleSessionSelection(state, nextSessions);
1282
+ return { ...state, sessions: groupSelection.sessions, ui: groupSelection.ui };
1283
+ }
1144
1284
  case "sessions/merged": {
1145
1285
  if (!action.session?.sessionId) return state;
1146
1286
  const previousSession = state.sessions.byId[action.session.sessionId];
@@ -1167,7 +1307,7 @@ export function appReducer(state, action) {
1167
1307
  ...state.sessions,
1168
1308
  byId,
1169
1309
  pinnedIds: survivingPins,
1170
- flat: buildSessionTree(Object.values(byId), state.sessions.collapsedIds, orderById, survivingPins),
1310
+ flat: buildSessionTree(Object.values(byId), state.sessions.collapsedIds, orderById, survivingPins, state.sessions.manualOrder),
1171
1311
  activeSessionId: state.sessions.activeSessionId,
1172
1312
  orderById,
1173
1313
  nextOrderOrdinal,
@@ -1181,6 +1321,7 @@ export function appReducer(state, action) {
1181
1321
  }
1182
1322
 
1183
1323
  case "sessions/selected": {
1324
+ state = { ...state, sessions: { ...state.sessions, listDeselected: false } };
1184
1325
  // Per-session chat scroll memory: stash the outgoing session's
1185
1326
  // offset and restore the incoming one's. If new chat arrives on
1186
1327
  // re-entry, history/set's activeChatUpdated reset still snaps to
@@ -1254,7 +1395,7 @@ export function appReducer(state, action) {
1254
1395
  ...state.sessions,
1255
1396
  collapsedIds,
1256
1397
  collapsedIdsExplicit: true,
1257
- flat: buildSessionTree(Object.values(state.sessions.byId), collapsedIds, state.sessions.orderById, state.sessions.pinnedIds),
1398
+ flat: buildSessionTree(Object.values(state.sessions.byId), collapsedIds, state.sessions.orderById, state.sessions.pinnedIds, state.sessions.manualOrder),
1258
1399
  },
1259
1400
  };
1260
1401
  }
@@ -1268,7 +1409,7 @@ export function appReducer(state, action) {
1268
1409
  ...state.sessions,
1269
1410
  collapsedIds,
1270
1411
  collapsedIdsExplicit: true,
1271
- flat: buildSessionTree(Object.values(state.sessions.byId), collapsedIds, state.sessions.orderById, state.sessions.pinnedIds),
1412
+ flat: buildSessionTree(Object.values(state.sessions.byId), collapsedIds, state.sessions.orderById, state.sessions.pinnedIds, state.sessions.manualOrder),
1272
1413
  },
1273
1414
  };
1274
1415
  }
@@ -1290,7 +1431,64 @@ export function appReducer(state, action) {
1290
1431
  sessions: {
1291
1432
  ...state.sessions,
1292
1433
  pinnedIds: nextPinned,
1293
- flat: buildSessionTree(Object.values(state.sessions.byId), state.sessions.collapsedIds, state.sessions.orderById, nextPinned),
1434
+ flat: buildSessionTree(Object.values(state.sessions.byId), state.sessions.collapsedIds, state.sessions.orderById, nextPinned, state.sessions.manualOrder),
1435
+ },
1436
+ };
1437
+ }
1438
+
1439
+ // Drop `sessionId` immediately before `beforeSessionId` in the user's
1440
+ // explicit order (beforeSessionId null ⇒ last among its siblings).
1441
+ //
1442
+ // The stored list spans every level; ordering only ever compares
1443
+ // siblings, so writing a global list is safe and keeps one array to
1444
+ // persist. The reducer is deliberately permissive about ids it does
1445
+ // not recognise — the desktop is the only writer, but a phone or TUI
1446
+ // may hold a narrower slice of the fleet, and dropping unknown ids
1447
+ // here would let the smaller surface erase the larger one's placements.
1448
+ case "sessions/reorder": {
1449
+ const sessionId = String(action.sessionId || "").trim();
1450
+ if (!sessionId) return state;
1451
+ const session = state.sessions.byId[sessionId];
1452
+ // The tree ignores placements for immovable rows; refusing here
1453
+ // too keeps the stored list free of entries that can never apply.
1454
+ if (!isManuallyOrderableSession(session)) return state;
1455
+
1456
+ const beforeId = action.beforeSessionId == null
1457
+ ? null
1458
+ : String(action.beforeSessionId).trim() || null;
1459
+ if (beforeId === sessionId) return state;
1460
+
1461
+ // Seed from the CURRENT rendered order so a first drag preserves
1462
+ // what the user is looking at. Without this, moving one row would
1463
+ // leave every other row unplaced and re-sorted underneath it.
1464
+ const seed = Array.isArray(state.sessions.manualOrder) && state.sessions.manualOrder.length > 0
1465
+ ? state.sessions.manualOrder
1466
+ : (state.sessions.flat || [])
1467
+ .map((entry) => entry.sessionId)
1468
+ .filter((id) => isManuallyOrderableSession(state.sessions.byId[id]));
1469
+
1470
+ const without = seed.filter((id) => id !== sessionId);
1471
+ const insertAt = beforeId ? without.indexOf(beforeId) : -1;
1472
+ const nextManualOrder = insertAt >= 0
1473
+ ? [...without.slice(0, insertAt), sessionId, ...without.slice(insertAt)]
1474
+ : [...without, sessionId];
1475
+
1476
+ const unchanged = nextManualOrder.length === (state.sessions.manualOrder || []).length
1477
+ && nextManualOrder.every((id, index) => id === state.sessions.manualOrder[index]);
1478
+ if (unchanged) return state;
1479
+
1480
+ return {
1481
+ ...state,
1482
+ sessions: {
1483
+ ...state.sessions,
1484
+ manualOrder: nextManualOrder,
1485
+ flat: buildSessionTree(
1486
+ Object.values(state.sessions.byId),
1487
+ state.sessions.collapsedIds,
1488
+ state.sessions.orderById,
1489
+ state.sessions.pinnedIds,
1490
+ nextManualOrder,
1491
+ ),
1294
1492
  },
1295
1493
  };
1296
1494
  }
@@ -1348,6 +1546,23 @@ export function appReducer(state, action) {
1348
1546
  };
1349
1547
  }
1350
1548
 
1549
+ // Re-arm the list highlight WITHOUT re-running session selection. A
1550
+ // plain click on the still-active row after an empty-space deselect
1551
+ // cannot go through loadSession (it short-circuits on the active id),
1552
+ // so without this the row could never be selected again.
1553
+ case "sessions/listReselect": {
1554
+ if (!state.sessions.listDeselected) return state;
1555
+ return { ...state, sessions: { ...state.sessions, listDeselected: false } };
1556
+ }
1557
+
1558
+ case "sessions/listDeselect": {
1559
+ // Purely a list affordance: activeSessionId is untouched so the
1560
+ // chat, inspector and activity panes carry on unchanged.
1561
+ return {
1562
+ ...state,
1563
+ sessions: { ...state.sessions, listDeselected: true, selectedIds: [], selectMode: false },
1564
+ };
1565
+ }
1351
1566
  case "sessions/selectClear": {
1352
1567
  if (state.sessions.selectedIds.length === 0 && !state.sessions.selectMode) return state;
1353
1568
  return {
@@ -1844,6 +2059,178 @@ export function appReducer(state, action) {
1844
2059
  },
1845
2060
  };
1846
2061
  }
2062
+ case "admin/section": {
2063
+ const section = ["packages", "workers", "ghcp"].includes(action.section) ? action.section : "ghcp";
2064
+ return { ...state, admin: { ...state.admin, section } };
2065
+ }
2066
+ case "admin/workers/attempt": {
2067
+ const workers = state.admin.workers || {};
2068
+ return { ...state, admin: { ...state.admin, workers: { ...workers, attempts: (workers.attempts || 0) + 1, lastAttemptAt: Date.now(), lastSkip: action.skip || null } } };
2069
+ }
2070
+ case "admin/workers/loading": {
2071
+ return { ...state, admin: { ...state.admin, workers: { ...state.admin.workers, loading: true, error: null } } };
2072
+ }
2073
+ case "admin/workers/loaded": {
2074
+ return {
2075
+ ...state,
2076
+ admin: {
2077
+ ...state.admin,
2078
+ workers: {
2079
+ loading: false,
2080
+ error: null,
2081
+ list: Array.isArray(action.list) ? action.list : [],
2082
+ fetchedAt: Date.now(),
2083
+ },
2084
+ },
2085
+ };
2086
+ }
2087
+ case "admin/workers/loadFailed": {
2088
+ return { ...state, admin: { ...state.admin, workers: { ...state.admin.workers, loading: false, error: action.error || "Failed to load workers" } } };
2089
+ }
2090
+ case "admin/packages/loading": {
2091
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, loading: true, error: null } } };
2092
+ }
2093
+ case "admin/packages/loaded": {
2094
+ const previous = state.admin.packages;
2095
+ const list = Array.isArray(action.list) ? action.list : [];
2096
+ // Keep the selection when the package still exists; otherwise clear
2097
+ // the dependent detail/workspace state with it.
2098
+ const stillThere = previous.selectedName && list.some((p) => p.name === previous.selectedName);
2099
+ return {
2100
+ ...state,
2101
+ admin: {
2102
+ ...state.admin,
2103
+ packages: {
2104
+ ...previous,
2105
+ loading: false,
2106
+ error: null,
2107
+ list,
2108
+ workerState: Array.isArray(action.workerState) ? action.workerState : [],
2109
+ fetchedAt: Date.now(),
2110
+ selectedName: stillThere ? previous.selectedName : null,
2111
+ ...(stillThere ? {} : { detail: null, workspace: { ...previous.workspace, tree: null, selectedPath: null, file: null } }),
2112
+ },
2113
+ },
2114
+ };
2115
+ }
2116
+ case "admin/packages/loadFailed": {
2117
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, loading: false, error: action.error || "Failed to load agent packages" } } };
2118
+ }
2119
+ case "admin/packages/select": {
2120
+ return {
2121
+ ...state,
2122
+ admin: {
2123
+ ...state.admin,
2124
+ section: "packages",
2125
+ packages: {
2126
+ ...state.admin.packages,
2127
+ selectedName: action.name || null,
2128
+ detail: null,
2129
+ detailLoading: Boolean(action.name),
2130
+ detailError: null,
2131
+ action: { pending: null, error: null },
2132
+ workspace: {
2133
+ tree: null, treeLoading: Boolean(action.name), treeError: null,
2134
+ expandedDirs: [], selectedPath: null,
2135
+ file: null, fileLoading: false, fileError: null,
2136
+ },
2137
+ },
2138
+ },
2139
+ };
2140
+ }
2141
+ case "admin/packages/detail/loaded": {
2142
+ if (state.admin.packages.selectedName !== action.name) return state;
2143
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, detail: action.detail, detailLoading: false, detailError: null } } };
2144
+ }
2145
+ case "admin/packages/detail/loadFailed": {
2146
+ if (state.admin.packages.selectedName !== action.name) return state;
2147
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, detail: null, detailLoading: false, detailError: action.error || "Failed to load package" } } };
2148
+ }
2149
+ case "admin/packages/tree/loaded": {
2150
+ if (state.admin.packages.selectedName !== action.name) return state;
2151
+ const topDirs = Array.isArray(action.tree?.dirs)
2152
+ ? action.tree.dirs.filter((dir) => !dir.includes("/"))
2153
+ : [];
2154
+ return {
2155
+ ...state,
2156
+ admin: {
2157
+ ...state.admin,
2158
+ packages: {
2159
+ ...state.admin.packages,
2160
+ workspace: {
2161
+ ...state.admin.packages.workspace,
2162
+ tree: action.tree,
2163
+ treeLoading: false,
2164
+ treeError: null,
2165
+ // Top-level folders start expanded; deeper levels open on click.
2166
+ expandedDirs: topDirs,
2167
+ },
2168
+ },
2169
+ },
2170
+ };
2171
+ }
2172
+ case "admin/packages/tree/loadFailed": {
2173
+ if (state.admin.packages.selectedName !== action.name) return state;
2174
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...state.admin.packages.workspace, treeLoading: false, treeError: action.error || "Failed to load package files" } } } };
2175
+ }
2176
+ case "admin/packages/toggleDir": {
2177
+ const workspace = state.admin.packages.workspace;
2178
+ const expanded = new Set(workspace.expandedDirs);
2179
+ if (expanded.has(action.dir)) expanded.delete(action.dir);
2180
+ else expanded.add(action.dir);
2181
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...workspace, expandedDirs: [...expanded] } } } };
2182
+ }
2183
+ case "admin/packages/file/loading": {
2184
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...state.admin.packages.workspace, selectedPath: action.path, file: null, fileLoading: true, fileError: null } } } };
2185
+ }
2186
+ case "admin/packages/file/loaded": {
2187
+ if (state.admin.packages.workspace.selectedPath !== action.file?.path) return state;
2188
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...state.admin.packages.workspace, file: action.file, fileLoading: false, fileError: null } } } };
2189
+ }
2190
+ case "admin/packages/file/loadFailed": {
2191
+ if (state.admin.packages.workspace.selectedPath !== action.path) return state;
2192
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, workspace: { ...state.admin.packages.workspace, fileLoading: false, fileError: action.error || "Failed to load file" } } } };
2193
+ }
2194
+ case "admin/packages/action/pending": {
2195
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, action: { pending: action.action, error: null } } } };
2196
+ }
2197
+ case "admin/packages/action/done": {
2198
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, action: { pending: null, error: null } } } };
2199
+ }
2200
+ case "admin/packages/action/failed": {
2201
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, action: { pending: null, error: action.error || "Action failed" } } } };
2202
+ }
2203
+ case "admin/packages/addDialog/open": {
2204
+ // Update mode reuses this dialog wholesale — the work is identical
2205
+ // (point at a repo folder, import it here, publish) and only the
2206
+ // destination is already decided.
2207
+ const seeded = {
2208
+ ...createInitialState().admin.packages.addDialog,
2209
+ open: true,
2210
+ updateName: action.updateName || null,
2211
+ scope: action.scope === "shared" ? "shared" : "user",
2212
+ };
2213
+ return { ...state, admin: { ...state.admin, section: "packages", packages: { ...state.admin.packages, addDialog: seeded } } };
2214
+ }
2215
+ case "admin/packages/addDialog/close": {
2216
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, addDialog: { ...createInitialState().admin.packages.addDialog, open: false } } } };
2217
+ }
2218
+ case "admin/packages/addDialog/setField": {
2219
+ const dialog = state.admin.packages.addDialog;
2220
+ if (!(action.field in dialog)) return state;
2221
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, addDialog: { ...dialog, [action.field]: action.value, error: null } } } };
2222
+ }
2223
+ case "admin/packages/addDialog/submitting": {
2224
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, addDialog: { ...state.admin.packages.addDialog, submitting: true, progress: null, error: null } } } };
2225
+ }
2226
+ case "admin/packages/addDialog/progress": {
2227
+ // Client-side import is a multi-request walk (tree, then blobs) —
2228
+ // the dialog narrates it so a slow repo never looks hung.
2229
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, addDialog: { ...state.admin.packages.addDialog, progress: action.message || null } } } };
2230
+ }
2231
+ case "admin/packages/addDialog/failed": {
2232
+ return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, addDialog: { ...state.admin.packages.addDialog, submitting: false, progress: null, error: action.error || "Failed to import package" } } } };
2233
+ }
1847
2234
  case "admin/systemGhcpKey/saved": {
1848
2235
  const status = action.status || {};
1849
2236
  return {